Transition component (#326)

* add redent function when verifying snapshots

This allows us not to care about the correct amount of spaces and always
produces a clean output.

* make the container the parent of the wrapper element

* drop the visible prop on the Portal component

* drop visible prop on Portal component

+ Also cleanup a little bit

* expose the RenderStrategy

* implement Transition component in Vue

* expose Transition component

* add Transitions to the Dialog example
This commit is contained in:
Robin Malfait
2021-04-12 23:40:42 +02:00
committed by GitHub
parent 6fa6c4502a
commit 0a39cf6b22
21 changed files with 3057 additions and 409 deletions
@@ -453,21 +453,21 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
+ <div
+ class=\\"enter from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
+ <div
+ class=\\"enter from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
Render 2:
- class=\\"enter from\\"
+ class=\\"enter to\\"
- class=\\"enter from\\"
+ class=\\"enter to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter to\\"
+ class=\\"\\""
- class=\\"enter to\\"
+ class=\\"\\""
`)
})
@@ -503,21 +503,21 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
+ <div
+ class=\\"enter from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
+ <div
+ class=\\"enter from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
Render 2:
- class=\\"enter from\\"
+ class=\\"enter to\\"
- class=\\"enter from\\"
+ class=\\"enter to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter to\\"
+ class=\\"\\""
- class=\\"enter to\\"
+ class=\\"\\""
`)
})
@@ -553,18 +553,18 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter from\\"
+ style=\\"\\"
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter from\\"
+ style=\\"\\"
Render 2:
- class=\\"enter from\\"
+ class=\\"enter to\\"
- class=\\"enter from\\"
+ class=\\"enter to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter to\\"
+ class=\\"\\""
- class=\\"enter to\\"
+ class=\\"\\""
`)
})
@@ -599,21 +599,21 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
+ <div
+ class=\\"enter from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
+ <div
+ class=\\"enter from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
Render 2:
- class=\\"enter from\\"
+ class=\\"enter to\\"
- class=\\"enter from\\"
+ class=\\"enter to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter to\\"
+ class=\\"\\""
- class=\\"enter to\\"
+ class=\\"\\""
`)
})
@@ -650,23 +650,23 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- <div>
+ <div
+ class=\\"leave from\\"
+ >
- <div>
+ <div
+ class=\\"leave from\\"
+ >
Render 2:
- class=\\"leave from\\"
+ class=\\"leave to\\"
- class=\\"leave from\\"
+ class=\\"leave to\\"
Render 3: Transition took at least 50ms (yes)
- <div
- class=\\"leave to\\"
- >
- <span>
- Hello!
- </span>
- </div>"
- <div
- class=\\"leave to\\"
- >
- <span>
- Hello!
- </span>
- </div>"
`)
})
)
@@ -704,20 +704,20 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- <div>
+ <div
+ class=\\"leave from\\"
+ >
- <div>
+ <div
+ class=\\"leave from\\"
+ >
Render 2:
- class=\\"leave from\\"
+ class=\\"leave to\\"
- class=\\"leave from\\"
+ class=\\"leave to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"leave to\\"
+ class=\\"\\"
+ hidden=\\"\\"
+ style=\\"display: none;\\""
- class=\\"leave to\\"
+ class=\\"\\"
+ hidden=\\"\\"
+ style=\\"display: none;\\""
`)
})
)
@@ -771,38 +771,38 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
+ <div
+ class=\\"enter enter-from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
+ <div
+ class=\\"enter enter-from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
Render 2:
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter enter-to\\"
+ class=\\"\\"
- class=\\"enter enter-to\\"
+ class=\\"\\"
Render 4:
- class=\\"\\"
+ class=\\"leave leave-from\\"
- class=\\"\\"
+ class=\\"leave leave-from\\"
Render 5:
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
Render 6: Transition took at least 75ms (yes)
- <div
- class=\\"leave leave-to\\"
- >
- <span>
- Hello!
- </span>
- </div>"
- <div
- class=\\"leave leave-to\\"
- >
- <span>
- Hello!
- </span>
- </div>"
`)
})
)
@@ -863,48 +863,48 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter enter-from\\"
+ style=\\"\\"
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter enter-from\\"
+ style=\\"\\"
Render 2:
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter enter-to\\"
+ class=\\"\\"
- class=\\"enter enter-to\\"
+ class=\\"\\"
Render 4:
- class=\\"\\"
+ class=\\"leave leave-from\\"
- class=\\"\\"
+ class=\\"leave leave-from\\"
Render 5:
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
Render 6: Transition took at least 75ms (yes)
- class=\\"leave leave-to\\"
- style=\\"\\"
+ class=\\"\\"
+ hidden=\\"\\"
+ style=\\"display: none;\\"
- class=\\"leave leave-to\\"
- style=\\"\\"
+ class=\\"\\"
+ hidden=\\"\\"
+ style=\\"display: none;\\"
Render 7:
- class=\\"\\"
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter enter-from\\"
+ style=\\"\\"
- class=\\"\\"
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter enter-from\\"
+ style=\\"\\"
Render 8:
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
Render 9: Transition took at least 75ms (yes)
- class=\\"enter enter-to\\"
+ class=\\"\\""
- class=\\"enter enter-to\\"
+ class=\\"\\""
`)
})
)
@@ -956,38 +956,38 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- <div>
+ <div
+ class=\\"leave-fast leave-from\\"
+ >
- <div>
+ <div
+ class=\\"leave-fast leave-from\\"
+ >
---
- <div>
+ <div
+ class=\\"leave-slow leave-from\\"
+ >
- <div>
+ <div
+ class=\\"leave-slow leave-from\\"
+ >
Render 2:
- class=\\"leave-fast leave-from\\"
+ class=\\"leave-fast leave-to\\"
- class=\\"leave-fast leave-from\\"
+ class=\\"leave-fast leave-to\\"
---
- class=\\"leave-slow leave-from\\"
+ class=\\"leave-slow leave-to\\"
- class=\\"leave-slow leave-from\\"
+ class=\\"leave-slow leave-to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"leave-fast leave-to\\"
- >
- I am fast
- </div>
- <div
- class=\\"leave-fast leave-to\\"
- >
- I am fast
- </div>
- <div
Render 4: Transition took at least 100ms (yes)
- <div>
- <div
- class=\\"leave-slow leave-to\\"
- >
- I am slow
- </div>
- </div>"
- <div>
- <div
- class=\\"leave-slow leave-to\\"
- >
- I am slow
- </div>
- </div>"
`)
})
)
@@ -1040,50 +1040,50 @@ describe('Transitions', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- <div>
+ <div
+ class=\\"leave-fast leave-from\\"
+ >
- <div>
+ <div
+ class=\\"leave-fast leave-from\\"
+ >
---
- <div>
+ <div
+ class=\\"leave-slow\\"
+ >
- <div>
+ <div
+ class=\\"leave-slow\\"
+ >
---
- <div>
+ <div
+ class=\\"leave-slow leave-from\\"
+ >
- <div>
+ <div
+ class=\\"leave-slow leave-from\\"
+ >
Render 2:
- class=\\"leave-fast leave-from\\"
+ class=\\"leave-fast leave-to\\"
- class=\\"leave-fast leave-from\\"
+ class=\\"leave-fast leave-to\\"
---
- class=\\"leave-slow leave-from\\"
+ class=\\"leave-slow leave-to\\"
- class=\\"leave-slow leave-from\\"
+ class=\\"leave-slow leave-to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"leave-fast leave-to\\"
- >
- <span>
- I am fast
- </span>
- <div
- class=\\"leave-slow\\"
- >
- I am my own root component and I don't talk to the parent
- </div>
- </div>
- <div
- class=\\"leave-fast leave-to\\"
- >
- <span>
- I am fast
- </span>
- <div
- class=\\"leave-slow\\"
- >
- I am my own root component and I don't talk to the parent
- </div>
- </div>
- <div
Render 4: Transition took at least 100ms (yes)
- <div>
- <div
- class=\\"leave-slow leave-to\\"
- >
- I am slow
- </div>
- </div>"
- <div>
- <div
- class=\\"leave-slow leave-to\\"
- >
- I am slow
- </div>
- </div>"
`)
})
)
@@ -1151,38 +1151,38 @@ describe('Events', () => {
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
+ <div
+ class=\\"enter enter-from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
+ <div
+ class=\\"enter enter-from\\"
+ >
+ <span>
+ Hello!
+ </span>
+ </div>
Render 2:
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter enter-to\\"
+ class=\\"\\"
- class=\\"enter enter-to\\"
+ class=\\"\\"
Render 4:
- class=\\"\\"
+ class=\\"leave leave-from\\"
- class=\\"\\"
+ class=\\"leave leave-from\\"
Render 5:
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
Render 6: Transition took at least 75ms (yes)
- <div
- class=\\"leave leave-to\\"
- >
- <span>
- Hello!
- </span>
- </div>"
- <div
- class=\\"leave leave-to\\"
- >
- <span>
- Hello!
- </span>
- </div>"
`)
expect(eventHandler).toHaveBeenCalledTimes(4)
@@ -4,6 +4,25 @@ import { render } from '@testing-library/react'
import { disposables } from '../utils/disposables'
import { reportChanges } from './report-dom-node-changes'
function redentSnapshot(input: string) {
let minSpaces = Infinity
let lines = input.split('\n')
for (let line of lines) {
if (line.trim() === '---') continue
let spacesInLine = (line.match(/^[+-](\s+)/g) || []).pop()!.length - 1
minSpaces = Math.min(minSpaces, spacesInLine)
}
let replacer = new RegExp(`^([+-])\\s{${minSpaces}}(.*)`, 'g')
return input
.split('\n')
.map(line =>
line.trim() === '---' ? line : line.replace(replacer, (_, sign, rest) => `${sign} ${rest}`)
)
.join('\n')
}
export async function executeTimeline(
element: JSX.Element,
steps: ((tools: ReturnType<typeof render>) => (null | number)[])[]
@@ -95,16 +114,18 @@ export async function executeTimeline(
? 'yes'
: `no, it took ${call.relativeToPreviousSnapshot}ms`
})`
}\n${snapshotDiff(uniqueSnapshots[i - 1].content, call.content, {
aAnnotation: '__REMOVE_ME__',
bAnnotation: '__REMOVE_ME__',
contextLines: 0,
})
// Just to do some cleanup
.replace(/\n\n@@([^@@]*)@@/g, '') // Top level @@ signs
.replace(/@@([^@@]*)@@/g, '---') // In between @@ signs
.replace(/[-+] __REMOVE_ME__\n/g, '')
.replace(/Snapshot Diff:\n/g, '')
}\n${redentSnapshot(
snapshotDiff(uniqueSnapshots[i - 1].content, call.content, {
aAnnotation: '__REMOVE_ME__',
bAnnotation: '__REMOVE_ME__',
contextLines: 0,
})
// Just to do some cleanup
.replace(/\n\n@@([^@@]*)@@/g, '') // Top level @@ signs
.replace(/@@([^@@]*)@@/g, '---') // In between @@ signs
.replace(/[-+] __REMOVE_ME__\n/g, '')
.replace(/Snapshot Diff:\n/g, '')
)
.split('\n')
.map(line => ` ${line}`)
.join('\n')}`
+1
View File
@@ -38,6 +38,7 @@ _This project is still in early development. New components will be added regula
- [Dialog](./src/components/dialog/README.md)
- [Popover](./src/components/popover/README.md)
- [Radio Group](./src/components/radio-group/README.md)
- [Transition](./src/components/transitions/README.md)
### Roadmap
@@ -7,132 +7,162 @@
Toggle!
</button>
<Dialog :open="isOpen" :onClose="setIsOpen">
<div class="fixed z-10 inset-0 overflow-y-auto">
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<DialogOverlay class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</DialogOverlay>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<TransitionRoot :show="isOpen" as="template">
<Dialog :open="isOpen" @close="setIsOpen" static>
<div class="fixed z-10 inset-0 overflow-y-auto">
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<!-- Heroicon name: exclamation -->
<svg
class="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<DialogTitle as="h3" class="text-lg leading-6 font-medium text-gray-900">
Deactivate account
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your data will be
permanently removed. This action cannot be undone.
</p>
<div class="relative inline-block text-left mt-10">
<Menu v-slot="{ open }">
<span class="rounded-md shadow-sm">
<MenuButton
ref="trigger"
class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Choose a reason</span>
<svg class="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</MenuButton>
</span>
<TransitionChild
as="template"
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<DialogOverlay class="fixed inset-0 transition-opacity">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</DialogOverlay>
</TransitionChild>
<Portal v-if="open">
<MenuItems
static
ref="container"
class="z-20 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div class="px-4 py-3">
<p class="text-sm leading-5">Signed in as</p>
<p class="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<TransitionChild
enter="ease-out transform duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in transform duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<!-- This element is to trick the browser into centering the modal contents. -->
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
&#8203;
</span>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<!-- Heroicon name: exclamation -->
<svg
class="h-6 w-6 text-red-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<DialogTitle as="h3" class="text-lg leading-6 font-medium text-gray-900">
Deactivate account
</DialogTitle>
<div class="mt-2">
<p class="text-sm text-gray-500">
Are you sure you want to deactivate your account? All of your data will be
permanently removed. This action cannot be undone.
</p>
<div class="relative inline-block text-left mt-10">
<Menu v-slot="{ open }">
<span class="rounded-md shadow-sm">
<MenuButton
ref="trigger"
class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Choose a reason</span>
<svg
class="w-5 h-5 ml-2 -mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</MenuButton>
</span>
<div class="py-1">
<MenuItem as="a" href="#account-settings" :className="resolveClass">
Account settings
</MenuItem>
<MenuItem as="a" href="#support" :className="resolveClass">
Support
</MenuItem>
<MenuItem as="a" disabled href="#new-feature" :className="resolveClass">
New feature (soon)
</MenuItem>
<MenuItem as="a" href="#license" :className="resolveClass">
License
</MenuItem>
</div>
<Portal v-if="open">
<MenuItems
static
ref="container"
class="z-20 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div class="px-4 py-3">
<p class="text-sm leading-5">Signed in as</p>
<p class="text-sm font-medium leading-5 text-gray-900 truncate">
tom@example.com
</p>
</div>
<div class="py-1">
<MenuItem as="a" href="#sign-out" :className="resolveClass">
Sign out
</MenuItem>
</div>
</MenuItems>
</Portal>
</Menu>
<div class="py-1">
<MenuItem as="a" href="#account-settings" :className="resolveClass">
Account settings
</MenuItem>
<MenuItem as="a" href="#support" :className="resolveClass">
Support
</MenuItem>
<MenuItem
as="a"
disabled
href="#new-feature"
:className="resolveClass"
>
New feature (soon)
</MenuItem>
<MenuItem as="a" href="#license" :className="resolveClass">
License
</MenuItem>
</div>
<div class="py-1">
<MenuItem as="a" href="#sign-out" :className="resolveClass">
Sign out
</MenuItem>
</div>
</MenuItems>
</Portal>
</Menu>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
@click="setIsOpen(false)"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:shadow-outline-red sm:ml-3 sm:w-auto sm:text-sm"
>
Deactivate
</button>
<button
type="button"
@click="setIsOpen(false)"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:shadow-outline-indigo sm:mt-0 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
@click="setIsOpen(false)"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:shadow-outline-red sm:ml-3 sm:w-auto sm:text-sm"
>
Deactivate
</button>
<button
type="button"
@click="setIsOpen(false)"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:shadow-outline-indigo sm:mt-0 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</TransitionChild>
</div>
</div>
</div>
</Dialog>
</Dialog>
</TransitionRoot>
</template>
<script>
@@ -146,6 +176,8 @@ import {
MenuItems,
MenuItem,
Portal,
Transition,
TransitionChild,
} from '@headlessui/vue'
import { usePopper } from '../../playground-utils/hooks/use-popper'
@@ -171,6 +203,8 @@ export default {
MenuItems,
MenuItem,
Portal,
TransitionRoot: Transition,
TransitionChild,
},
setup() {
let isOpen = ref(false)
@@ -52,7 +52,9 @@ it('should be possible to use useDescriptions without using a Description', asyn
expect(format(container.firstElementChild)).toEqual(
format(html`
<div>
No description
<div>
No description
</div>
</div>
`)
)
@@ -78,13 +80,15 @@ it('should be possible to use useDescriptions and a single Description, and have
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-describedby="headlessui-description-1">
<p id="headlessui-description-1">
I am a description
</p>
<span>
Contents
</span>
<div>
<div aria-describedby="headlessui-description-1">
<p id="headlessui-description-1">
I am a description
</p>
<span>
Contents
</span>
</div>
</div>
`)
)
@@ -111,16 +115,18 @@ it('should be possible to use useDescriptions and multiple Description component
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-describedby="headlessui-description-1 headlessui-description-2">
<p id="headlessui-description-1">
I am a description
</p>
<span>
Contents
</span>
<p id="headlessui-description-2">
I am also a description
</p>
<div>
<div aria-describedby="headlessui-description-1 headlessui-description-2">
<p id="headlessui-description-1">
I am a description
</p>
<span>
Contents
</span>
<p id="headlessui-description-2">
I am also a description
</p>
</div>
</div>
`)
)
@@ -147,11 +153,13 @@ it('should be possible to update a prop from the parent and it should reflect in
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-describedby="headlessui-description-1">
<p data-count="0" id="headlessui-description-1">
I am a description
</p>
<button>+1</button>
<div>
<div aria-describedby="headlessui-description-1">
<p data-count="0" id="headlessui-description-1">
I am a description
</p>
<button>+1</button>
</div>
</div>
`)
)
@@ -160,11 +168,13 @@ it('should be possible to update a prop from the parent and it should reflect in
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-describedby="headlessui-description-1">
<p data-count="1" id="headlessui-description-1">
I am a description
</p>
<button>+1</button>
<div>
<div aria-describedby="headlessui-description-1">
<p data-count="1" id="headlessui-description-1">
I am a description
</p>
<button>+1</button>
</div>
</div>
`)
)
@@ -30,6 +30,7 @@ import { StackMessage, useStackProvider } from '../../internal/stack-context'
import { match } from '../../utils/match'
import { ForcePortalRoot } from '../../internal/portal-force-root'
import { Description, useDescriptions } from '../description/description'
import { dom } from '../../utils/dom'
enum DialogStates {
Open,
@@ -89,30 +90,20 @@ export let Dialog = defineComponent({
let slot = { open: this.dialogState === DialogStates.Open }
return h(ForcePortalRoot, { force: true }, () =>
h(
Portal,
{
visible:
passThroughProps.static === true
? true
: passThroughProps.unmount === true
? open
: true,
},
() =>
h(PortalGroup, { target: this.dialogRef }, () =>
h(ForcePortalRoot, { force: false }, () =>
render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
visible: open,
features: Features.RenderStrategy | Features.Static,
name: 'Dialog',
})
)
h(Portal, () =>
h(PortalGroup, { target: this.dialogRef }, () =>
h(ForcePortalRoot, { force: false }, () =>
render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
visible: open,
features: Features.RenderStrategy | Features.Static,
name: 'Dialog',
})
)
)
)
)
},
@@ -189,9 +180,7 @@ export let Dialog = defineComponent({
if (contains(containers.value, target)) return
api.close()
nextTick(() => {
target?.focus()
})
nextTick(() => target?.focus())
})
// Handle `Escape` to close
@@ -223,7 +212,8 @@ export let Dialog = defineComponent({
// Trigger close when the FocusTrap gets hidden
watchEffect(onInvalidate => {
if (dialogState.value !== DialogStates.Open) return
if (!internalDialogRef.value) return
let container = dom(internalDialogRef)
if (!container) return
let observer = new IntersectionObserver(entries => {
for (let entry of entries) {
@@ -238,7 +228,7 @@ export let Dialog = defineComponent({
}
})
observer.observe(internalDialogRef.value)
observer.observe(container)
onInvalidate(() => observer.disconnect())
})
@@ -52,7 +52,9 @@ it('should be possible to use useLabels without using a Label', async () => {
expect(format(container.firstElementChild)).toEqual(
format(html`
<div>
No label
<div>
No label
</div>
</div>
`)
)
@@ -78,13 +80,15 @@ it('should be possible to use useLabels and a single Label, and have them linked
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-labelledby="headlessui-label-1">
<label id="headlessui-label-1">
I am a label
</label>
<span>
Contents
</span>
<div>
<div aria-labelledby="headlessui-label-1">
<label id="headlessui-label-1">
I am a label
</label>
<span>
Contents
</span>
</div>
</div>
`)
)
@@ -111,16 +115,18 @@ it('should be possible to use useLabels and multiple Label components, and have
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-labelledby="headlessui-label-1 headlessui-label-2">
<label id="headlessui-label-1">
I am a label
</label>
<span>
Contents
</span>
<label id="headlessui-label-2">
I am also a label
</label>
<div>
<div aria-labelledby="headlessui-label-1 headlessui-label-2">
<label id="headlessui-label-1">
I am a label
</label>
<span>
Contents
</span>
<label id="headlessui-label-2">
I am also a label
</label>
</div>
</div>
`)
)
@@ -147,11 +153,13 @@ it('should be possible to update a prop from the parent and it should reflect in
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-labelledby="headlessui-label-1">
<label data-count="0" id="headlessui-label-1">
I am a label
</label>
<button>+1</button>
<div>
<div aria-labelledby="headlessui-label-1">
<label data-count="0" id="headlessui-label-1">
I am a label
</label>
<button>+1</button>
</div>
</div>
`)
)
@@ -160,11 +168,13 @@ it('should be possible to update a prop from the parent and it should reflect in
expect(format(container.firstElementChild)).toEqual(
format(html`
<div aria-labelledby="headlessui-label-1">
<label data-count="1" id="headlessui-label-1">
I am a label
</label>
<button>+1</button>
<div>
<div aria-labelledby="headlessui-label-1">
<label data-count="1" id="headlessui-label-1">
I am a label
</label>
<button>+1</button>
</div>
</div>
`)
)
@@ -32,7 +32,6 @@ export let Portal = defineComponent({
name: 'Portal',
props: {
as: { type: [Object, String], default: 'div' },
visible: { type: [Boolean], default: true },
},
setup(props, { slots, attrs }) {
let forcePortalRoot = usePortalRoot()
@@ -67,9 +66,7 @@ export let Portal = defineComponent({
useStackProvider()
return () => {
let { visible, ...passThroughProps } = props
if (myTarget.value === null) return null
if (!visible) return null
let propsWeControl = {
ref: element,
@@ -81,7 +78,7 @@ export let Portal = defineComponent({
Teleport,
{ to: myTarget.value },
render({
props: { ...passThroughProps, ...propsWeControl },
props: { ...props, ...propsWeControl },
slot: {},
attrs,
slots,
@@ -0,0 +1,369 @@
## Transition
The `Transition` component lets you add enter/leave transitions to conditionally rendered elements, using CSS classes to control the actual transition styles in the different stages of the transition.
- [Installation](#installation)
- [Basic example](#basic-example)
- [Showing and hiding content](#showing-and-hiding-content)
- [Animating transitions](#animating-transitions)
- [Co-ordinating multiple transitions](#co-ordinating-multiple-transitions)
- [Transitioning on initial mount](#transitioning-on-initial-mount)
- [Component API](#component-api)
### Installation
```sh
# npm
npm install @headlessui/vue
# Yarn
yarn add @headlessui/vue
```
### Basic example
The `Transition` accepts a `show` prop that controls whether the children should be shown or hidden, and a set of lifecycle props (like `enterFrom`, and `leaveTo`) that let you add CSS classes at specific phases of a transition.
```vue
<template>
<button @click="open = !open">Toggle</button>
<TransitionRoot
:show="open"
enter="transition-opacity duration-75"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
I will fade in and out
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
### Showing and hiding content
Wrap the content that should be conditionally rendered in a `<Transition>` component, and use the `show` prop to control whether the content should be visible or hidden.
```vue
<template>
<button @click="open = !open">Toggle</button>
<TransitionRoot :show="open">
I will fade in and out
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
The `Transition` component will render a `div` by default, but you can use the `as` prop to render a different element instead if needed. Any other HTML attributes (like `className`) can be added directly to the `Transition` the same way they would be to regular elements.
```vue
<template>
<button @click="open = !open">Toggle</button>
<TransitionRoot :show="open" as="a" href="/my-url" class="font-bold">
I will fade in and out
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
### Animating transitions
By default, a `Transition` will enter and leave instantly, which is probably not what you're looking for if you're using this library.
To animate your enter/leave transitions, add classes that provide the styling for each phase of the transitions using these props:
- **enter**: Applied the entire time an element is entering. Usually you define your duration and what properties you want to transition here, for example `transition-opacity duration-75`.
- **enterFrom**: The starting point to enter from, for example `opacity-0` if something should fade in.
- **enterTo**: The ending point to enter to, for example `opacity-100` after fading in.
- **leave**: Applied the entire time an element is leaving. Usually you define your duration and what properties you want to transition here, for example `transition-opacity duration-75`.
- **leaveFrom**: The starting point to leave from, for example `opacity-100` if something should fade out.
- **leaveTo**: The ending point to leave to, for example `opacity-0` after fading out.
Here's an example:
```vue
<template>
<button @click="open = !open">Toggle</button>
<TransitionRoot
:show="open"
enter="transition-opacity duration-75"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
I will fade in and out
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
In this example, the transitioning element will take 75ms to enter (that's the `duration-75` class), and will transition the opacity property during that time (that's `transition-opacity`).
It will start completely transparent before entering (that's `opacity-0` in the `enterFrom` phase), and fade in to completely opaque (`opacity-100`) when finished (that's the `enterTo` phase).
When the element is being removed (the `leave` phase), it will transition the opacity property, and spend 150ms doing it (`transition-opacity duration-150`).
It will start as completely opaque (the `opacity-100` in the `leaveFrom` phase), and finish as completely transparent (the `opacity-0` in the `leaveTo` phase).
All of these props are optional, and will default to just an empty string.
### Co-ordinating multiple transitions
Sometimes you need to transition multiple elements with different animations but all based on the same state. For example, say the user clicks a button to open a sidebar that slides over the screen, and you also need to fade-in a background overlay at the same time.
You can do this by wrapping the related elements with a parent `Transition` component, and wrapping each child that needs its own transition styles with a `TransitionChild` component, which will automatically communicate with the parent `Transition` and inherit the parent's `show` state.
```vue
<template>
<TransitionRoot :show="open">
<!-- Background overlay -->
<TransitionChild
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<!-- ... -->
</TransitionChild>
<!-- Sliding sidebar -->
<TransitionChild
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<!-- ... -->
</TransitionChild>
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition, TransitionChild } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition, TransitionChild },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
The `TransitionChild` component has the exact same API as the `Transition` component, but with no `show` prop, since the `show` value is controlled by the parent.
Parent `Transition` components will always automatically wait for all children to finish transitioning before unmounting, so you don't need to manage any of that timing yourself.
### Transitioning on initial mount
If you want an element to transition the very first time it's rendered, set the `appear` prop to `true`.
This is useful if you want something to transition in on initial page load, or when its parent is conditionally rendered.
```vue
<template>
<TransitionRoot
:show="open"
enter="transition-opacity duration-75"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<!-- Your content goes here -->
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
### Component API
#### Transition
```vue
<template>
<TransitionRoot
:show="open"
:appear="true"
enter="transition-opacity duration-75"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<!-- Your content goes here -->
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
##### Props
| Prop | Type | Default | Description |
| :---------- | :------------------ | :------ | :------------------------------------------------------------------------------------ |
| `show` | Boolean | - | Whether the children should be shown or hidden. |
| `as` | String \| Component | `div` | The element or component to render in place of the `Transition` itself. |
| `appear` | Boolean | `false` | Whether the transition should run on initial mount. |
| `unmount` | Boolean | `true` | Whether the element should be `unmounted` or `hidden` based on the show state. |
| `enter` | String | `''` | Classes to add to the transitioning element during the entire enter phase. |
| `enterFrom` | String | `''` | Classes to add to the transitioning element before the enter phase starts. |
| `enterTo` | String | `''` | Classes to add to the transitioning element immediately after the enter phase starts. |
| `leave` | String | `''` | Classes to add to the transitioning element during the entire leave phase. |
| `leaveFrom` | String | `''` | Classes to add to the transitioning element before the leave phase starts. |
| `leaveTo` | String | `''` | Classes to add to the transitioning element immediately after the leave phase starts. |
##### Events
| Event | Description |
| :------------ | :--------------------------------------------------------------- |
| `beforeEnter` | Callback which is called before we start the enter transition. |
| `afterEnter` | Callback which is called after we finished the enter transition. |
| `beforeLeave` | Callback which is called before we start the leave transition. |
| `afterLeave` | Callback which is called after we finished the leave transition. |
##### Render prop object
- None
#### TransitionChild
```vue
<template>
<TransitionRoot :show="open">
<TransitionChild
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<!-- Your content goes here -->
</TransitionChild>
</TransitionRoot>
</template>
<script>
import { ref } from 'vue'
import { Transition, TransitionChild } from '@headlessui/vue'
export default {
components: { TranitionRoot: Transition, TransitionChild },
setup() {
let open = ref(false)
return { open }
},
}
</script>
```
##### Props
| Prop | Type | Default | Description |
| :---------- | :------------------ | :------ | :------------------------------------------------------------------------------------ |
| `as` | String \| Component | `div` | The element or component to render in place of the `TransitionChild` itself. |
| `appear` | Boolean | `false` | Whether the transition should run on initial mount. |
| `unmount` | Boolean | `true` | Whether the element should be `unmounted` or `hidden` based on the show state. |
| `enter` | String | `''` | Classes to add to the transitioning element during the entire enter phase. |
| `enterFrom` | String | `''` | Classes to add to the transitioning element before the enter phase starts. |
| `enterTo` | String | `''` | Classes to add to the transitioning element immediately after the enter phase starts. |
| `leave` | String | `''` | Classes to add to the transitioning element during the entire leave phase. |
| `leaveFrom` | String | `''` | Classes to add to the transitioning element before the leave phase starts. |
| `leaveTo` | String | `''` | Classes to add to the transitioning element immediately after the leave phase starts. |
##### Events
| Event | Description |
| :------------ | :--------------------------------------------------------------- |
| `beforeEnter` | Callback which is called before we start the enter transition. |
| `afterEnter` | Callback which is called after we finished the enter transition. |
| `beforeLeave` | Callback which is called before we start the leave transition. |
| `afterLeave` | Callback which is called after we finished the leave transition. |
##### Render prop object
- None
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,367 @@
import {
computed,
defineComponent,
h,
inject,
onMounted,
onUnmounted,
provide,
ref,
watch,
watchEffect,
// Types
InjectionKey,
Ref,
} from 'vue'
import { useId } from '../../hooks/use-id'
import { match } from '../../utils/match'
import { Features, render, RenderStrategy } from '../../utils/render'
import { Reason, transition } from './utils/transition'
import { dom } from '../../utils/dom'
type ID = ReturnType<typeof useId>
function splitClasses(classes: string = '') {
return classes.split(' ').filter(className => className.trim().length > 1)
}
interface TransitionContextValues {
show: Ref<boolean>
appear: Ref<boolean>
}
let TransitionContext = Symbol('TransitionContext') as InjectionKey<TransitionContextValues | null>
enum TreeStates {
Visible = 'visible',
Hidden = 'hidden',
}
function useTransitionContext() {
let context = inject(TransitionContext, null)
if (context === null) {
throw new Error('A <TransitionChild /> is used but it is missing a parent <Transition />.')
}
return context
}
function useParentNesting() {
let context = inject(NestingContext, null)
if (context === null) {
throw new Error('A <TransitionChild /> is used but it is missing a parent <Transition />.')
}
return context
}
interface NestingContextValues {
children: Ref<{ id: ID; state: TreeStates }[]>
register: (id: ID) => () => void
unregister: (id: ID, strategy?: RenderStrategy) => void
}
let NestingContext = Symbol('NestingContext') as InjectionKey<NestingContextValues | null>
function hasChildren(
bag: NestingContextValues['children'] | { children: NestingContextValues['children'] }
): boolean {
if ('children' in bag) return hasChildren(bag.children)
return bag.value.filter(({ state }) => state === TreeStates.Visible).length > 0
}
function useNesting(done?: () => void) {
let transitionableChildren = ref<NestingContextValues['children']['value']>([])
let mounted = ref(false)
onMounted(() => (mounted.value = true))
onUnmounted(() => (mounted.value = false))
function unregister(childId: ID, strategy = RenderStrategy.Hidden) {
let idx = transitionableChildren.value.findIndex(({ id }) => id === childId)
if (idx === -1) return
match(strategy, {
[RenderStrategy.Unmount]() {
transitionableChildren.value.splice(idx, 1)
},
[RenderStrategy.Hidden]() {
transitionableChildren.value[idx].state = TreeStates.Hidden
},
})
if (!hasChildren(transitionableChildren) && mounted.value) {
done?.()
}
}
function register(childId: ID) {
let child = transitionableChildren.value.find(({ id }) => id === childId)
if (!child) {
transitionableChildren.value.push({ id: childId, state: TreeStates.Visible })
} else if (child.state !== TreeStates.Visible) {
child.state = TreeStates.Visible
}
return () => unregister(childId, RenderStrategy.Unmount)
}
return {
children: transitionableChildren,
register,
unregister,
}
}
// ---
let TransitionChildRenderFeatures = Features.RenderStrategy
export let TransitionChild = defineComponent({
props: {
as: { type: [Object, String], default: 'div' },
show: { type: [Boolean], default: null },
unmount: { type: [Boolean], default: true },
appear: { type: [Boolean], default: false },
enter: { type: [String], default: '' },
enterFrom: { type: [String], default: '' },
enterTo: { type: [String], default: '' },
leave: { type: [String], default: '' },
leaveFrom: { type: [String], default: '' },
leaveTo: { type: [String], default: '' },
},
emits: ['beforeEnter', 'afterEnter', 'beforeLeave', 'afterLeave'],
render() {
let {
appear,
show,
// Class names
enter,
enterFrom,
enterTo,
leave,
leaveFrom,
leaveTo,
...rest
} = this.$props
let propsWeControl = { ref: 'el' }
let passthroughProps = rest
return render({
props: { ...passthroughProps, ...propsWeControl },
slot: {},
slots: this.$slots,
attrs: this.$attrs,
features: TransitionChildRenderFeatures,
visible: this.state === TreeStates.Visible,
name: 'TransitionChild',
})
},
setup(props, { emit }) {
let container = ref<HTMLElement | null>(null)
let state = ref(TreeStates.Visible)
let strategy = computed(() => (props.unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden))
let { show, appear } = useTransitionContext()
let { register, unregister } = useParentNesting()
let initial = { value: true }
let id = useId()
let isTransitioning = { value: false }
let nesting = useNesting(() => {
// When all children have been unmounted we can only hide ourselves if and only if we are not
// transitioning ourserlves. Otherwise we would unmount before the transitions are finished.
if (!isTransitioning.value) {
state.value = TreeStates.Hidden
unregister(id)
emit('afterLeave')
}
})
onMounted(() => {
let unregister = register(id)
onUnmounted(unregister)
})
watchEffect(() => {
// If we are in another mode than the Hidden mode then ignore
if (strategy.value !== RenderStrategy.Hidden) return
if (!id) return
// Make sure that we are visible
if (show && state.value !== TreeStates.Visible) {
state.value = TreeStates.Visible
return
}
match(state.value, {
[TreeStates.Hidden]: () => unregister(id),
[TreeStates.Visible]: () => register(id),
})
})
let enterClasses = splitClasses(props.enter)
let enterFromClasses = splitClasses(props.enterFrom)
let enterToClasses = splitClasses(props.enterTo)
let leaveClasses = splitClasses(props.leave)
let leaveFromClasses = splitClasses(props.leaveFrom)
let leaveToClasses = splitClasses(props.leaveTo)
onMounted(() => {
watchEffect(() => {
if (state.value === TreeStates.Visible) {
let domElement = dom(container)
// When you return `null` from a component, the actual DOM reference will
// be an empty comment... This means that we can never check for the DOM
// node to be `null`. So instead we check for an empty comment.
let isEmptyDOMNode = domElement instanceof Comment && domElement.data === ''
if (isEmptyDOMNode) {
throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?')
}
}
})
})
function executeTransition(onInvalidate: (cb: () => void) => void) {
// Skipping initial transition
let skip = initial.value && !appear.value
let node = dom(container)
if (!node || !(node instanceof HTMLElement)) return
if (skip) return
isTransitioning.value = true
if (show.value) emit('beforeEnter')
if (!show.value) emit('beforeLeave')
onInvalidate(
show.value
? transition(node, enterClasses, enterFromClasses, enterToClasses, reason => {
isTransitioning.value = false
if (reason === Reason.Finished) emit('afterEnter')
})
: transition(node, leaveClasses, leaveFromClasses, leaveToClasses, reason => {
isTransitioning.value = false
if (reason !== Reason.Finished) return
// When we don't have children anymore we can safely unregister from the parent and hide
// ourselves.
if (!hasChildren(nesting)) {
state.value = TreeStates.Hidden
unregister(id)
emit('afterLeave')
}
})
)
}
onMounted(() => {
watch(
[show, appear],
(_oldValues, _newValues, onInvalidate) => {
executeTransition(onInvalidate)
initial.value = false
},
{ immediate: true }
)
})
// onUpdated(() => executeTransition(() => {}))
provide(NestingContext, nesting)
return { el: container, state }
},
})
// ---
export let Transition = defineComponent({
inheritAttrs: false,
props: {
as: { type: [Object, String], default: 'div' },
show: { type: [Boolean], default: null },
unmount: { type: [Boolean], default: true },
appear: { type: [Boolean], default: false },
enter: { type: [String], default: '' },
enterFrom: { type: [String], default: '' },
enterTo: { type: [String], default: '' },
leave: { type: [String], default: '' },
leaveFrom: { type: [String], default: '' },
leaveTo: { type: [String], default: '' },
},
emits: ['beforeEnter', 'afterEnter', 'beforeLeave', 'afterLeave'],
render() {
let { show, appear, unmount, ...passThroughProps } = this.$props
let sharedProps = { unmount }
return render({
props: {
...sharedProps,
as: 'template',
},
slot: {},
slots: {
...this.$slots,
default: () => [
h(
TransitionChild,
{ ...this.$attrs, ...sharedProps, ...passThroughProps },
this.$slots.default
),
],
},
attrs: {},
features: TransitionChildRenderFeatures,
visible: this.state === TreeStates.Visible,
name: 'Transition',
})
},
setup(props) {
watchEffect(() => {
if (![true, false].includes(props.show)) {
throw new Error('A <Transition /> is used but it is missing a `:show="true | false"` prop.')
}
})
let state = ref(props.show ? TreeStates.Visible : TreeStates.Hidden)
let nestingBag = useNesting(() => {
state.value = TreeStates.Hidden
})
let initial = { value: true }
let transitionBag = {
show: computed(() => props.show),
appear: computed(() => props.appear || !initial.value),
}
onMounted(() => {
watchEffect(() => {
initial.value = false
if (props.show) {
state.value = TreeStates.Visible
} else if (!hasChildren(nestingBag)) {
state.value = TreeStates.Hidden
}
})
})
provide(NestingContext, nestingBag)
provide(TransitionContext, transitionBag)
return { state }
},
})
@@ -0,0 +1,193 @@
import { Reason, transition } from './transition'
import { reportChanges } from '../../../test-utils/report-dom-node-changes'
import { disposables } from '../../../utils/disposables'
beforeEach(() => {
document.body.innerHTML = ''
})
it('should be possible to transition', async () => {
let d = disposables()
let snapshots: { content: string; recordedAt: bigint }[] = []
let element = document.createElement('div')
document.body.appendChild(element)
d.add(
reportChanges(
() => document.body.innerHTML,
content => {
snapshots.push({
content,
recordedAt: process.hrtime.bigint(),
})
}
)
)
await new Promise(resolve => {
transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve)
})
await new Promise(resolve => d.nextFrame(resolve))
// Initial render:
expect(snapshots[0].content).toEqual('<div></div>')
// Start of transition
expect(snapshots[1].content).toEqual('<div class="enter enterFrom"></div>')
// NOTE: There is no `enter enterTo`, because we didn't define a duration. Therefore it is not
// necessary to put the classes on the element and immediatley remove them.
// Cleanup phase
expect(snapshots[2].content).toEqual('<div class=""></div>')
d.dispose()
})
it('should wait the correct amount of time to finish a transition', async () => {
let d = disposables()
let snapshots: { content: string; recordedAt: bigint }[] = []
let element = document.createElement('div')
document.body.appendChild(element)
let duration = 20
element.style.transitionDuration = `${duration}ms`
d.add(
reportChanges(
() => document.body.innerHTML,
content => {
snapshots.push({
content,
recordedAt: process.hrtime.bigint(),
})
}
)
)
let reason = await new Promise(resolve => {
transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve)
})
await new Promise(resolve => d.nextFrame(resolve))
expect(reason).toBe(Reason.Finished)
// Initial render:
expect(snapshots[0].content).toEqual(`<div style="transition-duration: ${duration}ms;"></div>`)
// Start of transition
expect(snapshots[1].content).toEqual(
`<div style="transition-duration: ${duration}ms;" class="enter enterFrom"></div>`
)
expect(snapshots[2].content).toEqual(
`<div style="transition-duration: ${duration}ms;" class="enter enterTo"></div>`
)
let estimatedDuration = Number(
(snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
BigInt(1e6)
)
expect(estimatedDuration).toBeWithinRenderFrame(duration)
// Cleanup phase
expect(snapshots[3].content).toEqual(
`<div style="transition-duration: ${duration}ms;" class=""></div>`
)
})
it('should keep the delay time into account', async () => {
let d = disposables()
let snapshots: { content: string; recordedAt: bigint }[] = []
let element = document.createElement('div')
document.body.appendChild(element)
let duration = 20
let delayDuration = 100
element.style.transitionDuration = `${duration}ms`
element.style.transitionDelay = `${delayDuration}ms`
d.add(
reportChanges(
() => document.body.innerHTML,
content => {
snapshots.push({
content,
recordedAt: process.hrtime.bigint(),
})
}
)
)
let reason = await new Promise(resolve => {
transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve)
})
await new Promise(resolve => d.nextFrame(resolve))
expect(reason).toBe(Reason.Finished)
let estimatedDuration = Number(
(snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
BigInt(1e6)
)
expect(estimatedDuration).toBeWithinRenderFrame(duration + delayDuration)
})
it('should be possible to cancel a transition at any time', async () => {
let d = disposables()
let snapshots: {
content: string
recordedAt: bigint
relativeTime: number
}[] = []
let element = document.createElement('div')
document.body.appendChild(element)
// This duration is so overkill, however it will demonstrate that we can cancel transitions.
let duration = 5000
element.style.transitionDuration = `${duration}ms`
d.add(
reportChanges(
() => document.body.innerHTML,
content => {
let recordedAt = process.hrtime.bigint()
let total = snapshots.length
snapshots.push({
content,
recordedAt,
relativeTime:
total === 0 ? 0 : Number((recordedAt - snapshots[total - 1].recordedAt) / BigInt(1e6)),
})
}
)
)
expect.assertions(2)
// Setup the transition
let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], reason => {
expect(reason).toBe(Reason.Cancelled)
})
// Wait for a bit
await new Promise(resolve => setTimeout(resolve, 20))
// Cancel the transition
cancel()
await new Promise(resolve => d.nextFrame(resolve))
expect(snapshots.map(snapshot => snapshot.content).join('\n')).not.toContain('enterTo')
})
@@ -0,0 +1,90 @@
import { once } from '../../../utils/once'
import { disposables } from '../../../utils/disposables'
function addClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.add(...classes)
}
function removeClasses(node: HTMLElement, ...classes: string[]) {
node && classes.length > 0 && node.classList.remove(...classes)
}
export enum Reason {
Finished = 'finished',
Cancelled = 'cancelled',
}
function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) {
let d = disposables()
if (!node) return d.dispose
// Safari returns a comma separated list of values, so let's sort them and take the highest value.
let { transitionDuration, transitionDelay } = getComputedStyle(node)
let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => {
let [resolvedValue = 0] = value
.split(',')
// Remove falseys we can't work with
.filter(Boolean)
// Values are returned as `0.3s` or `75ms`
.map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000))
.sort((a, z) => z - a)
return resolvedValue
})
// Waiting for the transition to end. We could use the `transitionend` event, however when no
// actual transition/duration is defined then the `transitionend` event is not fired.
//
// TODO: Downside is, when you slow down transitions via devtools this timeout is still using the
// full 100% speed instead of the 25% or 10%.
if (durationMs !== 0) {
d.setTimeout(() => done(Reason.Finished), durationMs + delaysMs)
} else {
// No transition is happening, so we should cleanup already. Otherwise we have to wait until we
// get disposed.
done(Reason.Finished)
}
// If we get disposed before the timeout runs we should cleanup anyway
d.add(() => done(Reason.Cancelled))
return d.dispose
}
export function transition(
node: HTMLElement,
base: string[],
from: string[],
to: string[],
done?: (reason: Reason) => void
) {
let d = disposables()
let _done = done !== undefined ? once(done) : () => {}
addClasses(node, ...base, ...from)
d.nextFrame(() => {
removeClasses(node, ...from)
addClasses(node, ...to)
d.add(
waitForTransition(node, reason => {
removeClasses(node, ...to, ...base)
return _done(reason)
})
)
})
// Once we get disposed, we should ensure that we cleanup after ourselves. In case of an unmount,
// the node itself will be nullified and will be a no-op. In case of a full transition the classes
// are already removed which is also a no-op. However if you go from enter -> leave mid-transition
// then we have some leftovers that should be cleaned.
d.add(() => removeClasses(node, ...base, ...from, ...to))
// When we get disposed early, than we should also call the done method but switch the reason.
d.add(() => _done(Reason.Cancelled))
return d.dispose
}
@@ -55,5 +55,9 @@ it('should expose the correct components', () => {
'Switch',
'SwitchLabel',
'SwitchDescription',
// Transition
'TransitionChild',
'Transition',
])
})
+1
View File
@@ -7,3 +7,4 @@ export * from './components/popover/popover'
export * from './components/portal/portal'
export * from './components/radio-group/radio-group'
export * from './components/switch/switch'
export * from './components/transitions/transition'
@@ -0,0 +1,183 @@
import { defineComponent } from 'vue'
import snapshotDiff from 'snapshot-diff'
import { render } from './vue-testing-library'
import { disposables } from '../utils/disposables'
import { reportChanges } from './report-dom-node-changes'
function redentSnapshot(input: string) {
let minSpaces = Infinity
let lines = input.split('\n')
for (let line of lines) {
if (line.trim() === '---') continue
let spacesInLine = (line.match(/^[+-](\s+)/g) || []).pop()!.length - 1
minSpaces = Math.min(minSpaces, spacesInLine)
}
let replacer = new RegExp(`^([+-])\\s{${minSpaces}}(.*)`, 'g')
return input
.split('\n')
.map(line =>
line.trim() === '---' ? line : line.replace(replacer, (_, sign, rest) => `${sign} ${rest}`)
)
.join('\n')
}
export async function executeTimeline(
element: ReturnType<typeof defineComponent>,
steps: ((tools: ReturnType<typeof render>) => (null | number)[])[]
) {
let d = disposables()
let snapshots: { content: Node; recordedAt: bigint }[] = []
//
let tools = render(element)
// Start listening for changes
d.add(
reportChanges(
() => document.body.innerHTML,
() => {
// This will ensure that any DOM change to the body has been recorded.
snapshots.push({
content: tools.asFragment(),
recordedAt: process.hrtime.bigint(),
})
}
)
)
// We start with a `null` value because we will start with a snapshot even _before_ things start
// happening.
let timestamps: (null | number)[] = [null]
//
await steps.reduce(async (chain, step) => {
await chain
let durations = await step(tools)
// Note: The following calls are just in place to ensure that **we** waited long enough for the
// transitions to take place. This has no impact on the actual transitions. Above where the
// `reportDOMNodeChanges` is used we will actually record all the changes, no matter what
// happens here.
timestamps.push(...durations)
let totalDuration = durations
.filter((duration): duration is number => duration !== null)
.reduce((total, current) => total + current, 0)
// Changes happen in the next frame
await new Promise(resolve => d.nextFrame(resolve))
// We wait for the amount of the duration
await new Promise(resolve => d.setTimeout(resolve, totalDuration))
// We wait an additional next frame so that we know that we are done
await new Promise(resolve => d.nextFrame(resolve))
}, Promise.resolve())
if (snapshots.length <= 0) {
throw new Error('We could not record any changes')
}
let uniqueSnapshots = snapshots
// Only keep the snapshots that are unique. Multiple snapshots of the same
// content are a bit useless for us.
.filter((snapshot, i) => {
if (i === 0) return true
return snapshot.content !== snapshots[i - 1].content
})
// Add a relative time compaired to the previous snapshot. We recorded everything in
// process.hrtime.bigint() which is in nanoseconds, we want it in milliseconds.
.map((snapshot, i, all) => ({
...snapshot,
relativeToPreviousSnapshot:
i === 0 ? 0 : Number((snapshot.recordedAt - all[i - 1].recordedAt) / BigInt(1e6)),
}))
let diffed = uniqueSnapshots
.map((call, i) => {
// Skip initial render, because there is nothing to compare with
if (i === 0) return false
// The next bit of code is a bit ugly, but mos of the code is just cleaning up some "noise"
// that we don't need in our test output.
return `Render ${i}:${
// `This took: ${call.relativeTime}ms`
timestamps[i] === null
? ''
: ` Transition took at least ${timestamps[i]}ms (${
isWithinFrame(call.relativeToPreviousSnapshot, timestamps[i]!)
? 'yes'
: `no, it took ${call.relativeToPreviousSnapshot}ms`
})`
}\n${redentSnapshot(
snapshotDiff(uniqueSnapshots[i - 1].content, call.content, {
aAnnotation: '__REMOVE_ME__',
bAnnotation: '__REMOVE_ME__',
contextLines: 0,
})
// Just to do some cleanup
.replace(/\n\n@@([^@@]*)@@/g, '') // Top level @@ signs
.replace(/@@([^@@]*)@@/g, '---') // In between @@ signs
.replace(/[-+] __REMOVE_ME__\n/g, '')
.replace(/Snapshot Diff:\n/g, '')
)
.split('\n')
.map(line => ` ${line}`)
.join('\n')}`
})
.filter(Boolean)
.join('\n\n')
d.dispose()
return diffed
}
executeTimeline.fullTransition = (duration: number) => {
return [
/** Stage 1: Immediately add `base` and `from` classes */
null,
/** Stage 2: Immediately remove `from` classes and add `to` classes */
null,
/** Stage 3: After duration remove `to` and `base` classes */
duration,
]
}
let state: {
before: number
fps: number
handle: ReturnType<typeof requestAnimationFrame> | null
} = {
before: Date.now(),
fps: 0,
handle: null,
}
state.handle = requestAnimationFrame(function loop() {
let now = Date.now()
state.fps = Math.round(1000 / (now - state.before))
state.before = now
state.handle = requestAnimationFrame(loop)
})
afterAll(() => {
if (state.handle) cancelAnimationFrame(state.handle)
})
function isWithinFrame(actual: number, expected: number) {
let buffer = state.fps
let min = expected - buffer
let max = expected + buffer
return actual >= min && actual <= max
}
@@ -0,0 +1,20 @@
import { disposables } from '../utils/disposables'
export function reportChanges<TType>(key: () => TType, onChange: (value: TType) => void) {
let d = disposables()
let previous: TType
function track() {
let next = key()
if (previous !== next) {
previous = next
onChange(next)
}
d.requestAnimationFrame(track)
}
track()
return d.dispose
}
@@ -23,11 +23,16 @@ export function render(TestComponent: any, options?: Parameters<typeof mount>[1]
return {
get container() {
return wrapper.element
return wrapper.element.parentElement!
},
debug(element = wrapper.element) {
debug(element = wrapper.element.parentElement!) {
logDOM(element)
},
asFragment() {
let template = document.createElement('template')
template.innerHTML = wrapper.element.parentElement!.innerHTML
return template.content
},
}
}
@@ -0,0 +1,33 @@
export function disposables() {
let disposables: Function[] = []
let api = {
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf))
},
nextFrame(...args: Parameters<typeof requestAnimationFrame>) {
api.requestAnimationFrame(() => {
api.requestAnimationFrame(...args)
})
},
setTimeout(...args: Parameters<typeof setTimeout>) {
let timer = setTimeout(...args)
api.add(() => clearTimeout(timer))
},
add(cb: () => void) {
disposables.push(cb)
},
dispose() {
for (let dispose of disposables.splice(0)) {
dispose()
}
},
}
return api
}
@@ -0,0 +1,9 @@
export function once<T>(cb: (...args: T[]) => void) {
let state = { called: false }
return (...args: T[]) => {
if (state.called) return
state.called = true
return cb(...args)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export enum Features {
Static = 2,
}
enum RenderStrategy {
export enum RenderStrategy {
Unmount,
Hidden,
}