diff --git a/packages/@headlessui-react/src/components/transitions/transition.test.tsx b/packages/@headlessui-react/src/components/transitions/transition.test.tsx index a6d20eb..5e860c6 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.test.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.test.tsx @@ -453,21 +453,21 @@ describe('Transitions', () => { expect(timeline).toMatchInlineSnapshot(` "Render 1: - +
- + - + Hello! - + - +
+ +
+ + + + Hello! + + + +
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: - +
- + - + Hello! - + - +
+ +
+ + + + Hello! + + + +
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: - +
- + - + Hello! - + - +
+ +
+ + + + Hello! + + + +
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: - -
- +
+ -
+ +
Render 2: - - class=\\"leave from\\" - + class=\\"leave to\\" + - class=\\"leave from\\" + + class=\\"leave to\\" Render 3: Transition took at least 50ms (yes) - -
- - - - Hello! - - - -
" + -
+ - + - Hello! + - + -
" `) }) ) @@ -704,20 +704,20 @@ describe('Transitions', () => { expect(timeline).toMatchInlineSnapshot(` "Render 1: - -
- +
+ -
+ +
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: - +
- + - + Hello! - + - +
+ +
+ + + + Hello! + + + +
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) - -
- - - - Hello! - - - -
" + -
+ - + - Hello! + - + -
" `) }) ) @@ -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: - -
- +
+ -
+ +
--- - -
- +
+ -
+ +
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 - -
- -
+ - I am fast + -
+ -
- -
- - I am slow - -
- -
" + -
+ -
+ - I am slow + -
+ -
" `) }) ) @@ -1040,50 +1040,50 @@ describe('Transitions', () => { expect(timeline).toMatchInlineSnapshot(` "Render 1: - -
- +
+ -
+ +
--- - -
- +
+ -
+ +
--- - -
- +
+ -
+ +
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 - - - -
- - I am my own root component and I don't talk to the parent - -
- -
- -
+ - + - I am fast + - + -
+ - I am my own root component and I don't talk to the parent + -
+ -
+ -
- -
- - I am slow - -
- -
" + -
+ -
+ - I am slow + -
+ -
" `) }) ) @@ -1151,38 +1151,38 @@ describe('Events', () => { expect(timeline).toMatchInlineSnapshot(` "Render 1: - +
- + - + Hello! - + - +
+ +
+ + + + Hello! + + + +
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) - -
- - - - Hello! - - - -
" + -
+ - + - Hello! + - + -
" `) expect(eventHandler).toHaveBeenCalledTimes(4) diff --git a/packages/@headlessui-react/src/test-utils/execute-timeline.ts b/packages/@headlessui-react/src/test-utils/execute-timeline.ts index bd5bdc5..8b032c1 100644 --- a/packages/@headlessui-react/src/test-utils/execute-timeline.ts +++ b/packages/@headlessui-react/src/test-utils/execute-timeline.ts @@ -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) => (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')}` diff --git a/packages/@headlessui-vue/README.md b/packages/@headlessui-vue/README.md index d86e466..de5853e 100644 --- a/packages/@headlessui-vue/README.md +++ b/packages/@headlessui-vue/README.md @@ -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 diff --git a/packages/@headlessui-vue/examples/src/components/dialog/dialog.vue b/packages/@headlessui-vue/examples/src/components/dialog/dialog.vue index 870f631..124ab08 100644 --- a/packages/@headlessui-vue/examples/src/components/dialog/dialog.vue +++ b/packages/@headlessui-vue/examples/src/components/dialog/dialog.vue @@ -7,132 +7,162 @@ Toggle! - -
-
- -
-
- - - + + +
-
-
-
- - -
-
- - Deactivate account - -
-

- Are you sure you want to deactivate your account? All of your data will be - permanently removed. This action cannot be undone. -

-
- - - - Choose a reason - - - - - + + +
+
+
- - -
-

Signed in as

-

- tom@example.com -

-
+ + + +
+
+
+
+ + +
+
+ + Deactivate account + +
+

+ Are you sure you want to deactivate your account? All of your data will be + permanently removed. This action cannot be undone. +

+
+ + + + Choose a reason + + + + + -
- - Account settings - - - Support - - - New feature (soon) - - - License - -
+ + +
+

Signed in as

+

+ tom@example.com +

