+
+ 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,
}