@pyreon/kinetic 0.24.4 → 0.24.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +10 -12
  2. package/src/Collapse.tsx +0 -166
  3. package/src/Stagger.tsx +0 -63
  4. package/src/Transition.tsx +0 -280
  5. package/src/TransitionGroup.tsx +0 -139
  6. package/src/__tests__/Collapse.test.tsx +0 -803
  7. package/src/__tests__/GroupRenderer.test.tsx +0 -434
  8. package/src/__tests__/StaggerRenderer.test.tsx +0 -523
  9. package/src/__tests__/Transition.ssr.test.tsx +0 -183
  10. package/src/__tests__/Transition.test.tsx +0 -403
  11. package/src/__tests__/TransitionItem.test.tsx +0 -514
  12. package/src/__tests__/kinetic-modes.ssr.test.tsx +0 -214
  13. package/src/__tests__/kinetic.browser.test.tsx +0 -327
  14. package/src/__tests__/kinetic.test.tsx +0 -565
  15. package/src/__tests__/presets.test.ts +0 -46
  16. package/src/__tests__/stagger-component-children-hydration.test.tsx +0 -191
  17. package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +0 -141
  18. package/src/__tests__/useAnimationEnd.test.ts +0 -194
  19. package/src/__tests__/useReducedMotion.test.ts +0 -160
  20. package/src/__tests__/useTransitionState.test.ts +0 -132
  21. package/src/__tests__/utils.test.ts +0 -139
  22. package/src/index.ts +0 -15
  23. package/src/jsx-augment.d.ts +0 -12
  24. package/src/kinetic/CollapseRenderer.tsx +0 -216
  25. package/src/kinetic/GroupRenderer.tsx +0 -149
  26. package/src/kinetic/StaggerRenderer.tsx +0 -94
  27. package/src/kinetic/TransitionItem.tsx +0 -250
  28. package/src/kinetic/TransitionRenderer.tsx +0 -230
  29. package/src/kinetic/createKineticComponent.tsx +0 -224
  30. package/src/kinetic/types.ts +0 -149
  31. package/src/kinetic.ts +0 -25
  32. package/src/presets.ts +0 -66
  33. package/src/types.ts +0 -118
  34. package/src/useAnimationEnd.ts +0 -59
  35. package/src/useReducedMotion.ts +0 -28
  36. package/src/useTransitionState.ts +0 -62
  37. package/src/utils.ts +0 -113