+
-
- - Sign out - -
-
-
-
+
+ + Account settings + + + Support + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+ + +
+
+
+
+ + +
-
-
- - -
+
-
- + + +``` + +### Showing and hiding content + +Wrap the content that should be conditionally rendered in a `` component, and use the `show` prop to control whether the content should be visible or hidden. + +```vue + + + +``` + +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 + + + +``` + +### 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 + + + +``` + +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 + + + +``` + +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 + + + +``` + +### Component API + +#### Transition + +```vue + + + +``` + +##### 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 + + + +``` + +##### 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 diff --git a/packages/@headlessui-vue/src/components/transitions/transition.test.ts b/packages/@headlessui-vue/src/components/transitions/transition.test.ts new file mode 100644 index 0000000..74f7e7a --- /dev/null +++ b/packages/@headlessui-vue/src/components/transitions/transition.test.ts @@ -0,0 +1,1311 @@ +import { defineComponent, ref, onMounted, h } from 'vue' +import { render, fireEvent } from '../../test-utils/vue-testing-library' + +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { Transition, TransitionChild } from './transition' + +import { executeTimeline } from '../../test-utils/execute-timeline' +import { html } from '../../test-utils/html' + +jest.mock('../../hooks/use-id') + +afterAll(() => jest.restoreAllMocks()) + +function renderTemplate(input: string | Partial[0]>) { + let defaultComponents = { TransitionRoot: Transition, TransitionChild } + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as Parameters[0] + ) + ) +} + +function getByTestId(id: string) { + return document.querySelector(`[data-testid="${id}"]`)! as HTMLElement +} + +let styles: HTMLElement[] = [] +afterEach(() => { + for (let style of styles.splice(0)) { + style.parentElement?.removeChild(style) + } +}) + +function withStyles(css: string) { + let style = document.createElement('style') + style.type = 'text/css' + style.innerHTML = css + document.head.appendChild(style) + styles.push(style) +} + +it('should render without crashing', () => { + renderTemplate({ + template: html` + +
Children
+
+ `, + }) +}) + +it('should be possible to render a Transition without children', () => { + renderTemplate({ + template: html` + + `, + }) + expect(document.getElementsByClassName('transition')).not.toBeNull() +}) + +it( + 'should yell at us when we forget the required show prop', + suppressConsoleLogs(() => { + expect.assertions(1) + + renderTemplate({ + template: html` + +
Children
+
+ `, + errorCaptured(err) { + expect(err as Error).toEqual( + new Error('A is used but it is missing a `:show="true | false"` prop.') + ) + + return false + }, + }) + }) +) + +describe('Setup API', () => { + describe('shallow', () => { + it('should render a div and its children by default', () => { + let { container } = renderTemplate({ + template: html` + Children + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(html` +
+ Children +
+ `) + }) + + it('should passthrough all the props (that we do not use internally)', () => { + let { container } = renderTemplate({ + template: html` + + Children + + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Children +
+ `) + }) + + it('should render another component if the `as` prop is used and its children by default', () => { + let { container } = renderTemplate({ + template: html` + + Children + + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` + + Children + + `) + }) + + it('should passthrough all the props (that we do not use internally) even when using an `as` prop', () => { + let { container } = renderTemplate({ + template: html` + + Children + + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` + + Children + + `) + }) + + it('should render nothing when the show prop is false', () => { + let { container } = renderTemplate({ + template: html` + Children + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(``) + }) + + it('should be possible to change the underlying DOM tag', () => { + let { container } = renderTemplate({ + template: html` + + Children + + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` + + Children + + `) + }) + }) + + describe('nested', () => { + it( + 'should yell at us when we forget to wrap the `` in a parent component', + suppressConsoleLogs(() => { + expect.assertions(1) + + renderTemplate({ + template: html` +
+ Oops +
+ `, + errorCaptured(err) { + expect(err as Error).toEqual( + new Error('A is used but it is missing a parent .') + ) + return false + }, + }) + }) + ) + + it('should be possible to render a TransitionChild without children', () => { + renderTemplate({ + template: html` + + + + `, + }) + expect(document.getElementsByClassName('transition')).not.toBeNull() + }) + + it('should be possible to nest transition components', () => { + let { container } = renderTemplate({ + template: html` +
+ + Sidebar + Content + +
+ `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+
+ Sidebar +
+
+ Content +
+
+
+ `) + }) + + it('should be possible to change the underlying DOM tag of the TransitionChild components', () => { + let { container } = renderTemplate({ + template: html` +
+ + Sidebar + Content + +
+ `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+ +
+ Content +
+
+
+ `) + }) + + it('should be possible to change the underlying DOM tag of the Transition component and TransitionChild components', () => { + let { container } = renderTemplate({ + template: html` +
+ + Sidebar + Content + +
+ `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+ +
+ Content +
+
+
+ `) + }) + + it('should be possible to use render props on the TransitionChild components', () => { + let { container } = renderTemplate({ + template: html` +
+ + +
Content
+
+
+ `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+ +
+ Content +
+
+
+ `) + }) + + it('should be possible to use render props on the Transition and TransitionChild components', () => { + let { container } = renderTemplate({ + template: html` +
+ +
+ + + + +
Content
+
+
+
+
+ `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+
+ +
+ Content +
+
+
+ `) + }) + + it( + 'should yell at us when we forgot to forward the ref on one of the TransitionChild components', + suppressConsoleLogs(() => { + expect.hasAssertions() + + let Dummy = defineComponent({ + setup() { + return () => null + }, + }) + + renderTemplate({ + components: { TransitionRoot: Transition, TransitionChild, Dummy }, + template: html` +
+ + Sidebar + Content + +
+ `, + errorCaptured(err) { + expect(err as Error).toEqual( + new Error('Did you forget to passthrough the `ref` to the actual DOM node?') + ) + return false + }, + }) + }) + ) + }) + + describe('transition classes', () => { + it('should be possible to passthrough the transition classes', () => { + let { container } = renderTemplate({ + components: { TransitionRoot: Transition }, + template: html` + + Children + + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Children +
+ `) + }) + + it('should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true', () => { + let { container } = renderTemplate({ + template: html` + + Children + + `, + }) + + expect(container.firstChild).toMatchInlineSnapshot(` +
+ Children +
+ `) + }) + }) +}) + +describe('Transitions', () => { + describe('shallow transitions', () => { + it('should transition in completely (duration defined in milliseconds)', async () => { + let enterDuration = 50 + + withStyles(` + .enter { transition-duration: ${enterDuration}ms; } + .from { opacity: 0%; } + .to { opacity: 100%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - + +
+ + + + Hello! + + + +
+ + Render 2: + - class=\\"enter from\\" + + class=\\"enter to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter to\\" + + class=\\"\\"" + `) + }) + + it('should transition in completely (duration defined in seconds)', async () => { + let enterDuration = 50 + + withStyles(` + .enter { transition-duration: ${enterDuration / 1000}s; } + .from { opacity: 0%; } + .to { opacity: 100%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - + +
+ + + + Hello! + + + +
+ + Render 2: + - class=\\"enter from\\" + + class=\\"enter to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter to\\" + + class=\\"\\"" + `) + }) + + it('should transition in completely (duration defined in seconds) in (render strategy = hidden)', async () => { + let enterDuration = 50 + + withStyles(` + .enter { transition-duration: ${enterDuration / 1000}s; } + .from { opacity: 0%; } + .to { opacity: 100%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - hidden=\\"\\" + - style=\\"display: none;\\" + + class=\\"enter from\\" + + Render 2: + - class=\\"enter from\\" + + class=\\"enter to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter to\\" + + class=\\"\\"" + `) + }) + + it('should transition in completely', async () => { + let enterDuration = 50 + + withStyles(` + .enter { transition-duration: ${enterDuration}ms; } + .from { opacity: 0%; } + .to { opacity: 100%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - + +
+ + + + Hello! + + + +
+ + Render 2: + - class=\\"enter from\\" + + class=\\"enter to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter to\\" + + class=\\"\\"" + `) + }) + + it( + 'should transition out completely', + suppressConsoleLogs(async () => { + let leaveDuration = 50 + + withStyles(` + .leave { transition-duration: ${leaveDuration}ms; } + .from { opacity: 0%; } + .to { opacity: 100%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(true) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + -
+ +
+ + Render 2: + - class=\\"leave from\\" + + class=\\"leave to\\" + + Render 3: Transition took at least 50ms (yes) + -
+ - + - Hello! + - + -
+ + " + `) + }) + ) + + it( + 'should transition out completely (render strategy = hidden)', + suppressConsoleLogs(async () => { + let leaveDuration = 50 + + withStyles(` + .leave { transition-duration: ${leaveDuration}ms; } + .from { opacity: 0%; } + .to { opacity: 100%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(true) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + -
+ +
+ + Render 2: + - class=\\"leave from\\" + + class=\\"leave to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"leave to\\" + + class=\\"\\" + + hidden=\\"\\" + + style=\\"display: none;\\"" + `) + }) + ) + + it( + 'should transition in and out completely', + suppressConsoleLogs(async () => { + let enterDuration = 50 + let leaveDuration = 75 + + withStyles(` + .enter { transition-duration: ${enterDuration}ms; } + .enter-from { opacity: 0%; } + .enter-to { opacity: 100%; } + + .leave { transition-duration: ${leaveDuration}ms; } + .leave-from { opacity: 100%; } + .leave-to { opacity: 0%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - + +
+ + + + Hello! + + + +
+ + Render 2: + - class=\\"enter enter-from\\" + + class=\\"enter enter-to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter enter-to\\" + + class=\\"\\" + + Render 4: + - class=\\"\\" + + class=\\"leave leave-from\\" + + Render 5: + - class=\\"leave leave-from\\" + + class=\\"leave leave-to\\" + + Render 6: Transition took at least 75ms (yes) + -
+ - + - Hello! + - + -
+ + " + `) + }) + ) + + it( + 'should transition in and out completely (render strategy = hidden)', + suppressConsoleLogs(async () => { + let enterDuration = 50 + let leaveDuration = 75 + + withStyles(` + .enter { transition-duration: ${enterDuration}ms; } + .enter-from { opacity: 0%; } + .enter-to { opacity: 100%; } + + .leave { transition-duration: ${leaveDuration}ms; } + .leave-from { opacity: 100%; } + .leave-to { opacity: 0%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - hidden=\\"\\" + - style=\\"display: none;\\" + + class=\\"enter enter-from\\" + + Render 2: + - class=\\"enter enter-from\\" + + class=\\"enter enter-to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter enter-to\\" + + class=\\"\\" + + Render 4: + - class=\\"\\" + + class=\\"leave leave-from\\" + + Render 5: + - class=\\"leave leave-from\\" + + class=\\"leave leave-to\\" + + Render 6: Transition took at least 75ms (yes) + - class=\\"leave leave-to\\" + + class=\\"\\" + + hidden=\\"\\" + + style=\\"display: none;\\" + + Render 7: + - class=\\"\\" + - hidden=\\"\\" + - style=\\"display: none;\\" + + class=\\"enter enter-from\\" + + Render 8: + - class=\\"enter enter-from\\" + + class=\\"enter enter-to\\" + + Render 9: Transition took at least 75ms (yes) + - class=\\"enter enter-to\\" + + class=\\"\\"" + `) + }) + ) + }) + + describe('nested transitions', () => { + it( + 'should not unmount the whole tree when some children are still transitioning', + suppressConsoleLogs(async () => { + let slowLeaveDuration = 150 + let fastLeaveDuration = 50 + + withStyles(` + .leave-slow { transition-duration: ${slowLeaveDuration}ms; } + .leave-from { opacity: 100%; } + .leave-to { opacity: 0%; } + + .leave-fast { transition-duration: ${fastLeaveDuration}ms; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition, TransitionChild }, + template: html` + + + I am fast + + + I am slow + + + + + `, + setup() { + let show = ref(true) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return [ + null, // Initial render + null, // Setup leave classes + fastLeaveDuration, // Done with fast leave + slowLeaveDuration - fastLeaveDuration, // Done with slow leave (which starts at the same time, but it is compaired with previous render snapshot so we have to subtract those) + ] + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + -
+ +
+ --- + -
+ +
+ + Render 2: + - class=\\"leave-fast leave-from\\" + + class=\\"leave-fast leave-to\\" + --- + - class=\\"leave-slow leave-from\\" + + class=\\"leave-slow leave-to\\" + + Render 3: Transition took at least 50ms (yes) + -
+ - I am fast + -
+ + + + Render 4: Transition took at least 100ms (yes) + -
+ --- + -
+ - I am slow + -
+ -
" + `) + }) + ) + + it( + 'should not unmount the whole tree when some children are still transitioning', + suppressConsoleLogs(async () => { + let slowLeaveDuration = 150 + let fastLeaveDuration = 50 + + withStyles(` + .leave-slow { transition-duration: ${slowLeaveDuration}ms; } + .leave-from { opacity: 100%; } + .leave-to { opacity: 0%; } + + .leave-fast { transition-duration: ${fastLeaveDuration}ms; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition, TransitionChild }, + template: html` + + + I am fast + + I am my own root component and I don't talk to the parent + + + + I am slow + + + + + `, + setup() { + let show = ref(true) + return { show } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return [ + null, // Initial render + null, // Setup leave classes + fastLeaveDuration, // Done with fast leave + slowLeaveDuration - fastLeaveDuration, // Done with slow leave (which starts at the same time, but it is compaired with previous render snapshot so we have to subtract those) + ] + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + -
+ +
+ --- + -
+ +
+ --- + -
+ +
+ + Render 2: + - class=\\"leave-fast leave-from\\" + + class=\\"leave-fast leave-to\\" + --- + - class=\\"leave-slow leave-from\\" + + class=\\"leave-slow leave-to\\" + + Render 3: Transition took at least 50ms (yes) + -
+ - + - I am fast + - + -
+ - I am my own root component and I don't talk to the parent + -
+ -
+ + + + Render 4: Transition took at least 100ms (yes) + -
+ --- + -
+ - I am slow + -
+ -
" + `) + }) + ) + }) +}) + +describe('Events', () => { + it( + 'should fire events for all the stages', + suppressConsoleLogs(async () => { + let eventHandler = jest.fn() + let enterDuration = 50 + let leaveDuration = 75 + + withStyles(` + .enter { transition-duration: ${enterDuration}ms; } + .enter-from { opacity: 0%; } + .enter-to { opacity: 100%; } + + .leave { transition-duration: ${leaveDuration}ms; } + .leave-from { opacity: 100%; } + .leave-to { opacity: 0%; } + `) + + let Example = defineComponent({ + components: { TransitionRoot: Transition }, + template: html` + + Hello! + + + + `, + setup() { + let show = ref(false) + let start = ref(Date.now()) + + onMounted(() => (start.value = Date.now())) + + return { show, start, eventHandler } + }, + }) + + let timeline = await executeTimeline(Example, [ + // Toggle to show + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(enterDuration) + }, + // Toggle to hide + () => { + fireEvent.click(getByTestId('toggle')) + return executeTimeline.fullTransition(leaveDuration) + }, + ]) + + expect(timeline).toMatchInlineSnapshot(` + "Render 1: + - + +
+ + + + Hello! + + + +
+ + Render 2: + - class=\\"enter enter-from\\" + + class=\\"enter enter-to\\" + + Render 3: Transition took at least 50ms (yes) + - class=\\"enter enter-to\\" + + class=\\"\\" + + Render 4: + - class=\\"\\" + + class=\\"leave leave-from\\" + + Render 5: + - class=\\"leave leave-from\\" + + class=\\"leave leave-to\\" + + Render 6: Transition took at least 75ms (yes) + -
+ - + - Hello! + - + -
+ + " + `) + + expect(eventHandler).toHaveBeenCalledTimes(4) + expect(eventHandler.mock.calls.map(([name]) => name)).toEqual([ + // Order is important here + 'beforeEnter', + 'afterEnter', + 'beforeLeave', + 'afterLeave', + ]) + + let enterHookDiff = eventHandler.mock.calls[1][1] - eventHandler.mock.calls[0][1] + expect(enterHookDiff).toBeGreaterThanOrEqual(enterDuration) + expect(enterHookDiff).toBeLessThanOrEqual(enterDuration * 2) + + let leaveHookDiff = eventHandler.mock.calls[3][1] - eventHandler.mock.calls[2][1] + expect(leaveHookDiff).toBeGreaterThanOrEqual(leaveDuration) + expect(leaveHookDiff).toBeLessThanOrEqual(leaveDuration * 2) + }) + ) +}) diff --git a/packages/@headlessui-vue/src/components/transitions/transition.ts b/packages/@headlessui-vue/src/components/transitions/transition.ts new file mode 100644 index 0000000..0cd8aa5 --- /dev/null +++ b/packages/@headlessui-vue/src/components/transitions/transition.ts @@ -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 + +function splitClasses(classes: string = '') { + return classes.split(' ').filter(className => className.trim().length > 1) +} + +interface TransitionContextValues { + show: Ref + appear: Ref +} +let TransitionContext = Symbol('TransitionContext') as InjectionKey + +enum TreeStates { + Visible = 'visible', + Hidden = 'hidden', +} + +function useTransitionContext() { + let context = inject(TransitionContext, null) + + if (context === null) { + throw new Error('A is used but it is missing a parent .') + } + + return context +} + +function useParentNesting() { + let context = inject(NestingContext, null) + + if (context === null) { + throw new Error('A is used but it is missing a parent .') + } + + 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 + +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([]) + + 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(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 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 } + }, +}) diff --git a/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts b/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts new file mode 100644 index 0000000..0b0f51f --- /dev/null +++ b/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts @@ -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('
') + + // Start of transition + expect(snapshots[1].content).toEqual('
') + + // 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('
') + + 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(`
`) + + // Start of transition + expect(snapshots[1].content).toEqual( + `
` + ) + + expect(snapshots[2].content).toEqual( + `
` + ) + + 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( + `
` + ) +}) + +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') +}) diff --git a/packages/@headlessui-vue/src/components/transitions/utils/transition.ts b/packages/@headlessui-vue/src/components/transitions/utils/transition.ts new file mode 100644 index 0000000..24baf21 --- /dev/null +++ b/packages/@headlessui-vue/src/components/transitions/utils/transition.ts @@ -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 +} diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index 62cdf54..d3c23e5 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -55,5 +55,9 @@ it('should expose the correct components', () => { 'Switch', 'SwitchLabel', 'SwitchDescription', + + // Transition + 'TransitionChild', + 'Transition', ]) }) diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts index ef704fb..cce0dd2 100644 --- a/packages/@headlessui-vue/src/index.ts +++ b/packages/@headlessui-vue/src/index.ts @@ -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' diff --git a/packages/@headlessui-vue/src/test-utils/execute-timeline.ts b/packages/@headlessui-vue/src/test-utils/execute-timeline.ts new file mode 100644 index 0000000..0fbec32 --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/execute-timeline.ts @@ -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, + steps: ((tools: ReturnType) => (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 | 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 +} diff --git a/packages/@headlessui-vue/src/test-utils/report-dom-node-changes.ts b/packages/@headlessui-vue/src/test-utils/report-dom-node-changes.ts new file mode 100644 index 0000000..2f33091 --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/report-dom-node-changes.ts @@ -0,0 +1,20 @@ +import { disposables } from '../utils/disposables' + +export function reportChanges(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 +} diff --git a/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts b/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts index 87abfaa..e41af7a 100644 --- a/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts +++ b/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts @@ -23,11 +23,16 @@ export function render(TestComponent: any, options?: Parameters[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 + }, } } diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts new file mode 100644 index 0000000..7c9a388 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/disposables.ts @@ -0,0 +1,33 @@ +export function disposables() { + let disposables: Function[] = [] + + let api = { + requestAnimationFrame(...args: Parameters) { + let raf = requestAnimationFrame(...args) + api.add(() => cancelAnimationFrame(raf)) + }, + + nextFrame(...args: Parameters) { + api.requestAnimationFrame(() => { + api.requestAnimationFrame(...args) + }) + }, + + setTimeout(...args: Parameters) { + 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 +} diff --git a/packages/@headlessui-vue/src/utils/once.ts b/packages/@headlessui-vue/src/utils/once.ts new file mode 100644 index 0000000..b41d570 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/once.ts @@ -0,0 +1,9 @@ +export function once(cb: (...args: T[]) => void) { + let state = { called: false } + + return (...args: T[]) => { + if (state.called) return + state.called = true + return cb(...args) + } +} diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts index e6f555d..87882bc 100644 --- a/packages/@headlessui-vue/src/utils/render.ts +++ b/packages/@headlessui-vue/src/utils/render.ts @@ -21,7 +21,7 @@ export enum Features { Static = 2, } -enum RenderStrategy { +export enum RenderStrategy { Unmount, Hidden, }