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:
@@ -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')}`
|
||||
|
||||
@@ -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">
|
||||
​
|
||||
</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">
|
||||
​
|
||||
</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',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export enum Features {
|
||||
Static = 2,
|
||||
}
|
||||
|
||||
enum RenderStrategy {
|
||||
export enum RenderStrategy {
|
||||
Unmount,
|
||||
Hidden,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user