@@ -1,191 +0,0 @@
1
- /** @jsxImportSource @pyreon/core */
2
- /**
3
- * Regression: `kinetic('div').stagger()` with `show={() => true}` +
4
- * `appear` + multiple component-VNode children rendered `<undefined>`
5
- * tags in place of the children's actual DOM post-hydrate.
6
- *
7
- * Real-app reporter (examples/bokisch.com Intro section): SSG'd HTML
8
- * carried `<h1>Hello</h1>` + tagline + icons; client hydration produced
9
- * `<undefined></undefined>` tags (literal HTML element with tagName
10
- * "UNDEFINED") + `<!--pyreon-->` markers in place of every child.
11
- *
12
- * Bug class — Pyreon-compiler prop-inlining + cloneVNode-on-a-function:
13
- *
14
- * 1. The compiler rewrites local `const children = obj.x` then
15
- * `<Comp>{children}</Comp>` as `Comp({..., children: () => obj.x})`.
16
- * Component receives `props.children` as a FUNCTION, not an array.
17
- *
18
- * 2. StaggerRenderer iterated `(Array.isArray(children) ? children : [children])`
19
- * directly. `[function].filter(isVNode)` collapsed to `[]` → the
20
- * kinetic `<div>` rendered with zero children.
21
- *
22
- * 3. Even after StaggerRenderer's fix, TransitionItem's `cloneVNode(props.children, {ref})`
23
- * tried to clone the same function-wrapped value (also auto-wrapped
24
- * by the compiler one level down — `{cloneVNode(child, {style})}`
25
- * became `() => cloneVNode(child, {style})`). Spreading a function
26
- * via `{...fn, props: {...}}` yields `{props: {...}}` (no own
27
- * enumerable properties on functions) — the resulting vnode had
28
- * `type: undefined`. mountElement called `document.createElement(undefined)`
29
- * → the browser produced literal `<undefined>` tags.
30
- *
31
- * Fix: `resolveChildren` helper in both StaggerRenderer (iteration) AND
32
- * TransitionItem (cloning). Unwraps function-wrapped children eagerly
33
- * since kinetic snapshots children at render time and does not observe
34
- * children changes.
35
- *
36
- * Bisect-verified: reverting either `resolveChildren` call in this PR
37
- * fails this spec — StaggerRenderer revert produces zero children in the
38
- * kinetic `<div>`; TransitionItem revert produces `<undefined>` tags
39
- * with the right cloned style.
40
- */
41
- import type { VNode } from '@pyreon/core'
42
- import { h } from '@pyreon/core'
43
- import { mount } from '@pyreon/runtime-dom'
44
- import { afterEach, describe, expect, it } from 'vitest'
45
- import kinetic from '../kinetic'
46
- import TransitionItem from '../kinetic/TransitionItem'
47
-
48
- // Build a kinetic Component VNode whose `props.children` is a FUNCTION
49
- // (not an array), mirroring what the Pyreon vite-plugin emits when JSX
50
- // children are inlined back at the call site (`<Entrance>{children}</Entrance>`
51
- // → `jsx(Entrance, { children: () => h.children })`). The kinetic test
52
- // pipeline uses `vl_rolldown_build` which does NOT do Pyreon's
53
- // prop-inlining, so we construct the shape directly.
54
- const buildEntranceWithFunctionChildren = (
55
- // Wider call signature — kinetic('div').stagger() returns a stagger-mode
56
- // component whose precise typed shape (`KineticComponent<'div', 'stagger'>`)
57
- // is narrower than what `kinetic()`'s default returns. The function-
58
- // children wrapper bypasses the strict children-required typing.
59
- Entrance: (props: Record<string, unknown>) => VNode | null,
60
- childArray: VNode[],
61
- ): VNode => {
62
- // h() puts children in vnode.children (rest args). For mountComponent's
63
- // merge to leave props.children alone, set it explicitly here.
64
- return h(Entrance, {
65
- show: () => true,
66
- appear: true,
67
- children: (() => childArray) as unknown as VNode[],
68
- })
69
- }
70
-
71
- let containers: HTMLElement[] = []
72
- afterEach(() => {
73
- for (const c of containers) c.remove()
74
- containers = []
75
- })
76
-
77
- describe('kinetic("div").stagger() — function-wrapped children survive render', () => {
78
- it('iterates function-wrapped children correctly (no <undefined> tags)', () => {
79
- const Entrance = kinetic('div')
80
- .enter({ opacity: '0' })
81
- .enterTo({ opacity: '1' })
82
- .stagger({ interval: 20 })
83
-
84
- const tree = buildEntranceWithFunctionChildren(Entrance as never, [
85
- h('h1', { 'data-id': 'heading' }, 'Hello'),
86
- h('p', { 'data-id': 'tagline' }, 'tagline'),
87
- h('ul', { 'data-id': 'icons' }, h('li', null, 'icon-a')),
88
- ])
89
-
90
- const container = document.createElement('div')
91
- document.body.appendChild(container)
92
- containers.push(container)
93
-
94
- const dispose = mount(tree as VNode, container)
95
-
96
- // Children are rendered with proper element tags — NOT <undefined>
97
- const heading = container.querySelector('[data-id="heading"]')
98
- const tagline = container.querySelector('[data-id="tagline"]')
99
- const icons = container.querySelector('[data-id="icons"]')
100
-
101
- expect(
102
- heading,
103
- `heading missing — pre-fix the function-wrapped child got mounted as <undefined>. ` +
104
- `container.innerHTML=${container.innerHTML.slice(0, 600)}`,
105
- ).not.toBeNull()
106
- expect(heading?.tagName).toBe('H1')
107
- expect(heading?.textContent).toBe('Hello')
108
-
109
- expect(tagline).not.toBeNull()
110
- expect(tagline?.tagName).toBe('P')
111
-
112
- expect(icons).not.toBeNull()
113
- expect(icons?.tagName).toBe('UL')
114
-
115
- // Sanity: no `<undefined>` tags should exist anywhere (pre-fix
116
- // TransitionItem's cloneVNode(props.children, {ref}) on a function
117
- // produced `{type: undefined, props: {ref}}` → mountElement called
118
- // document.createElement(undefined) → `<undefined>` element).
119
- expect(container.querySelector('undefined')).toBeNull()
120
-
121
- dispose()
122
- })
123
-
124
- it('TransitionItem resolves function-wrapped children before cloneVNode (no <undefined> tag)', () => {
125
- // Direct test for the SECOND fix-site — TransitionItem's
126
- // `cloneVNode(props.children, {ref})`. Pre-fix, when the parent
127
- // (StaggerRenderer/GroupRenderer) emits `<TransitionItem>{cloneVNode(c, {style})}</TransitionItem>`
128
- // under the Pyreon vite-plugin, the compiler wraps the JSX child as
129
- // `() => cloneVNode(c, {style})`. TransitionItem then receives
130
- // `props.children = function`. `cloneVNode(function, {ref})` spreads
131
- // the function (no own enumerable properties) → produces
132
- // `{type: undefined, props: {ref}}` → mountElement creates literal
133
- // `<undefined>` tag.
134
- const childVNode = h('h1', { 'data-id': 'ti-heading' }, 'Hello')
135
- const tree = h(TransitionItem, {
136
- show: () => true,
137
- appear: false,
138
- timeout: 100,
139
- enterStyle: { opacity: '0' },
140
- enterToStyle: { opacity: '1' },
141
- enterTransition: 'opacity 50ms ease',
142
- // Function-wrapped children, mirroring the compiler's emit.
143
- children: (() => childVNode) as unknown as VNode,
144
- })
145
-
146
- const container = document.createElement('div')
147
- document.body.appendChild(container)
148
- containers.push(container)
149
-
150
- const dispose = mount(tree as VNode, container)
151
-
152
- const heading = container.querySelector('[data-id="ti-heading"]')
153
- expect(
154
- heading,
155
- `heading missing — pre-fix TransitionItem cloned the function, ` +
156
- `producing <undefined>. container.innerHTML=${container.innerHTML.slice(0, 400)}`,
157
- ).not.toBeNull()
158
- expect(heading?.tagName).toBe('H1')
159
- expect(heading?.textContent).toBe('Hello')
160
- expect(container.querySelector('undefined')).toBeNull()
161
-
162
- dispose()
163
- })
164
-
165
- it('iterates static-array children correctly (control — was always working)', () => {
166
- const Entrance = kinetic('div')
167
- .enter({ opacity: '0' })
168
- .enterTo({ opacity: '1' })
169
- .stagger({ interval: 20 })
170
-
171
- // No compiler wrap — children as a plain array.
172
- const tree = h(
173
- Entrance,
174
- { show: () => true, appear: true },
175
- h('h1', { 'data-id': 'heading-static' }, 'Static'),
176
- h('p', { 'data-id': 'tagline-static' }, 't'),
177
- )
178
-
179
- const container = document.createElement('div')
180
- document.body.appendChild(container)
181
- containers.push(container)
182
-
183
- const dispose = mount(tree as VNode, container)
184
-
185
- expect(container.querySelector('[data-id="heading-static"]')?.tagName).toBe('H1')
186
- expect(container.querySelector('[data-id="tagline-static"]')?.tagName).toBe('P')
187
- expect(container.querySelector('undefined')).toBeNull()
188
-
189
- dispose()
190
- })
191
- })
@@ -1,141 +0,0 @@
1
- /** @jsxImportSource @pyreon/core */
2
- /**
3
- * Regression: PR #731 fixed the kinetic-mode renderers (StaggerRenderer +
4
- * TransitionItem under `src/kinetic/`) but missed the parallel TOP-LEVEL
5
- * `<Transition>` and `<Stagger>` components in `src/Transition.tsx` and
6
- * `src/Stagger.tsx`. They have the SAME iteration + cloneVNode shape and
7
- * the SAME bug when the Pyreon compiler wraps the children prop in
8
- * `() => x` (the prop-inlining pass).
9
- *
10
- * The Pyreon vite-plugin auto-wraps `<Comp>{x}</Comp>` JSX child
11
- * expressions in `() => x` for stable prop-derived references; downstream
12
- * libraries that iterate `props.children` directly at the VNode level or
13
- * `cloneVNode` them silently break — the function spread produces
14
- * `{type: undefined}` → `<undefined>` DOM tags. PR #732 added the
15
- * compiler carve-out for stable references; library-side `resolveChildren`
16
- * is still needed for the CallExpression-inside-JSX-child shape that the
17
- * compiler (correctly) doesn't optimize.
18
- *
19
- * Bisect-verified: reverting the `resolveChildren` call in `Stagger.tsx`
20
- * fails the Stagger spec (no children rendered); reverting in
21
- * `Transition.tsx` fails the Transition spec (`<undefined>` tag rendered
22
- * instead of the cloned child).
23
- */
24
- import type { VNode } from '@pyreon/core'
25
- import { h } from '@pyreon/core'
26
- import { mount } from '@pyreon/runtime-dom'
27
- import { afterEach, describe, expect, it } from 'vitest'
28
- import Stagger from '../Stagger'
29
- import Transition from '../Transition'
30
-
31
- let containers: HTMLElement[] = []
32
- afterEach(() => {
33
- for (const c of containers) c.remove()
34
- containers = []
35
- })
36
-
37
- describe('top-level <Stagger> — function-wrapped children survive render', () => {
38
- it('iterates function-wrapped children correctly (no empty render)', () => {
39
- const childArray: VNode[] = [
40
- h('h1', { 'data-id': 'st-h1' }, 'Hello'),
41
- h('p', { 'data-id': 'st-p' }, 'tagline'),
42
- h('ul', { 'data-id': 'st-ul' }, h('li', null, 'a')),
43
- ]
44
-
45
- const tree = h(Stagger, {
46
- show: () => true,
47
- appear: true,
48
- interval: 20,
49
- // Compiler-emitted shape: children is a function returning the array.
50
- children: (() => childArray) as unknown as VNode[],
51
- })
52
-
53
- const container = document.createElement('div')
54
- document.body.appendChild(container)
55
- containers.push(container)
56
-
57
- const dispose = mount(tree as VNode, container)
58
-
59
- const h1 = container.querySelector('[data-id="st-h1"]')
60
- const p = container.querySelector('[data-id="st-p"]')
61
- const ul = container.querySelector('[data-id="st-ul"]')
62
-
63
- expect(
64
- h1,
65
- `Stagger collapsed when children is a function — html=${container.innerHTML.slice(0, 400)}`,
66
- ).not.toBeNull()
67
- expect(h1?.tagName).toBe('H1')
68
- expect(h1?.textContent).toBe('Hello')
69
- expect(p?.tagName).toBe('P')
70
- expect(ul?.tagName).toBe('UL')
71
- expect(container.querySelector('undefined')).toBeNull()
72
-
73
- dispose()
74
- })
75
-
76
- it('static-array children control — was always working', () => {
77
- const tree = h(
78
- Stagger,
79
- { show: () => true, appear: true, interval: 20 },
80
- h('h1', { 'data-id': 'st-static' }, 'Static'),
81
- h('p', { 'data-id': 'st-static-p' }, 't'),
82
- )
83
-
84
- const container = document.createElement('div')
85
- document.body.appendChild(container)
86
- containers.push(container)
87
- const dispose = mount(tree as VNode, container)
88
-
89
- expect(container.querySelector('[data-id="st-static"]')?.tagName).toBe('H1')
90
- expect(container.querySelector('[data-id="st-static-p"]')?.tagName).toBe('P')
91
-
92
- dispose()
93
- })
94
- })
95
-
96
- describe('top-level <Transition> — function-wrapped children survive render', () => {
97
- it('resolves function-wrapped children before cloneVNode (no <undefined> tag)', () => {
98
- const childVNode = h('h1', { 'data-id': 'tn-h1' }, 'Hello')
99
-
100
- const tree = h(Transition, {
101
- show: () => true,
102
- appear: false,
103
- // Compiler-emitted shape.
104
- children: (() => childVNode) as unknown as VNode,
105
- })
106
-
107
- const container = document.createElement('div')
108
- document.body.appendChild(container)
109
- containers.push(container)
110
-
111
- const dispose = mount(tree as VNode, container)
112
-
113
- const h1 = container.querySelector('[data-id="tn-h1"]')
114
- expect(
115
- h1,
116
- `Transition produced <undefined> — html=${container.innerHTML.slice(0, 400)}`,
117
- ).not.toBeNull()
118
- expect(h1?.tagName).toBe('H1')
119
- expect(h1?.textContent).toBe('Hello')
120
- expect(container.querySelector('undefined')).toBeNull()
121
-
122
- dispose()
123
- })
124
-
125
- it('static-VNode child control — was always working', () => {
126
- const tree = h(
127
- Transition,
128
- { show: () => true, appear: false },
129
- h('h1', { 'data-id': 'tn-static' }, 'Static'),
130
- )
131
-
132
- const container = document.createElement('div')
133
- document.body.appendChild(container)
134
- containers.push(container)
135
- const dispose = mount(tree as VNode, container)
136
-
137
- expect(container.querySelector('[data-id="tn-static"]')?.tagName).toBe('H1')
138
-
139
- dispose()
140
- })
141
- })
@@ -1,194 +0,0 @@
1
- import { signal } from '@pyreon/reactivity'
2
- import useAnimationEnd from '../useAnimationEnd'
3
-
4
- const createMockRef = () => {
5
- const el = document.createElement('div')
6
- return { current: el }
7
- }
8
-
9
- describe('useAnimationEnd', () => {
10
- beforeEach(() => vi.useFakeTimers())
11
- afterEach(() => vi.useRealTimers())
12
-
13
- it('calls onEnd when transitionend fires on the element', () => {
14
- const onEnd = vi.fn()
15
- const ref = createMockRef()
16
- const active = signal(true)
17
-
18
- useAnimationEnd({ ref, onEnd, active })
19
-
20
- const event = new Event('transitionend', { bubbles: true })
21
- Object.defineProperty(event, 'target', { value: ref.current })
22
- ref.current.dispatchEvent(event)
23
-
24
- expect(onEnd).toHaveBeenCalledTimes(1)
25
- })
26
-
27
- it('calls onEnd when animationend fires on the element', () => {
28
- const onEnd = vi.fn()
29
- const ref = createMockRef()
30
- const active = signal(true)
31
-
32
- useAnimationEnd({ ref, onEnd, active })
33
-
34
- const event = new Event('animationend', { bubbles: true })
35
- Object.defineProperty(event, 'target', { value: ref.current })
36
- ref.current.dispatchEvent(event)
37
-
38
- expect(onEnd).toHaveBeenCalledTimes(1)
39
- })
40
-
41
- it('ignores bubbled events from children', () => {
42
- const onEnd = vi.fn()
43
- const ref = createMockRef()
44
- const child = document.createElement('span')
45
- ref.current.appendChild(child)
46
- const active = signal(true)
47
-
48
- useAnimationEnd({ ref, onEnd, active })
49
-
50
- const event = new Event('transitionend', { bubbles: true })
51
- child.dispatchEvent(event)
52
-
53
- expect(onEnd).not.toHaveBeenCalled()
54
- })
55
-
56
- it('fires timeout fallback when no event fires', () => {
57
- const onEnd = vi.fn()
58
- const ref = createMockRef()
59
- const active = signal(true)
60
-
61
- useAnimationEnd({ ref, onEnd, active, timeout: 1000 })
62
-
63
- expect(onEnd).not.toHaveBeenCalled()
64
-
65
- vi.advanceTimersByTime(1000)
66
-
67
- expect(onEnd).toHaveBeenCalledTimes(1)
68
- })
69
-
70
- it('uses default timeout of 5000ms', () => {
71
- const onEnd = vi.fn()
72
- const ref = createMockRef()
73
- const active = signal(true)
74
-
75
- useAnimationEnd({ ref, onEnd, active })
76
-
77
- vi.advanceTimersByTime(4999)
78
- expect(onEnd).not.toHaveBeenCalled()
79
-
80
- vi.advanceTimersByTime(1)
81
- expect(onEnd).toHaveBeenCalledTimes(1)
82
- })
83
-
84
- it('only fires onEnd once even if multiple events fire', () => {
85
- const onEnd = vi.fn()
86
- const ref = createMockRef()
87
- const active = signal(true)
88
-
89
- useAnimationEnd({ ref, onEnd, active })
90
-
91
- const event1 = new Event('transitionend', { bubbles: true })
92
- Object.defineProperty(event1, 'target', { value: ref.current })
93
- ref.current.dispatchEvent(event1)
94
-
95
- const event2 = new Event('animationend', { bubbles: true })
96
- Object.defineProperty(event2, 'target', { value: ref.current })
97
- ref.current.dispatchEvent(event2)
98
-
99
- expect(onEnd).toHaveBeenCalledTimes(1)
100
- })
101
-
102
- it('does not fire when active is false', () => {
103
- const onEnd = vi.fn()
104
- const ref = createMockRef()
105
- const active = signal(false)
106
-
107
- useAnimationEnd({ ref, onEnd, active, timeout: 100 })
108
-
109
- vi.advanceTimersByTime(200)
110
-
111
- expect(onEnd).not.toHaveBeenCalled()
112
- })
113
-
114
- it('does not fire when active=true but ref.current is null', () => {
115
- const onEnd = vi.fn()
116
- const ref = { current: null } as { current: HTMLElement | null }
117
- const active = signal(true)
118
-
119
- useAnimationEnd({ ref, onEnd, active, timeout: 100 })
120
-
121
- // No timer should be set when ref is null
122
- vi.advanceTimersByTime(200)
123
-
124
- expect(onEnd).not.toHaveBeenCalled()
125
- })
126
-
127
- it('does not call onEnd twice when transitionend fires and then timeout fires', () => {
128
- const onEnd = vi.fn()
129
- const ref = createMockRef()
130
- const active = signal(true)
131
-
132
- useAnimationEnd({ ref, onEnd, active, timeout: 1000 })
133
-
134
- // First: transitionend fires — calls done()
135
- const event = new Event('transitionend', { bubbles: true })
136
- Object.defineProperty(event, 'target', { value: ref.current })
137
- ref.current.dispatchEvent(event)
138
-
139
- expect(onEnd).toHaveBeenCalledTimes(1)
140
-
141
- // Second: timeout fires — should be no-op because called is true
142
- vi.advanceTimersByTime(1000)
143
-
144
- expect(onEnd).toHaveBeenCalledTimes(1)
145
- })
146
-
147
- it('does not call onEnd twice when timeout fires and then transitionend fires', () => {
148
- const onEnd = vi.fn()
149
- const ref = createMockRef()
150
- const active = signal(true)
151
-
152
- useAnimationEnd({ ref, onEnd, active, timeout: 500 })
153
-
154
- // First: timeout fires — calls done()
155
- vi.advanceTimersByTime(500)
156
-
157
- expect(onEnd).toHaveBeenCalledTimes(1)
158
-
159
- // Second: transitionend fires — should be no-op via called guard
160
- const event = new Event('transitionend', { bubbles: true })
161
- Object.defineProperty(event, 'target', { value: ref.current })
162
- ref.current.dispatchEvent(event)
163
-
164
- expect(onEnd).toHaveBeenCalledTimes(1)
165
- })
166
-
167
- it('resets called when active transitions from true to false', () => {
168
- const onEnd = vi.fn()
169
- const ref = createMockRef()
170
- const active = signal(true)
171
-
172
- useAnimationEnd({ ref, onEnd, active, timeout: 1000 })
173
-
174
- // Fire to set called = true
175
- const event = new Event('transitionend', { bubbles: true })
176
- Object.defineProperty(event, 'target', { value: ref.current })
177
- ref.current.dispatchEvent(event)
178
-
179
- expect(onEnd).toHaveBeenCalledTimes(1)
180
-
181
- // Deactivate — resets called
182
- active.set(false)
183
-
184
- // Re-activate
185
- active.set(true)
186
-
187
- // Should be able to fire again
188
- const event2 = new Event('transitionend', { bubbles: true })
189
- Object.defineProperty(event2, 'target', { value: ref.current })
190
- ref.current.dispatchEvent(event2)
191
-
192
- expect(onEnd).toHaveBeenCalledTimes(2)
193
- })
194
- })
@@ -1,160 +0,0 @@
1
- // Track lifecycle callbacks
2
- let mountCallbacks: Array<() => undefined | (() => void)> = []
3
- let unmountCallbacks: Array<() => void> = []
4
-
5
- vi.mock('@pyreon/core', () => ({
6
- onMount: vi.fn((cb: () => undefined | (() => void)) => {
7
- mountCallbacks.push(cb)
8
- }),
9
- onUnmount: vi.fn((cb: () => void) => {
10
- unmountCallbacks.push(cb)
11
- }),
12
- }))
13
-
14
- vi.mock('@pyreon/reactivity', () => {
15
- const signal = <T>(initial: T) => {
16
- let value = initial
17
- const s = (() => value) as (() => T) & {
18
- set: (v: T) => void
19
- update: (fn: (c: T) => T) => void
20
- peek: () => T
21
- subscribe: () => () => void
22
- direct: () => () => void
23
- label: string | undefined
24
- debug: () => { name: string | undefined; value: T; subscriberCount: number }
25
- }
26
- s.set = (v: T) => {
27
- value = v
28
- }
29
- s.update = (fn: (c: T) => T) => {
30
- value = fn(value)
31
- }
32
- s.peek = () => value
33
- s.subscribe = () => () => undefined
34
- s.direct = () => () => undefined
35
- s.label = undefined
36
- s.debug = () => ({ name: undefined, value, subscriberCount: 0 })
37
- return s
38
- }
39
- // No-op stub for the DI hook `@pyreon/core/context.ts` calls at module
40
- // load. See sibling test mocks for the full rationale.
41
- const setSnapshotCapture = () => {}
42
- return { signal, setSnapshotCapture }
43
- })
44
-
45
- import { useReducedMotion } from '../useReducedMotion'
46
-
47
- describe('useReducedMotion', () => {
48
- let changeHandlers: Array<(e: any) => void>
49
- let removedHandlers: Array<(e: any) => void>
50
-
51
- const createMockMQL = (matches: boolean) => ({
52
- matches,
53
- media: '(prefers-reduced-motion: reduce)',
54
- addEventListener: vi.fn((event: string, handler: (e: any) => void) => {
55
- if (event === 'change') changeHandlers.push(handler)
56
- }),
57
- removeEventListener: vi.fn((event: string, handler: (e: any) => void) => {
58
- if (event === 'change') removedHandlers.push(handler)
59
- }),
60
- })
61
-
62
- beforeEach(() => {
63
- mountCallbacks = []
64
- unmountCallbacks = []
65
- changeHandlers = []
66
- removedHandlers = []
67
- })
68
-
69
- afterEach(() => {
70
- vi.restoreAllMocks()
71
- })
72
-
73
- it('returns false initially', () => {
74
- vi.stubGlobal(
75
- 'matchMedia',
76
- vi.fn(() => createMockMQL(false)),
77
- )
78
- const result = useReducedMotion()
79
- expect(result()).toBe(false)
80
- })
81
-
82
- it('reads matchMedia state on mount (true)', () => {
83
- vi.stubGlobal(
84
- 'matchMedia',
85
- vi.fn(() => createMockMQL(true)),
86
- )
87
- const result = useReducedMotion()
88
-
89
- // Fire mount callback
90
- for (const cb of mountCallbacks) cb()
91
-
92
- expect(result()).toBe(true)
93
- })
94
-
95
- it('reads matchMedia state on mount (false)', () => {
96
- vi.stubGlobal(
97
- 'matchMedia',
98
- vi.fn(() => createMockMQL(false)),
99
- )
100
- const result = useReducedMotion()
101
-
102
- for (const cb of mountCallbacks) cb()
103
-
104
- expect(result()).toBe(false)
105
- })
106
-
107
- it('reacts to change events', () => {
108
- vi.stubGlobal(
109
- 'matchMedia',
110
- vi.fn(() => createMockMQL(false)),
111
- )
112
- const result = useReducedMotion()
113
-
114
- for (const cb of mountCallbacks) cb()
115
- expect(result()).toBe(false)
116
-
117
- // Simulate preference change
118
- for (const handler of changeHandlers) {
119
- handler({ matches: true })
120
- }
121
-
122
- expect(result()).toBe(true)
123
- })
124
-
125
- it('queries the correct media string', () => {
126
- const mockMatchMedia = vi.fn(() => createMockMQL(false))
127
- vi.stubGlobal('matchMedia', mockMatchMedia)
128
-
129
- useReducedMotion()
130
- for (const cb of mountCallbacks) cb()
131
-
132
- expect(mockMatchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
133
- })
134
-
135
- it('registers a change listener on mount', () => {
136
- vi.stubGlobal(
137
- 'matchMedia',
138
- vi.fn(() => createMockMQL(false)),
139
- )
140
- useReducedMotion()
141
-
142
- for (const cb of mountCallbacks) cb()
143
-
144
- expect(changeHandlers).toHaveLength(1)
145
- })
146
-
147
- it('removes the change listener on unmount', () => {
148
- vi.stubGlobal(
149
- 'matchMedia',
150
- vi.fn(() => createMockMQL(false)),
151
- )
152
- useReducedMotion()
153
-
154
- for (const cb of mountCallbacks) cb()
155
- expect(changeHandlers).toHaveLength(1)
156
-
157
- for (const cb of unmountCallbacks) cb()
158
- expect(removedHandlers).toHaveLength(1)
159
- })
160
- })