@pyreon/kinetic 0.11.0 → 0.11.2

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.
@@ -0,0 +1,782 @@
1
+ import type { VNode } from "@pyreon/core"
2
+ import { signal } from "@pyreon/reactivity"
3
+ import Collapse from "../Collapse"
4
+ import CollapseRenderer from "../kinetic/CollapseRenderer"
5
+ import type { KineticConfig } from "../kinetic/types"
6
+
7
+ let _reducedMotion = false
8
+
9
+ vi.mock("../useReducedMotion", () => ({
10
+ useReducedMotion: () => () => _reducedMotion,
11
+ }))
12
+
13
+ // Mock scrollHeight
14
+ const mockScrollHeight = (value: number) => {
15
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
16
+ configurable: true,
17
+ get() {
18
+ return value
19
+ },
20
+ })
21
+ }
22
+
23
+ const fireTransitionEnd = (el: HTMLElement) => {
24
+ const event = new Event("transitionend", { bubbles: true })
25
+ Object.defineProperty(event, "target", { value: el })
26
+ el.dispatchEvent(event)
27
+ }
28
+
29
+ /**
30
+ * Helper: call Collapse and wire up mock elements to the refs.
31
+ * Collapse creates a wrapper div (wrapperRef) and inner content div (contentRef).
32
+ * We manually assign mock elements to the refs so the animation logic runs.
33
+ */
34
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
35
+ const setupCollapse = (props: Record<string, unknown>) => {
36
+ const wrapperEl = document.createElement("div")
37
+ const contentEl = document.createElement("div")
38
+
39
+ // Mock offsetHeight for reflow forcing
40
+ Object.defineProperty(wrapperEl, "offsetHeight", {
41
+ configurable: true,
42
+ get() {
43
+ return 0
44
+ },
45
+ })
46
+
47
+ const vnode = Collapse(props as any)
48
+
49
+ // Wire up refs: the wrapper div has ref={wrapperRef}, inner div has ref={contentRef}
50
+ // In the VNode tree: <div ref={wrapperRef}><Show><div ref={contentRef}>...</div></Show></div>
51
+ if (vnode?.props) {
52
+ const vnodeProps = vnode.props as Record<string, unknown>
53
+ // wrapperRef is on the outer div
54
+ if (typeof vnodeProps.ref === "function") {
55
+ ;(vnodeProps.ref as (el: HTMLElement | null) => void)(wrapperEl)
56
+ } else if (vnodeProps.ref && typeof vnodeProps.ref === "object") {
57
+ ;(vnodeProps.ref as { current: HTMLElement | null }).current = wrapperEl
58
+ }
59
+ }
60
+
61
+ // Find contentRef in children (Show > div)
62
+ if (vnode?.children) {
63
+ const children = Array.isArray(vnode.children) ? vnode.children : [vnode.children]
64
+ for (const child of children) {
65
+ if (child && typeof child === "object" && "type" in (child as object)) {
66
+ const showNode = child as any
67
+ // Show's children contain <div ref={contentRef}>
68
+ const showChildren = showNode.props?.children ?? showNode.children
69
+ if (showChildren) {
70
+ const sc = Array.isArray(showChildren) ? showChildren : [showChildren]
71
+ for (const s of sc) {
72
+ if (s && typeof s === "object" && "props" in s) {
73
+ const ref = s.props?.ref
74
+ if (ref && typeof ref === "object") {
75
+ ref.current = contentEl
76
+ } else if (typeof ref === "function") {
77
+ ref(contentEl)
78
+ }
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ return { vnode, wrapperEl, contentEl }
87
+ }
88
+
89
+ describe("Collapse", () => {
90
+ beforeEach(() => {
91
+ vi.useFakeTimers()
92
+ mockScrollHeight(200)
93
+ })
94
+
95
+ afterEach(() => vi.useRealTimers())
96
+
97
+ it("returns a VNode", () => {
98
+ const show = signal(true)
99
+ const child = { type: "div", props: {}, children: ["Hello"], key: undefined }
100
+ const vnode = Collapse({ show, children: child } as any)
101
+ expect(vnode).not.toBeNull()
102
+ })
103
+
104
+ it("fires onEnter callback when entering", () => {
105
+ const show = signal(false)
106
+ const onEnter = vi.fn()
107
+
108
+ setupCollapse({
109
+ show,
110
+ onEnter,
111
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
112
+ })
113
+
114
+ show.set(true)
115
+ expect(onEnter).toHaveBeenCalledTimes(1)
116
+ })
117
+
118
+ it("fires onAfterEnter after transitionend", () => {
119
+ const show = signal(false)
120
+ const onAfterEnter = vi.fn()
121
+
122
+ const { wrapperEl } = setupCollapse({
123
+ show,
124
+ onAfterEnter,
125
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
126
+ })
127
+
128
+ show.set(true)
129
+ expect(onAfterEnter).not.toHaveBeenCalled()
130
+
131
+ fireTransitionEnd(wrapperEl)
132
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
133
+ })
134
+
135
+ it("fires onLeave callback when leaving", () => {
136
+ const show = signal(true)
137
+ const onLeave = vi.fn()
138
+
139
+ setupCollapse({
140
+ show,
141
+ onLeave,
142
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
143
+ })
144
+
145
+ show.set(false)
146
+ expect(onLeave).toHaveBeenCalledTimes(1)
147
+ })
148
+
149
+ it("fires onAfterLeave after transitionend", () => {
150
+ const show = signal(true)
151
+ const onAfterLeave = vi.fn()
152
+
153
+ const { wrapperEl } = setupCollapse({
154
+ show,
155
+ onAfterLeave,
156
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
157
+ })
158
+
159
+ show.set(false)
160
+ expect(onAfterLeave).not.toHaveBeenCalled()
161
+
162
+ fireTransitionEnd(wrapperEl)
163
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
164
+ })
165
+
166
+ it("animates height from 0 to scrollHeight on enter", () => {
167
+ const show = signal(false)
168
+
169
+ const { wrapperEl } = setupCollapse({
170
+ show,
171
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
172
+ })
173
+
174
+ show.set(true)
175
+
176
+ expect(wrapperEl.style.height).toBe("200px")
177
+ expect(wrapperEl.style.transition).toBe("height 300ms ease")
178
+ })
179
+
180
+ it("switches to height:auto after enter animation completes", () => {
181
+ const show = signal(false)
182
+
183
+ const { wrapperEl } = setupCollapse({
184
+ show,
185
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
186
+ })
187
+
188
+ show.set(true)
189
+ fireTransitionEnd(wrapperEl)
190
+
191
+ expect(wrapperEl.style.height).toBe("auto")
192
+ expect(wrapperEl.style.overflow).toBe("")
193
+ expect(wrapperEl.style.transition).toBe("")
194
+ })
195
+
196
+ it("animates height to 0 on leave", () => {
197
+ const show = signal(true)
198
+
199
+ const { wrapperEl } = setupCollapse({
200
+ show,
201
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
202
+ })
203
+
204
+ show.set(false)
205
+
206
+ expect(wrapperEl.style.height).toBe("0px")
207
+ expect(wrapperEl.style.overflow).toBe("hidden")
208
+ })
209
+
210
+ it("uses custom transition property", () => {
211
+ const show = signal(false)
212
+
213
+ const { wrapperEl } = setupCollapse({
214
+ show,
215
+ transition: "height 500ms ease-in-out",
216
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
217
+ })
218
+
219
+ show.set(true)
220
+
221
+ expect(wrapperEl.style.transition).toBe("height 500ms ease-in-out")
222
+ })
223
+
224
+ it("appear=true animates on initial mount", async () => {
225
+ const show = signal(true)
226
+ const onEnter = vi.fn()
227
+
228
+ const { wrapperEl } = setupCollapse({
229
+ show,
230
+ appear: true,
231
+ onEnter,
232
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
233
+ })
234
+
235
+ // appear defers via queueMicrotask so all refs are wired first
236
+ await Promise.resolve()
237
+
238
+ expect(onEnter).toHaveBeenCalledTimes(1)
239
+ expect(wrapperEl.style.height).toBe("200px")
240
+ })
241
+
242
+ it("custom timeout completes leave when transitionend does not fire", () => {
243
+ const show = signal(true)
244
+ const onAfterLeave = vi.fn()
245
+
246
+ setupCollapse({
247
+ show,
248
+ timeout: 800,
249
+ onAfterLeave,
250
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
251
+ })
252
+
253
+ show.set(false)
254
+ expect(onAfterLeave).not.toHaveBeenCalled()
255
+
256
+ vi.advanceTimersByTime(800)
257
+
258
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
259
+ })
260
+
261
+ it("interrupts leave and starts entering when toggled back to show", () => {
262
+ const show = signal(true)
263
+ const onEnter = vi.fn()
264
+ const onLeave = vi.fn()
265
+
266
+ const { wrapperEl } = setupCollapse({
267
+ show,
268
+ onEnter,
269
+ onLeave,
270
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
271
+ })
272
+
273
+ // Start leaving
274
+ show.set(false)
275
+ expect(onLeave).toHaveBeenCalledTimes(1)
276
+
277
+ // Toggle back
278
+ show.set(true)
279
+ expect(onEnter).toHaveBeenCalledTimes(1)
280
+ expect(wrapperEl.style.height).toBe("200px")
281
+ })
282
+
283
+ it("interrupts entering and starts leaving when toggled back to hide", () => {
284
+ const show = signal(false)
285
+ const onEnter = vi.fn()
286
+ const onLeave = vi.fn()
287
+
288
+ setupCollapse({
289
+ show,
290
+ onEnter,
291
+ onLeave,
292
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
293
+ })
294
+
295
+ // Start entering
296
+ show.set(true)
297
+ expect(onEnter).toHaveBeenCalledTimes(1)
298
+
299
+ // Toggle back before transitionend
300
+ show.set(false)
301
+ expect(onLeave).toHaveBeenCalledTimes(1)
302
+ })
303
+
304
+ it("does not re-trigger entering if already entered", () => {
305
+ const show = signal(false)
306
+ const onEnter = vi.fn()
307
+
308
+ const { wrapperEl } = setupCollapse({
309
+ show,
310
+ onEnter,
311
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
312
+ })
313
+
314
+ show.set(true)
315
+ expect(onEnter).toHaveBeenCalledTimes(1)
316
+
317
+ // Complete the transition
318
+ fireTransitionEnd(wrapperEl)
319
+
320
+ // Toggle show off and on again
321
+ show.set(false)
322
+ show.set(true)
323
+ expect(onEnter).toHaveBeenCalledTimes(2)
324
+ })
325
+
326
+ it("does not re-trigger leaving if already hidden", () => {
327
+ const show = signal(true)
328
+ const onLeave = vi.fn()
329
+
330
+ const { wrapperEl } = setupCollapse({
331
+ show,
332
+ onLeave,
333
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
334
+ })
335
+
336
+ show.set(false)
337
+ expect(onLeave).toHaveBeenCalledTimes(1)
338
+
339
+ fireTransitionEnd(wrapperEl)
340
+
341
+ // Already hidden, setting false again should not trigger leave
342
+ show.set(false)
343
+ expect(onLeave).toHaveBeenCalledTimes(1)
344
+ })
345
+
346
+ it("appear=true fires onAfterEnter after transitionend", async () => {
347
+ const show = signal(true)
348
+ const onAfterEnter = vi.fn()
349
+
350
+ const { wrapperEl } = setupCollapse({
351
+ show,
352
+ appear: true,
353
+ onAfterEnter,
354
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
355
+ })
356
+
357
+ await Promise.resolve()
358
+
359
+ expect(onAfterEnter).not.toHaveBeenCalled()
360
+ fireTransitionEnd(wrapperEl)
361
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
362
+ })
363
+
364
+ it("leave transition sets height to scrollHeight first then to 0", () => {
365
+ const show = signal(true)
366
+
367
+ const { wrapperEl } = setupCollapse({
368
+ show,
369
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
370
+ })
371
+
372
+ show.set(false)
373
+
374
+ // After leaving, height should be 0
375
+ expect(wrapperEl.style.height).toBe("0px")
376
+ expect(wrapperEl.style.overflow).toBe("hidden")
377
+ expect(wrapperEl.style.transition).toBe("height 300ms ease")
378
+ })
379
+ })
380
+
381
+ // ─── CollapseRenderer (kinetic mode) ──────────────────────
382
+
383
+ const makeCollapseConfig = (overrides: Partial<KineticConfig> = {}): KineticConfig => ({
384
+ tag: "div",
385
+ mode: "collapse",
386
+ ...overrides,
387
+ })
388
+
389
+ /** Wire a ref (function or object) on a VNode's props to a given element. */
390
+ const wireWrapperRef = (vnode: VNode | null, el: HTMLElement) => {
391
+ if (!vnode?.props) return
392
+ const vnodeProps = vnode.props as Record<string, unknown>
393
+ if (typeof vnodeProps.ref === "function") {
394
+ ;(vnodeProps.ref as (el: HTMLElement | null) => void)(el)
395
+ } else if (vnodeProps.ref && typeof vnodeProps.ref === "object") {
396
+ ;(vnodeProps.ref as { current: HTMLElement | null }).current = el
397
+ }
398
+ }
399
+
400
+ /** Find and wire contentRef inside Show > div children. */
401
+ const wireContentRef = (vnode: VNode | null, contentEl: HTMLElement) => {
402
+ if (!vnode?.children) return
403
+ const vnodeChildren = Array.isArray(vnode.children) ? vnode.children : [vnode.children]
404
+ for (const c of vnodeChildren) {
405
+ if (!c || typeof c !== "object" || !("type" in (c as object))) continue
406
+ const showNode = c as any
407
+ const showChildren = showNode.props?.children ?? showNode.children
408
+ if (!showChildren) continue
409
+ const sc = Array.isArray(showChildren) ? showChildren : [showChildren]
410
+ for (const s of sc) {
411
+ if (!s || typeof s !== "object" || !("props" in s)) continue
412
+ const ref = s.props?.ref
413
+ if (ref && typeof ref === "object") {
414
+ ref.current = contentEl
415
+ } else if (typeof ref === "function") {
416
+ ref(contentEl)
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Helper: call CollapseRenderer and wire up mock elements to the refs.
424
+ * CollapseRenderer uses h(config.tag, { ref: wrapperRef }) and an inner
425
+ * <div ref={contentRef}> — same structure as Collapse but via config.tag.
426
+ */
427
+ const setupCollapseRenderer = (props: {
428
+ config?: KineticConfig
429
+ show: () => boolean
430
+ appear?: boolean | undefined
431
+ timeout?: number | undefined
432
+ transition?: string | undefined
433
+ callbacks?: Record<string, unknown>
434
+ children?: VNode | VNode[]
435
+ }) => {
436
+ const wrapperEl = document.createElement("div")
437
+ const contentEl = document.createElement("div")
438
+
439
+ Object.defineProperty(wrapperEl, "offsetHeight", {
440
+ configurable: true,
441
+ get() {
442
+ return 0
443
+ },
444
+ })
445
+
446
+ const config = props.config ?? makeCollapseConfig()
447
+ const child: VNode = { type: "p", props: {}, children: ["Content"], key: null }
448
+
449
+ const vnode = CollapseRenderer({
450
+ config,
451
+ htmlProps: {},
452
+ show: props.show,
453
+ appear: props.appear,
454
+ timeout: props.timeout,
455
+ transition: props.transition,
456
+ callbacks: (props.callbacks ?? {}) as Record<string, () => void>,
457
+ children: props.children ?? child,
458
+ })
459
+
460
+ wireWrapperRef(vnode, wrapperEl)
461
+ wireContentRef(vnode, contentEl)
462
+
463
+ return { vnode, wrapperEl, contentEl }
464
+ }
465
+
466
+ describe("CollapseRenderer", () => {
467
+ beforeEach(() => {
468
+ vi.useFakeTimers()
469
+ mockScrollHeight(200)
470
+ })
471
+
472
+ afterEach(() => vi.useRealTimers())
473
+
474
+ it("returns a VNode with the config.tag", () => {
475
+ const show = signal(true)
476
+ const config = makeCollapseConfig({ tag: "section" })
477
+ const child: VNode = { type: "p", props: {}, children: ["Content"], key: null }
478
+
479
+ const vnode = CollapseRenderer({
480
+ config,
481
+ htmlProps: {},
482
+ show: () => show(),
483
+ callbacks: {},
484
+ children: child,
485
+ })
486
+
487
+ expect(vnode).not.toBeNull()
488
+ expect(vnode?.type).toBe("section")
489
+ })
490
+
491
+ it("fires onEnter and animates height on entering", () => {
492
+ const show = signal(false)
493
+ const onEnter = vi.fn()
494
+
495
+ const { wrapperEl } = setupCollapseRenderer({
496
+ show: () => show(),
497
+ callbacks: { onEnter },
498
+ })
499
+
500
+ show.set(true)
501
+ expect(onEnter).toHaveBeenCalledTimes(1)
502
+ expect(wrapperEl.style.height).toBe("200px")
503
+ expect(wrapperEl.style.transition).toBe("height 300ms ease")
504
+ })
505
+
506
+ it("fires onLeave and animates height to 0 on leaving", () => {
507
+ const show = signal(true)
508
+ const onLeave = vi.fn()
509
+
510
+ const { wrapperEl } = setupCollapseRenderer({
511
+ show: () => show(),
512
+ callbacks: { onLeave },
513
+ })
514
+
515
+ show.set(false)
516
+ expect(onLeave).toHaveBeenCalledTimes(1)
517
+ expect(wrapperEl.style.height).toBe("0px")
518
+ expect(wrapperEl.style.overflow).toBe("hidden")
519
+ })
520
+
521
+ it("fires onAfterEnter and sets height:auto after transitionend", () => {
522
+ const show = signal(false)
523
+ const onAfterEnter = vi.fn()
524
+
525
+ const { wrapperEl } = setupCollapseRenderer({
526
+ show: () => show(),
527
+ callbacks: { onAfterEnter },
528
+ })
529
+
530
+ show.set(true)
531
+ fireTransitionEnd(wrapperEl)
532
+
533
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
534
+ expect(wrapperEl.style.height).toBe("auto")
535
+ expect(wrapperEl.style.overflow).toBe("")
536
+ expect(wrapperEl.style.transition).toBe("")
537
+ })
538
+
539
+ it("fires onAfterLeave after leave transitionend", () => {
540
+ const show = signal(true)
541
+ const onAfterLeave = vi.fn()
542
+
543
+ const { wrapperEl } = setupCollapseRenderer({
544
+ show: () => show(),
545
+ callbacks: { onAfterLeave },
546
+ })
547
+
548
+ show.set(false)
549
+ fireTransitionEnd(wrapperEl)
550
+
551
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
552
+ })
553
+
554
+ it("uses custom transition from prop", () => {
555
+ const show = signal(false)
556
+
557
+ const { wrapperEl } = setupCollapseRenderer({
558
+ show: () => show(),
559
+ transition: "height 500ms ease-in-out",
560
+ callbacks: {},
561
+ })
562
+
563
+ show.set(true)
564
+ expect(wrapperEl.style.transition).toBe("height 500ms ease-in-out")
565
+ })
566
+
567
+ it("uses config.transition as fallback", () => {
568
+ const show = signal(false)
569
+ const config = makeCollapseConfig({ transition: "height 700ms linear" })
570
+
571
+ const { wrapperEl } = setupCollapseRenderer({
572
+ config,
573
+ show: () => show(),
574
+ callbacks: {},
575
+ })
576
+
577
+ show.set(true)
578
+ expect(wrapperEl.style.transition).toBe("height 700ms linear")
579
+ })
580
+
581
+ it("appear=true triggers entering via ref proxy on initial mount", async () => {
582
+ const show = signal(true)
583
+ const onEnter = vi.fn()
584
+
585
+ const { wrapperEl } = setupCollapseRenderer({
586
+ show: () => show(),
587
+ appear: true,
588
+ callbacks: { onEnter },
589
+ })
590
+
591
+ // appear defers via queueMicrotask
592
+ await Promise.resolve()
593
+
594
+ expect(onEnter).toHaveBeenCalledTimes(1)
595
+ expect(wrapperEl.style.height).toBe("200px")
596
+ })
597
+
598
+ it("timeout fallback completes enter when transitionend never fires", () => {
599
+ const show = signal(false)
600
+ const onAfterEnter = vi.fn()
601
+
602
+ setupCollapseRenderer({
603
+ show: () => show(),
604
+ timeout: 600,
605
+ callbacks: { onAfterEnter },
606
+ })
607
+
608
+ show.set(true)
609
+ expect(onAfterEnter).not.toHaveBeenCalled()
610
+
611
+ vi.advanceTimersByTime(600)
612
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
613
+ })
614
+
615
+ it("timeout fallback completes leave when transitionend never fires", () => {
616
+ const show = signal(true)
617
+ const onAfterLeave = vi.fn()
618
+
619
+ setupCollapseRenderer({
620
+ show: () => show(),
621
+ timeout: 600,
622
+ callbacks: { onAfterLeave },
623
+ })
624
+
625
+ show.set(false)
626
+ expect(onAfterLeave).not.toHaveBeenCalled()
627
+
628
+ vi.advanceTimersByTime(600)
629
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
630
+ })
631
+
632
+ it("interrupts leave and re-enters when show toggles back", () => {
633
+ const show = signal(true)
634
+ const onEnter = vi.fn()
635
+ const onLeave = vi.fn()
636
+
637
+ const { wrapperEl } = setupCollapseRenderer({
638
+ show: () => show(),
639
+ callbacks: { onEnter, onLeave },
640
+ })
641
+
642
+ show.set(false)
643
+ expect(onLeave).toHaveBeenCalledTimes(1)
644
+
645
+ show.set(true)
646
+ expect(onEnter).toHaveBeenCalledTimes(1)
647
+ expect(wrapperEl.style.height).toBe("200px")
648
+ })
649
+
650
+ it("uses config.timeout as fallback", () => {
651
+ const show = signal(false)
652
+ const onAfterEnter = vi.fn()
653
+ const config = makeCollapseConfig({ timeout: 400 })
654
+
655
+ setupCollapseRenderer({
656
+ config,
657
+ show: () => show(),
658
+ callbacks: { onAfterEnter },
659
+ })
660
+
661
+ show.set(true)
662
+ vi.advanceTimersByTime(400)
663
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
664
+ })
665
+
666
+ it("uses config.appear as fallback", async () => {
667
+ const show = signal(true)
668
+ const onEnter = vi.fn()
669
+ const config = makeCollapseConfig({ appear: true })
670
+
671
+ setupCollapseRenderer({
672
+ config,
673
+ show: () => show(),
674
+ callbacks: { onEnter },
675
+ })
676
+
677
+ await Promise.resolve()
678
+ expect(onEnter).toHaveBeenCalledTimes(1)
679
+ })
680
+ })
681
+
682
+ describe("CollapseRenderer — reduced motion", () => {
683
+ beforeEach(() => {
684
+ vi.useFakeTimers()
685
+ mockScrollHeight(200)
686
+ _reducedMotion = true
687
+ })
688
+
689
+ afterEach(() => {
690
+ vi.useRealTimers()
691
+ _reducedMotion = false
692
+ })
693
+
694
+ it("reduced motion: entering skips animation and sets height:auto immediately", () => {
695
+ const show = signal(false)
696
+ const onEnter = vi.fn()
697
+ const onAfterEnter = vi.fn()
698
+
699
+ const { wrapperEl } = setupCollapseRenderer({
700
+ show: () => show(),
701
+ callbacks: { onEnter, onAfterEnter },
702
+ })
703
+
704
+ show.set(true)
705
+
706
+ expect(onEnter).toHaveBeenCalledTimes(1)
707
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
708
+ expect(wrapperEl.style.height).toBe("auto")
709
+ expect(wrapperEl.style.overflow).toBe("")
710
+ })
711
+
712
+ it("reduced motion: leaving skips animation and sets height:0 immediately", () => {
713
+ const show = signal(true)
714
+ const onLeave = vi.fn()
715
+ const onAfterLeave = vi.fn()
716
+
717
+ const { wrapperEl } = setupCollapseRenderer({
718
+ show: () => show(),
719
+ callbacks: { onLeave, onAfterLeave },
720
+ })
721
+
722
+ show.set(false)
723
+
724
+ expect(onLeave).toHaveBeenCalledTimes(1)
725
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
726
+ expect(wrapperEl.style.height).toBe("0px")
727
+ expect(wrapperEl.style.overflow).toBe("hidden")
728
+ })
729
+ })
730
+
731
+ describe("Collapse — reduced motion", () => {
732
+ beforeEach(() => {
733
+ vi.useFakeTimers()
734
+ mockScrollHeight(200)
735
+ _reducedMotion = true
736
+ })
737
+
738
+ afterEach(() => {
739
+ vi.useRealTimers()
740
+ _reducedMotion = false
741
+ })
742
+
743
+ it("reduced motion: entering skips animation and fires both callbacks", () => {
744
+ const show = signal(false)
745
+ const onEnter = vi.fn()
746
+ const onAfterEnter = vi.fn()
747
+
748
+ const { wrapperEl } = setupCollapse({
749
+ show,
750
+ onEnter,
751
+ onAfterEnter,
752
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
753
+ })
754
+
755
+ show.set(true)
756
+
757
+ expect(onEnter).toHaveBeenCalledTimes(1)
758
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
759
+ expect(wrapperEl.style.height).toBe("auto")
760
+ expect(wrapperEl.style.overflow).toBe("")
761
+ })
762
+
763
+ it("reduced motion: leaving skips animation and fires both callbacks", () => {
764
+ const show = signal(true)
765
+ const onLeave = vi.fn()
766
+ const onAfterLeave = vi.fn()
767
+
768
+ const { wrapperEl } = setupCollapse({
769
+ show,
770
+ onLeave,
771
+ onAfterLeave,
772
+ children: { type: "div", props: {}, children: ["Hello"], key: undefined },
773
+ })
774
+
775
+ show.set(false)
776
+
777
+ expect(onLeave).toHaveBeenCalledTimes(1)
778
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
779
+ expect(wrapperEl.style.height).toBe("0px")
780
+ expect(wrapperEl.style.overflow).toBe("hidden")
781
+ })
782
+ })