@pyreon/elements 0.11.1 → 0.11.3

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 (52) hide show
  1. package/package.json +8 -7
  2. package/src/Element/component.tsx +211 -0
  3. package/src/Element/constants.ts +96 -0
  4. package/src/Element/index.ts +6 -0
  5. package/src/Element/types.ts +168 -0
  6. package/src/Element/utils.ts +15 -0
  7. package/src/List/component.tsx +57 -0
  8. package/src/List/index.ts +5 -0
  9. package/src/Overlay/component.tsx +131 -0
  10. package/src/Overlay/context.tsx +37 -0
  11. package/src/Overlay/index.ts +7 -0
  12. package/src/Overlay/useOverlay.tsx +616 -0
  13. package/src/Portal/component.tsx +41 -0
  14. package/src/Portal/index.ts +5 -0
  15. package/src/Text/component.tsx +65 -0
  16. package/src/Text/index.ts +5 -0
  17. package/src/Text/styled.ts +30 -0
  18. package/src/Util/component.tsx +43 -0
  19. package/src/Util/index.ts +5 -0
  20. package/src/__tests__/Content.test.tsx +115 -0
  21. package/src/__tests__/Element.test.ts +604 -0
  22. package/src/__tests__/Iterator.test.ts +483 -0
  23. package/src/__tests__/List.test.ts +199 -0
  24. package/src/__tests__/Overlay.test.ts +485 -0
  25. package/src/__tests__/Portal.test.ts +82 -0
  26. package/src/__tests__/Text.test.ts +274 -0
  27. package/src/__tests__/Util.test.ts +63 -0
  28. package/src/__tests__/Wrapper.test.tsx +152 -0
  29. package/src/__tests__/equalBeforeAfter.test.ts +122 -0
  30. package/src/__tests__/helpers.test.ts +65 -0
  31. package/src/__tests__/overlayContext.test.tsx +78 -0
  32. package/src/__tests__/responsiveProps.test.ts +298 -0
  33. package/src/__tests__/useOverlay.test.ts +1330 -0
  34. package/src/__tests__/utils.test.ts +69 -0
  35. package/src/constants.ts +1 -0
  36. package/src/helpers/Content/component.tsx +51 -0
  37. package/src/helpers/Content/index.ts +3 -0
  38. package/src/helpers/Content/styled.ts +105 -0
  39. package/src/helpers/Content/types.ts +49 -0
  40. package/src/helpers/Iterator/component.tsx +252 -0
  41. package/src/helpers/Iterator/index.ts +13 -0
  42. package/src/helpers/Iterator/types.ts +79 -0
  43. package/src/helpers/Wrapper/component.tsx +78 -0
  44. package/src/helpers/Wrapper/constants.ts +10 -0
  45. package/src/helpers/Wrapper/index.ts +3 -0
  46. package/src/helpers/Wrapper/styled.ts +69 -0
  47. package/src/helpers/Wrapper/types.ts +56 -0
  48. package/src/helpers/Wrapper/utils.ts +7 -0
  49. package/src/helpers/index.ts +4 -0
  50. package/src/index.ts +37 -0
  51. package/src/types.ts +81 -0
  52. package/src/utils.ts +1 -0
@@ -0,0 +1,1330 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+
7
+ vi.mock("@pyreon/reactivity", () => {
8
+ const signal = <T>(initial: T) => {
9
+ let value = initial
10
+ const s = (() => value) as (() => T) & {
11
+ set: (v: T) => void
12
+ update: (fn: (c: T) => T) => void
13
+ peek: () => T
14
+ subscribe: (listener: () => void) => () => void
15
+ direct: (updater: () => void) => () => void
16
+ label: string | undefined
17
+ debug: () => { name: string | undefined; value: T; subscriberCount: number }
18
+ }
19
+ s.set = (v: T) => {
20
+ value = v
21
+ }
22
+ s.update = (fn: (c: T) => T) => {
23
+ value = fn(value)
24
+ }
25
+ s.peek = () => value
26
+ s.subscribe = () => () => {
27
+ /* noop */
28
+ }
29
+ s.direct = () => () => {
30
+ /* noop */
31
+ }
32
+ s.label = undefined
33
+ s.debug = () => ({ name: undefined, value, subscriberCount: 0 })
34
+ return s
35
+ }
36
+
37
+ return { signal }
38
+ })
39
+
40
+ vi.mock("@pyreon/core", async (importOriginal) => {
41
+ const actual = (await importOriginal()) as Record<string, unknown>
42
+ return {
43
+ ...actual,
44
+ onMount: vi.fn(),
45
+ onUnmount: vi.fn(),
46
+ Portal: actual.Fragment,
47
+ }
48
+ })
49
+
50
+ vi.mock("@pyreon/ui-core", async () => {
51
+ const throttle = <F extends (...args: any[]) => any>(fn: F, _delay: number) => {
52
+ const wrapped = (...args: any[]) => fn(...args)
53
+ wrapped.cancel = () => {
54
+ /* no-op */
55
+ }
56
+ return wrapped as F & { cancel: () => void }
57
+ }
58
+
59
+ return { render: vi.fn(), throttle }
60
+ })
61
+
62
+ vi.mock("@pyreon/unistyle", () => ({
63
+ value: (v: unknown, _base?: number) => (typeof v === "number" ? `${v}px` : v),
64
+ }))
65
+
66
+ const mockSetBlocked = vi.fn()
67
+ const mockSetUnblocked = vi.fn()
68
+
69
+ vi.mock("../Overlay/context", async (importOriginal) => {
70
+ const actual = (await importOriginal()) as Record<string, unknown>
71
+ return {
72
+ ...actual,
73
+ useOverlayContext: () => ({
74
+ setBlocked: mockSetBlocked,
75
+ setUnblocked: mockSetUnblocked,
76
+ }),
77
+ }
78
+ })
79
+
80
+ vi.mock("~/utils", () => ({
81
+ IS_DEVELOPMENT: false,
82
+ }))
83
+
84
+ import { useOverlay } from "../Overlay"
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const mockElement = (rect: Partial<DOMRect> = {}): HTMLElement => {
91
+ const el = document.createElement("div")
92
+ el.getBoundingClientRect = () => ({
93
+ top: 0,
94
+ bottom: 0,
95
+ left: 0,
96
+ right: 0,
97
+ width: 0,
98
+ height: 0,
99
+ x: 0,
100
+ y: 0,
101
+ toJSON: () => {},
102
+ ...rect,
103
+ })
104
+ return el
105
+ }
106
+
107
+ // Set viewport dimensions for position tests
108
+ const setViewport = (width: number, height: number) => {
109
+ Object.defineProperty(window, "innerWidth", { value: width, configurable: true })
110
+ Object.defineProperty(window, "innerHeight", { value: height, configurable: true })
111
+ }
112
+
113
+ beforeEach(() => {
114
+ vi.clearAllMocks()
115
+ vi.useFakeTimers()
116
+ setViewport(1024, 768)
117
+ })
118
+
119
+ afterEach(() => {
120
+ vi.useRealTimers()
121
+ })
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Tests
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe("useOverlay", () => {
128
+ // =========================================================================
129
+ // 1. Default state
130
+ // =========================================================================
131
+ describe("default state", () => {
132
+ it("active is false by default", () => {
133
+ const o = useOverlay()
134
+ expect(o.active()).toBe(false)
135
+ })
136
+
137
+ it("align defaults to bottom", () => {
138
+ const o = useOverlay()
139
+ expect(o.align).toBe("bottom")
140
+ })
141
+
142
+ it("alignX defaults to left", () => {
143
+ const o = useOverlay()
144
+ expect(o.alignX()).toBe("left")
145
+ })
146
+
147
+ it("alignY defaults to bottom", () => {
148
+ const o = useOverlay()
149
+ expect(o.alignY()).toBe("bottom")
150
+ })
151
+
152
+ it("blocked is false by default", () => {
153
+ const o = useOverlay()
154
+ expect(o.blocked()).toBe(false)
155
+ })
156
+ })
157
+
158
+ // =========================================================================
159
+ // 2. isOpen=true
160
+ // =========================================================================
161
+ describe("isOpen=true", () => {
162
+ it("active starts true when isOpen is true", () => {
163
+ const o = useOverlay({ isOpen: true })
164
+ expect(o.active()).toBe(true)
165
+ })
166
+ })
167
+
168
+ // =========================================================================
169
+ // 3. Disabled state
170
+ // =========================================================================
171
+ describe("disabled", () => {
172
+ it("forces active to false when disabled is true", () => {
173
+ const o = useOverlay({ isOpen: true, disabled: true })
174
+ expect(o.active()).toBe(false)
175
+ })
176
+
177
+ it("prevents event handling when disabled", () => {
178
+ const onOpen = vi.fn()
179
+ const o = useOverlay({ openOn: "click", disabled: true, onOpen })
180
+ const triggerEl = mockElement()
181
+ o.triggerRef(triggerEl)
182
+ const cleanup = o.setupListeners()
183
+
184
+ const click = new MouseEvent("click", { bubbles: true })
185
+ Object.defineProperty(click, "target", { value: triggerEl })
186
+ window.dispatchEvent(click)
187
+
188
+ expect(o.active()).toBe(false)
189
+ expect(onOpen).not.toHaveBeenCalled()
190
+ cleanup()
191
+ })
192
+ })
193
+
194
+ // =========================================================================
195
+ // 4. triggerRef / contentRef
196
+ // =========================================================================
197
+ describe("triggerRef and contentRef", () => {
198
+ it("triggerRef is a callable function", () => {
199
+ const o = useOverlay()
200
+ expect(typeof o.triggerRef).toBe("function")
201
+ })
202
+
203
+ it("contentRef is a callable function", () => {
204
+ const o = useOverlay()
205
+ expect(typeof o.contentRef).toBe("function")
206
+ })
207
+
208
+ it("triggerRef accepts an HTMLElement", () => {
209
+ const o = useOverlay()
210
+ const el = mockElement()
211
+ expect(() => o.triggerRef(el)).not.toThrow()
212
+ })
213
+
214
+ it("contentRef accepts an HTMLElement", () => {
215
+ const o = useOverlay()
216
+ const el = mockElement()
217
+ expect(() => o.contentRef(el)).not.toThrow()
218
+ })
219
+
220
+ it("triggerRef accepts null", () => {
221
+ const o = useOverlay()
222
+ expect(() => o.triggerRef(null)).not.toThrow()
223
+ })
224
+
225
+ it("contentRef accepts null", () => {
226
+ const o = useOverlay()
227
+ expect(() => o.contentRef(null)).not.toThrow()
228
+ })
229
+ })
230
+
231
+ // =========================================================================
232
+ // 5. showContent / hideContent
233
+ // =========================================================================
234
+ describe("showContent / hideContent", () => {
235
+ it("showContent sets active to true", () => {
236
+ const o = useOverlay()
237
+ o.showContent()
238
+ expect(o.active()).toBe(true)
239
+ })
240
+
241
+ it("hideContent sets active to false", () => {
242
+ const o = useOverlay({ isOpen: true })
243
+ o.hideContent()
244
+ expect(o.active()).toBe(false)
245
+ })
246
+
247
+ it("showContent calls onOpen callback", () => {
248
+ const onOpen = vi.fn()
249
+ const o = useOverlay({ onOpen })
250
+ o.showContent()
251
+ expect(onOpen).toHaveBeenCalledOnce()
252
+ })
253
+
254
+ it("hideContent calls onClose callback", () => {
255
+ const onClose = vi.fn()
256
+ const o = useOverlay({ isOpen: true, onClose })
257
+ o.hideContent()
258
+ expect(onClose).toHaveBeenCalledOnce()
259
+ })
260
+
261
+ it("showContent calls ctx.setBlocked", () => {
262
+ const o = useOverlay()
263
+ o.showContent()
264
+ expect(mockSetBlocked).toHaveBeenCalledOnce()
265
+ })
266
+
267
+ it("hideContent calls ctx.setUnblocked", () => {
268
+ const o = useOverlay({ isOpen: true })
269
+ o.hideContent()
270
+ expect(mockSetUnblocked).toHaveBeenCalledOnce()
271
+ })
272
+
273
+ it("toggle between show and hide works", () => {
274
+ const o = useOverlay()
275
+ o.showContent()
276
+ expect(o.active()).toBe(true)
277
+ o.hideContent()
278
+ expect(o.active()).toBe(false)
279
+ o.showContent()
280
+ expect(o.active()).toBe(true)
281
+ })
282
+ })
283
+
284
+ // =========================================================================
285
+ // 6. Blocked state
286
+ // =========================================================================
287
+ describe("blocked state", () => {
288
+ it("setBlocked increments blocked count", () => {
289
+ const o = useOverlay()
290
+ o.setBlocked()
291
+ expect(o.blocked()).toBe(true)
292
+ })
293
+
294
+ it("setUnblocked decrements blocked count", () => {
295
+ const o = useOverlay()
296
+ o.setBlocked()
297
+ o.setUnblocked()
298
+ expect(o.blocked()).toBe(false)
299
+ })
300
+
301
+ it("multiple setBlocked calls require matching setUnblocked calls", () => {
302
+ const o = useOverlay()
303
+ o.setBlocked()
304
+ o.setBlocked()
305
+ o.setUnblocked()
306
+ expect(o.blocked()).toBe(true)
307
+ o.setUnblocked()
308
+ expect(o.blocked()).toBe(false)
309
+ })
310
+
311
+ it("setUnblocked does not go below zero", () => {
312
+ const o = useOverlay()
313
+ o.setUnblocked()
314
+ o.setUnblocked()
315
+ expect(o.blocked()).toBe(false)
316
+ })
317
+
318
+ it("blocked overlay ignores click events", () => {
319
+ const onOpen = vi.fn()
320
+ const o = useOverlay({ openOn: "click", onOpen })
321
+ const triggerEl = mockElement()
322
+ o.triggerRef(triggerEl)
323
+ const cleanup = o.setupListeners()
324
+
325
+ // Block the overlay
326
+ o.setBlocked()
327
+
328
+ const click = new MouseEvent("click", { bubbles: true })
329
+ Object.defineProperty(click, "target", { value: triggerEl })
330
+ window.dispatchEvent(click)
331
+
332
+ expect(o.active()).toBe(false)
333
+ expect(onOpen).not.toHaveBeenCalled()
334
+ cleanup()
335
+ })
336
+ })
337
+
338
+ // =========================================================================
339
+ // 7. setupListeners
340
+ // =========================================================================
341
+ describe("setupListeners", () => {
342
+ it("returns a cleanup function", () => {
343
+ const o = useOverlay()
344
+ const cleanup = o.setupListeners()
345
+ expect(typeof cleanup).toBe("function")
346
+ cleanup()
347
+ })
348
+
349
+ it("cleanup removes event listeners without error", () => {
350
+ const o = useOverlay()
351
+ const cleanup = o.setupListeners()
352
+ expect(() => cleanup()).not.toThrow()
353
+ })
354
+
355
+ it("cleanup can be called multiple times safely", () => {
356
+ const o = useOverlay()
357
+ const cleanup = o.setupListeners()
358
+ cleanup()
359
+ expect(() => cleanup()).not.toThrow()
360
+ })
361
+ })
362
+
363
+ // =========================================================================
364
+ // 8. Click handling
365
+ // =========================================================================
366
+ describe("click handling", () => {
367
+ it("openOn=click: clicking trigger when inactive opens overlay", () => {
368
+ const o = useOverlay({ openOn: "click" })
369
+ const triggerEl = mockElement()
370
+ o.triggerRef(triggerEl)
371
+ const cleanup = o.setupListeners()
372
+
373
+ const click = new MouseEvent("click", { bubbles: true })
374
+ Object.defineProperty(click, "target", { value: triggerEl })
375
+ window.dispatchEvent(click)
376
+
377
+ expect(o.active()).toBe(true)
378
+ cleanup()
379
+ })
380
+
381
+ it("openOn=click: clicking non-trigger when inactive does not open", () => {
382
+ const o = useOverlay({ openOn: "click" })
383
+ const triggerEl = mockElement()
384
+ o.triggerRef(triggerEl)
385
+ const cleanup = o.setupListeners()
386
+
387
+ const outsideEl = document.createElement("div")
388
+ const click = new MouseEvent("click", { bubbles: true })
389
+ Object.defineProperty(click, "target", { value: outsideEl })
390
+ window.dispatchEvent(click)
391
+
392
+ expect(o.active()).toBe(false)
393
+ cleanup()
394
+ })
395
+
396
+ it("closeOn=click: any click when active closes overlay", () => {
397
+ const o = useOverlay({ openOn: "click", closeOn: "click", isOpen: true })
398
+ const triggerEl = mockElement()
399
+ o.triggerRef(triggerEl)
400
+ const cleanup = o.setupListeners()
401
+
402
+ const outsideEl = document.createElement("div")
403
+ const click = new MouseEvent("click", { bubbles: true })
404
+ Object.defineProperty(click, "target", { value: outsideEl })
405
+ window.dispatchEvent(click)
406
+
407
+ expect(o.active()).toBe(false)
408
+ cleanup()
409
+ })
410
+
411
+ it("closeOn=clickOnTrigger: clicking trigger when active closes overlay", () => {
412
+ const o = useOverlay({ openOn: "click", closeOn: "clickOnTrigger", isOpen: true })
413
+ const triggerEl = mockElement()
414
+ o.triggerRef(triggerEl)
415
+ const cleanup = o.setupListeners()
416
+
417
+ const click = new MouseEvent("click", { bubbles: true })
418
+ Object.defineProperty(click, "target", { value: triggerEl })
419
+ window.dispatchEvent(click)
420
+
421
+ expect(o.active()).toBe(false)
422
+ cleanup()
423
+ })
424
+
425
+ it("closeOn=clickOnTrigger: clicking outside does not close overlay", () => {
426
+ const o = useOverlay({ openOn: "click", closeOn: "clickOnTrigger", isOpen: true })
427
+ const triggerEl = mockElement()
428
+ o.triggerRef(triggerEl)
429
+ const cleanup = o.setupListeners()
430
+
431
+ const outsideEl = document.createElement("div")
432
+ const click = new MouseEvent("click", { bubbles: true })
433
+ Object.defineProperty(click, "target", { value: outsideEl })
434
+ window.dispatchEvent(click)
435
+
436
+ expect(o.active()).toBe(true)
437
+ cleanup()
438
+ })
439
+
440
+ it("closeOn=clickOutsideContent: click outside content closes overlay", () => {
441
+ const o = useOverlay({ openOn: "click", closeOn: "clickOutsideContent", isOpen: true })
442
+ const triggerEl = mockElement()
443
+ const contentEl = mockElement()
444
+ o.triggerRef(triggerEl)
445
+ o.contentRef(contentEl)
446
+ const cleanup = o.setupListeners()
447
+
448
+ const outsideEl = document.createElement("div")
449
+ const click = new MouseEvent("click", { bubbles: true })
450
+ Object.defineProperty(click, "target", { value: outsideEl })
451
+ window.dispatchEvent(click)
452
+
453
+ expect(o.active()).toBe(false)
454
+ cleanup()
455
+ })
456
+
457
+ it("closeOn=clickOutsideContent: click inside content does not close overlay", () => {
458
+ const o = useOverlay({ openOn: "click", closeOn: "clickOutsideContent", isOpen: true })
459
+ const triggerEl = mockElement()
460
+ const contentEl = mockElement()
461
+ const childEl = document.createElement("span")
462
+ contentEl.appendChild(childEl)
463
+ o.triggerRef(triggerEl)
464
+ o.contentRef(contentEl)
465
+ const cleanup = o.setupListeners()
466
+
467
+ const click = new MouseEvent("click", { bubbles: true })
468
+ Object.defineProperty(click, "target", { value: childEl })
469
+ window.dispatchEvent(click)
470
+
471
+ expect(o.active()).toBe(true)
472
+ cleanup()
473
+ })
474
+
475
+ it("click on trigger child element opens overlay (contains check)", () => {
476
+ const o = useOverlay({ openOn: "click" })
477
+ const triggerEl = mockElement()
478
+ const innerEl = document.createElement("span")
479
+ triggerEl.appendChild(innerEl)
480
+ o.triggerRef(triggerEl)
481
+ const cleanup = o.setupListeners()
482
+
483
+ const click = new MouseEvent("click", { bubbles: true })
484
+ Object.defineProperty(click, "target", { value: innerEl })
485
+ window.dispatchEvent(click)
486
+
487
+ expect(o.active()).toBe(true)
488
+ cleanup()
489
+ })
490
+ })
491
+
492
+ // =========================================================================
493
+ // 9. ESC handling
494
+ // =========================================================================
495
+ describe("ESC handling", () => {
496
+ it("closeOnEsc=true: Escape key when active closes overlay", () => {
497
+ const o = useOverlay({ closeOnEsc: true, isOpen: true })
498
+ const cleanup = o.setupListeners()
499
+
500
+ const esc = new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
501
+ window.dispatchEvent(esc)
502
+
503
+ expect(o.active()).toBe(false)
504
+ cleanup()
505
+ })
506
+
507
+ it("closeOnEsc=true: Escape key when inactive does nothing", () => {
508
+ const onClose = vi.fn()
509
+ const o = useOverlay({ closeOnEsc: true, onClose })
510
+ const cleanup = o.setupListeners()
511
+
512
+ const esc = new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
513
+ window.dispatchEvent(esc)
514
+
515
+ expect(o.active()).toBe(false)
516
+ expect(onClose).not.toHaveBeenCalled()
517
+ cleanup()
518
+ })
519
+
520
+ it("closeOnEsc=false: Escape key does not close overlay", () => {
521
+ const o = useOverlay({ closeOnEsc: false, isOpen: true })
522
+ const cleanup = o.setupListeners()
523
+
524
+ const esc = new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
525
+ window.dispatchEvent(esc)
526
+
527
+ expect(o.active()).toBe(true)
528
+ cleanup()
529
+ })
530
+
531
+ it("closeOnEsc: Escape does not close when blocked", () => {
532
+ const o = useOverlay({ closeOnEsc: true, isOpen: true })
533
+ const cleanup = o.setupListeners()
534
+ o.setBlocked()
535
+
536
+ const esc = new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
537
+ window.dispatchEvent(esc)
538
+
539
+ expect(o.active()).toBe(true)
540
+ cleanup()
541
+ })
542
+
543
+ it("non-Escape key does not close overlay", () => {
544
+ const o = useOverlay({ closeOnEsc: true, isOpen: true })
545
+ const cleanup = o.setupListeners()
546
+
547
+ const enter = new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
548
+ window.dispatchEvent(enter)
549
+
550
+ expect(o.active()).toBe(true)
551
+ cleanup()
552
+ })
553
+ })
554
+
555
+ // =========================================================================
556
+ // 10. Hover handling
557
+ // =========================================================================
558
+ describe("hover handling", () => {
559
+ it("openOn=hover: mouseenter on trigger opens overlay", () => {
560
+ const o = useOverlay({ openOn: "hover", closeOn: "hover" })
561
+ const triggerEl = mockElement()
562
+ o.triggerRef(triggerEl)
563
+ const cleanup = o.setupListeners()
564
+
565
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
566
+
567
+ expect(o.active()).toBe(true)
568
+ cleanup()
569
+ })
570
+
571
+ it("closeOn=hover: mouseleave from trigger schedules hide with hoverDelay", () => {
572
+ const o = useOverlay({ openOn: "hover", closeOn: "hover", hoverDelay: 100 })
573
+ const triggerEl = mockElement()
574
+ o.triggerRef(triggerEl)
575
+ const cleanup = o.setupListeners()
576
+
577
+ // Open first
578
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
579
+ expect(o.active()).toBe(true)
580
+
581
+ // Leave trigger
582
+ triggerEl.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }))
583
+ // Should still be active (delay not elapsed)
584
+ expect(o.active()).toBe(true)
585
+
586
+ // Advance timer
587
+ vi.advanceTimersByTime(100)
588
+ expect(o.active()).toBe(false)
589
+ cleanup()
590
+ })
591
+
592
+ it("mouseenter on content cancels hide timeout", () => {
593
+ const o = useOverlay({ openOn: "hover", closeOn: "hover", hoverDelay: 100 })
594
+ const triggerEl = mockElement()
595
+ const contentEl = mockElement()
596
+ o.triggerRef(triggerEl)
597
+ o.contentRef(contentEl)
598
+ const cleanup = o.setupListeners()
599
+
600
+ // Open
601
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
602
+ expect(o.active()).toBe(true)
603
+
604
+ // Leave trigger (starts hide timer)
605
+ triggerEl.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }))
606
+
607
+ // Enter content (cancels hide timer)
608
+ contentEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
609
+
610
+ // Advance past delay
611
+ vi.advanceTimersByTime(200)
612
+ expect(o.active()).toBe(true)
613
+ cleanup()
614
+ })
615
+
616
+ it("mouseleave from content schedules hide", () => {
617
+ const o = useOverlay({ openOn: "hover", closeOn: "hover", hoverDelay: 50 })
618
+ const triggerEl = mockElement()
619
+ const contentEl = mockElement()
620
+ o.triggerRef(triggerEl)
621
+ o.contentRef(contentEl)
622
+ const cleanup = o.setupListeners()
623
+
624
+ // Open
625
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
626
+ expect(o.active()).toBe(true)
627
+
628
+ // Leave content
629
+ contentEl.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }))
630
+ vi.advanceTimersByTime(50)
631
+ expect(o.active()).toBe(false)
632
+ cleanup()
633
+ })
634
+
635
+ it("hover: scroll event closes overlay when closeOn=hover and active", () => {
636
+ const o = useOverlay({ openOn: "hover", closeOn: "hover" })
637
+ const triggerEl = mockElement()
638
+ o.triggerRef(triggerEl)
639
+ const cleanup = o.setupListeners()
640
+
641
+ // Open
642
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
643
+ expect(o.active()).toBe(true)
644
+
645
+ // Scroll (should trigger processVisibilityEvent with closeOn=hover + scroll)
646
+ window.dispatchEvent(new Event("scroll"))
647
+ expect(o.active()).toBe(false)
648
+ cleanup()
649
+ })
650
+
651
+ it("openOn=hover: mouseenter when already active does not call onOpen again", () => {
652
+ const onOpen = vi.fn()
653
+ const o = useOverlay({ openOn: "hover", closeOn: "hover", onOpen })
654
+ const triggerEl = mockElement()
655
+ o.triggerRef(triggerEl)
656
+ const cleanup = o.setupListeners()
657
+
658
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
659
+ triggerEl.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }))
660
+
661
+ expect(onOpen).toHaveBeenCalledOnce()
662
+ cleanup()
663
+ })
664
+ })
665
+
666
+ // =========================================================================
667
+ // 11. Position calculation (dropdown)
668
+ // =========================================================================
669
+ describe("position calculation - dropdown", () => {
670
+ const setupDropdown = (opts: Parameters<typeof useOverlay>[0] = {}) => {
671
+ const o = useOverlay({
672
+ type: "dropdown",
673
+ align: "bottom",
674
+ alignX: "left",
675
+ isOpen: true,
676
+ ...opts,
677
+ })
678
+
679
+ const triggerEl = mockElement({
680
+ top: 100,
681
+ bottom: 130,
682
+ left: 50,
683
+ right: 150,
684
+ width: 100,
685
+ height: 30,
686
+ })
687
+ const contentEl = mockElement({
688
+ top: 0,
689
+ bottom: 200,
690
+ left: 0,
691
+ right: 200,
692
+ width: 200,
693
+ height: 200,
694
+ })
695
+
696
+ o.triggerRef(triggerEl)
697
+ o.contentRef(contentEl)
698
+
699
+ return { o, triggerEl, contentEl }
700
+ }
701
+
702
+ it("bottom align: positions content below trigger", () => {
703
+ const { o, contentEl } = setupDropdown({ align: "bottom", alignX: "left" })
704
+
705
+ // Trigger setContentPosition by calling showContent (which sets active)
706
+ // active is already true and contentRef is set, but isContentLoaded is separate
707
+ // contentRef callback sets isContentLoaded
708
+
709
+ // After contentRef callback, isContentLoaded is true. We need to trigger position calc.
710
+ // The hook doesn't auto-trigger - it exposes setupListeners. Let's simulate resize.
711
+ const cleanup = o.setupListeners()
712
+ window.dispatchEvent(new Event("resize"))
713
+
714
+ // Content should be positioned: top = trigger.bottom + offsetY = 130
715
+ expect(contentEl.style.top).toBe("130px")
716
+ // left = trigger.left + offsetX = 50
717
+ expect(contentEl.style.left).toBe("50px")
718
+ cleanup()
719
+ })
720
+
721
+ it("top align: positions content above trigger", () => {
722
+ const { o, contentEl } = setupDropdown({ align: "top", alignX: "left" })
723
+ const cleanup = o.setupListeners()
724
+ window.dispatchEvent(new Event("resize"))
725
+
726
+ // top = trigger.top - offsetY - content.height = 100 - 0 - 200 = -100
727
+ // Doesn't fit top (-100 < 0), so falls back to bottom: trigger.bottom + offsetY = 130
728
+ expect(contentEl.style.top).toBe("130px")
729
+ cleanup()
730
+ })
731
+
732
+ it("top align with room above: positions content above trigger", () => {
733
+ const o = useOverlay({
734
+ type: "dropdown",
735
+ align: "top",
736
+ alignX: "left",
737
+ isOpen: true,
738
+ })
739
+ const triggerEl = mockElement({
740
+ top: 400,
741
+ bottom: 430,
742
+ left: 50,
743
+ right: 150,
744
+ width: 100,
745
+ height: 30,
746
+ })
747
+ const contentEl = mockElement({
748
+ top: 0,
749
+ bottom: 100,
750
+ left: 0,
751
+ right: 200,
752
+ width: 200,
753
+ height: 100,
754
+ })
755
+ o.triggerRef(triggerEl)
756
+ o.contentRef(contentEl)
757
+ const cleanup = o.setupListeners()
758
+ window.dispatchEvent(new Event("resize"))
759
+
760
+ // top = trigger.top - offsetY - content.height = 400 - 0 - 100 = 300
761
+ expect(contentEl.style.top).toBe("300px")
762
+ cleanup()
763
+ })
764
+
765
+ it("alignX=right: positions content aligned to right edge", () => {
766
+ const { o, contentEl } = setupDropdown({ align: "bottom", alignX: "right" })
767
+ const cleanup = o.setupListeners()
768
+ window.dispatchEvent(new Event("resize"))
769
+
770
+ // right pos = trigger.right - offsetX - content.width = 150 - 0 - 200 = -50
771
+ // fitsRight = -50 >= 0 → false, falls back to leftPos = trigger.left + offsetX = 50
772
+ expect(contentEl.style.left).toBe("50px")
773
+ cleanup()
774
+ })
775
+
776
+ it("alignX=center: centers content horizontally under trigger", () => {
777
+ const { o, contentEl } = setupDropdown({ align: "bottom", alignX: "center" })
778
+ const cleanup = o.setupListeners()
779
+ window.dispatchEvent(new Event("resize"))
780
+
781
+ // center = trigger.left + (trigger.right - trigger.left) / 2 - content.width / 2
782
+ // = 50 + (150 - 50) / 2 - 200 / 2 = 50 + 50 - 100 = 0
783
+ // fitsCL = 0 >= 0 → true, fitsCR = 0 + 200 <= 1024 → true
784
+ expect(contentEl.style.left).toBe("0px")
785
+ cleanup()
786
+ })
787
+
788
+ it("with offsets: applies offsetX and offsetY", () => {
789
+ const o = useOverlay({
790
+ type: "dropdown",
791
+ align: "bottom",
792
+ alignX: "left",
793
+ offsetX: 10,
794
+ offsetY: 5,
795
+ isOpen: true,
796
+ })
797
+ const triggerEl = mockElement({
798
+ top: 100,
799
+ bottom: 130,
800
+ left: 50,
801
+ right: 150,
802
+ width: 100,
803
+ height: 30,
804
+ })
805
+ const contentEl = mockElement({
806
+ top: 0,
807
+ bottom: 100,
808
+ left: 0,
809
+ right: 100,
810
+ width: 100,
811
+ height: 100,
812
+ })
813
+ o.triggerRef(triggerEl)
814
+ o.contentRef(contentEl)
815
+ const cleanup = o.setupListeners()
816
+ window.dispatchEvent(new Event("resize"))
817
+
818
+ // top = trigger.bottom + offsetY = 130 + 5 = 135
819
+ expect(contentEl.style.top).toBe("135px")
820
+ // left = trigger.left + offsetX = 50 + 10 = 60
821
+ expect(contentEl.style.left).toBe("60px")
822
+ cleanup()
823
+ })
824
+ })
825
+
826
+ // =========================================================================
827
+ // 11b. Position calculation - horizontal dropdown
828
+ // =========================================================================
829
+ describe("position calculation - horizontal dropdown", () => {
830
+ it("align=right: positions content to the right of trigger", () => {
831
+ const o = useOverlay({
832
+ type: "dropdown",
833
+ align: "right",
834
+ alignY: "top",
835
+ isOpen: true,
836
+ })
837
+ const triggerEl = mockElement({
838
+ top: 100,
839
+ bottom: 130,
840
+ left: 50,
841
+ right: 150,
842
+ width: 100,
843
+ height: 30,
844
+ })
845
+ const contentEl = mockElement({
846
+ top: 0,
847
+ bottom: 100,
848
+ left: 0,
849
+ right: 100,
850
+ width: 100,
851
+ height: 100,
852
+ })
853
+ o.triggerRef(triggerEl)
854
+ o.contentRef(contentEl)
855
+ const cleanup = o.setupListeners()
856
+ window.dispatchEvent(new Event("resize"))
857
+
858
+ // rightPos = trigger.right + offsetX = 150 + 0 = 150
859
+ // fitsRight = 150 + 100 <= 1024 → true
860
+ expect(contentEl.style.left).toBe("150px")
861
+ // topPos = trigger.top + offsetY = 100
862
+ // fitsTop = 100 + 100 <= 768 → true
863
+ expect(contentEl.style.top).toBe("100px")
864
+ cleanup()
865
+ })
866
+
867
+ it("align=left: positions content to the left of trigger", () => {
868
+ const o = useOverlay({
869
+ type: "dropdown",
870
+ align: "left",
871
+ alignY: "top",
872
+ isOpen: true,
873
+ })
874
+ const triggerEl = mockElement({
875
+ top: 100,
876
+ bottom: 130,
877
+ left: 300,
878
+ right: 400,
879
+ width: 100,
880
+ height: 30,
881
+ })
882
+ const contentEl = mockElement({
883
+ top: 0,
884
+ bottom: 100,
885
+ left: 0,
886
+ right: 100,
887
+ width: 100,
888
+ height: 100,
889
+ })
890
+ o.triggerRef(triggerEl)
891
+ o.contentRef(contentEl)
892
+ const cleanup = o.setupListeners()
893
+ window.dispatchEvent(new Event("resize"))
894
+
895
+ // leftPos = trigger.left - offsetX - content.width = 300 - 0 - 100 = 200
896
+ // fitsLeft = 200 >= 0 → true
897
+ expect(contentEl.style.left).toBe("200px")
898
+ cleanup()
899
+ })
900
+
901
+ it("align=right, alignY=center: vertically centers content", () => {
902
+ const o = useOverlay({
903
+ type: "dropdown",
904
+ align: "right",
905
+ alignY: "center",
906
+ isOpen: true,
907
+ })
908
+ const triggerEl = mockElement({
909
+ top: 300,
910
+ bottom: 330,
911
+ left: 50,
912
+ right: 150,
913
+ width: 100,
914
+ height: 30,
915
+ })
916
+ const contentEl = mockElement({
917
+ top: 0,
918
+ bottom: 100,
919
+ left: 0,
920
+ right: 100,
921
+ width: 100,
922
+ height: 100,
923
+ })
924
+ o.triggerRef(triggerEl)
925
+ o.contentRef(contentEl)
926
+ const cleanup = o.setupListeners()
927
+ window.dispatchEvent(new Event("resize"))
928
+
929
+ // center = trigger.top + (trigger.bottom - trigger.top) / 2 - content.height / 2
930
+ // = 300 + (330 - 300) / 2 - 100 / 2 = 300 + 15 - 50 = 265
931
+ expect(contentEl.style.top).toBe("265px")
932
+ cleanup()
933
+ })
934
+
935
+ it("align=right, alignY=bottom: positions from bottom", () => {
936
+ const o = useOverlay({
937
+ type: "dropdown",
938
+ align: "right",
939
+ alignY: "bottom",
940
+ isOpen: true,
941
+ })
942
+ const triggerEl = mockElement({
943
+ top: 300,
944
+ bottom: 330,
945
+ left: 50,
946
+ right: 150,
947
+ width: 100,
948
+ height: 30,
949
+ })
950
+ const contentEl = mockElement({
951
+ top: 0,
952
+ bottom: 100,
953
+ left: 0,
954
+ right: 100,
955
+ width: 100,
956
+ height: 100,
957
+ })
958
+ o.triggerRef(triggerEl)
959
+ o.contentRef(contentEl)
960
+ const cleanup = o.setupListeners()
961
+ window.dispatchEvent(new Event("resize"))
962
+
963
+ // bottomPos = trigger.bottom - offsetY - content.height = 330 - 0 - 100 = 230
964
+ // fitsBottom = 230 >= 0 → true
965
+ expect(contentEl.style.top).toBe("230px")
966
+ cleanup()
967
+ })
968
+ })
969
+
970
+ // =========================================================================
971
+ // 12. Modal positioning
972
+ // =========================================================================
973
+ describe("position calculation - modal", () => {
974
+ it("modal type: centers content by default", () => {
975
+ const o = useOverlay({
976
+ type: "modal",
977
+ alignX: "center",
978
+ alignY: "center",
979
+ isOpen: true,
980
+ })
981
+ const contentEl = mockElement({
982
+ top: 0,
983
+ bottom: 200,
984
+ left: 0,
985
+ right: 300,
986
+ width: 300,
987
+ height: 200,
988
+ })
989
+ o.contentRef(contentEl)
990
+ const cleanup = o.setupListeners()
991
+ window.dispatchEvent(new Event("resize"))
992
+
993
+ // left = innerWidth / 2 - width / 2 = 1024 / 2 - 300 / 2 = 362
994
+ expect(contentEl.style.left).toBe("362px")
995
+ // top = innerHeight / 2 - height / 2 = 768 / 2 - 200 / 2 = 284
996
+ expect(contentEl.style.top).toBe("284px")
997
+ cleanup()
998
+ })
999
+
1000
+ it("modal type: alignX=left positions left edge", () => {
1001
+ const o = useOverlay({
1002
+ type: "modal",
1003
+ alignX: "left",
1004
+ alignY: "top",
1005
+ offsetX: 20,
1006
+ offsetY: 10,
1007
+ isOpen: true,
1008
+ })
1009
+ const contentEl = mockElement({ width: 300, height: 200 })
1010
+ o.contentRef(contentEl)
1011
+ const cleanup = o.setupListeners()
1012
+ window.dispatchEvent(new Event("resize"))
1013
+
1014
+ expect(contentEl.style.left).toBe("20px")
1015
+ expect(contentEl.style.top).toBe("10px")
1016
+ cleanup()
1017
+ })
1018
+
1019
+ it("modal type: alignX=right positions right edge", () => {
1020
+ const o = useOverlay({
1021
+ type: "modal",
1022
+ alignX: "right",
1023
+ alignY: "bottom",
1024
+ offsetX: 15,
1025
+ offsetY: 25,
1026
+ isOpen: true,
1027
+ })
1028
+ const contentEl = mockElement({ width: 300, height: 200 })
1029
+ o.contentRef(contentEl)
1030
+ const cleanup = o.setupListeners()
1031
+ window.dispatchEvent(new Event("resize"))
1032
+
1033
+ expect(contentEl.style.right).toBe("15px")
1034
+ expect(contentEl.style.bottom).toBe("25px")
1035
+ cleanup()
1036
+ })
1037
+
1038
+ it("modal type: sets document.body overflow to hidden", () => {
1039
+ const o = useOverlay({ type: "modal", isOpen: true })
1040
+ const contentEl = mockElement({ width: 300, height: 200 })
1041
+ o.contentRef(contentEl)
1042
+ const cleanup = o.setupListeners()
1043
+
1044
+ expect(document.body.style.overflow).toBe("hidden")
1045
+ cleanup()
1046
+ expect(document.body.style.overflow).toBe("")
1047
+ })
1048
+ })
1049
+
1050
+ // =========================================================================
1051
+ // 13. Position - custom type
1052
+ // =========================================================================
1053
+ describe("position calculation - custom type", () => {
1054
+ it("custom type: does not set position styles", () => {
1055
+ const o = useOverlay({
1056
+ type: "custom",
1057
+ isOpen: true,
1058
+ })
1059
+ const contentEl = mockElement({ width: 100, height: 100 })
1060
+ o.contentRef(contentEl)
1061
+ const cleanup = o.setupListeners()
1062
+ window.dispatchEvent(new Event("resize"))
1063
+
1064
+ // computePosition returns {} for custom type
1065
+ expect(contentEl.style.top).toBe("")
1066
+ expect(contentEl.style.left).toBe("")
1067
+ cleanup()
1068
+ })
1069
+ })
1070
+
1071
+ // =========================================================================
1072
+ // 14. Alignment signal updates after positioning
1073
+ // =========================================================================
1074
+ describe("alignment signal updates", () => {
1075
+ it("updates alignX signal when position flips horizontally", () => {
1076
+ setViewport(200, 768)
1077
+ const o = useOverlay({
1078
+ type: "dropdown",
1079
+ align: "bottom",
1080
+ alignX: "left",
1081
+ isOpen: true,
1082
+ })
1083
+ const triggerEl = mockElement({
1084
+ top: 100,
1085
+ bottom: 130,
1086
+ left: 50,
1087
+ right: 150,
1088
+ width: 100,
1089
+ height: 30,
1090
+ })
1091
+ // Content wider than remaining space on the left side
1092
+ const contentEl = mockElement({
1093
+ width: 200,
1094
+ height: 50,
1095
+ })
1096
+ o.triggerRef(triggerEl)
1097
+ o.contentRef(contentEl)
1098
+ const cleanup = o.setupListeners()
1099
+ window.dispatchEvent(new Event("resize"))
1100
+
1101
+ // leftPos = 50 + 0 = 50, fitsLeft = 50 + 200 <= 200 → false
1102
+ // rightPos = 150 - 0 - 200 = -50, fitsRight = -50 >= 0 → false
1103
+ // Falls back: sel(fitsLeft, leftPos, rightPos) → rightPos = -50
1104
+ // resolvedAlignX = sel(fitsLeft, "left", "right") → "right"
1105
+ expect(o.alignX()).toBe("right")
1106
+ cleanup()
1107
+ })
1108
+
1109
+ it("updates alignY signal when vertical position flips to top", () => {
1110
+ setViewport(1024, 200)
1111
+ const o = useOverlay({
1112
+ type: "dropdown",
1113
+ align: "bottom",
1114
+ alignX: "left",
1115
+ isOpen: true,
1116
+ })
1117
+ const triggerEl = mockElement({
1118
+ top: 100,
1119
+ bottom: 130,
1120
+ left: 50,
1121
+ right: 150,
1122
+ width: 100,
1123
+ height: 30,
1124
+ })
1125
+ const contentEl = mockElement({
1126
+ width: 100,
1127
+ height: 100,
1128
+ })
1129
+ o.triggerRef(triggerEl)
1130
+ o.contentRef(contentEl)
1131
+ const cleanup = o.setupListeners()
1132
+ window.dispatchEvent(new Event("resize"))
1133
+
1134
+ // bottomPos = 130, fitsBottom = 130 + 100 <= 200 → false
1135
+ // useTop = sel(align === "top", fitsTop, !fitsBottom) = sel(false, _, !false) = true
1136
+ // resolvedAlignY = "top"
1137
+ expect(o.alignY()).toBe("top")
1138
+ cleanup()
1139
+ })
1140
+ })
1141
+
1142
+ // =========================================================================
1143
+ // 15. Resize reposition
1144
+ // =========================================================================
1145
+ describe("resize handling", () => {
1146
+ it("recalculates position on window resize", () => {
1147
+ const o = useOverlay({
1148
+ type: "dropdown",
1149
+ align: "bottom",
1150
+ alignX: "left",
1151
+ isOpen: true,
1152
+ })
1153
+ const triggerEl = mockElement({
1154
+ top: 100,
1155
+ bottom: 130,
1156
+ left: 50,
1157
+ right: 150,
1158
+ width: 100,
1159
+ height: 30,
1160
+ })
1161
+ const contentEl = mockElement({
1162
+ width: 100,
1163
+ height: 100,
1164
+ })
1165
+ o.triggerRef(triggerEl)
1166
+ o.contentRef(contentEl)
1167
+ const cleanup = o.setupListeners()
1168
+
1169
+ // First resize
1170
+ window.dispatchEvent(new Event("resize"))
1171
+ expect(contentEl.style.top).toBe("130px")
1172
+
1173
+ // Change trigger position and resize again
1174
+ triggerEl.getBoundingClientRect = () => ({
1175
+ top: 200,
1176
+ bottom: 230,
1177
+ left: 50,
1178
+ right: 150,
1179
+ width: 100,
1180
+ height: 30,
1181
+ x: 50,
1182
+ y: 200,
1183
+ toJSON: () => {},
1184
+ })
1185
+ window.dispatchEvent(new Event("resize"))
1186
+ expect(contentEl.style.top).toBe("230px")
1187
+ cleanup()
1188
+ })
1189
+ })
1190
+
1191
+ // =========================================================================
1192
+ // 16. Parent container
1193
+ // =========================================================================
1194
+ describe("parentContainer", () => {
1195
+ it("sets overflow hidden on parent when closeOn is not hover", () => {
1196
+ const parent = document.createElement("div")
1197
+ const o = useOverlay({ parentContainer: parent, closeOn: "click" })
1198
+ const cleanup = o.setupListeners()
1199
+
1200
+ expect(parent.style.overflow).toBe("hidden")
1201
+ cleanup()
1202
+ expect(parent.style.overflow).toBe("")
1203
+ })
1204
+
1205
+ it("does not set overflow hidden on parent when closeOn is hover", () => {
1206
+ const parent = document.createElement("div")
1207
+ const o = useOverlay({ parentContainer: parent, closeOn: "hover", openOn: "hover" })
1208
+ const cleanup = o.setupListeners()
1209
+
1210
+ expect(parent.style.overflow).not.toBe("hidden")
1211
+ cleanup()
1212
+ })
1213
+ })
1214
+
1215
+ // =========================================================================
1216
+ // 17. Provider
1217
+ // =========================================================================
1218
+ describe("Provider", () => {
1219
+ it("exposes Provider component", () => {
1220
+ const o = useOverlay()
1221
+ expect(typeof o.Provider).toBe("function")
1222
+ })
1223
+ })
1224
+
1225
+ // =========================================================================
1226
+ // 18. Independent instances
1227
+ // =========================================================================
1228
+ describe("independent instances", () => {
1229
+ it("two useOverlay instances do not share state", () => {
1230
+ const o1 = useOverlay()
1231
+ const o2 = useOverlay()
1232
+
1233
+ o1.showContent()
1234
+ expect(o1.active()).toBe(true)
1235
+ expect(o2.active()).toBe(false)
1236
+
1237
+ o2.showContent()
1238
+ o1.hideContent()
1239
+ expect(o1.active()).toBe(false)
1240
+ expect(o2.active()).toBe(true)
1241
+ })
1242
+ })
1243
+
1244
+ // =========================================================================
1245
+ // 19. Manual open/close mode
1246
+ // =========================================================================
1247
+ describe("manual mode", () => {
1248
+ it("openOn=manual: click on trigger does not open", () => {
1249
+ const o = useOverlay({ openOn: "manual", closeOn: "manual" })
1250
+ const triggerEl = mockElement()
1251
+ o.triggerRef(triggerEl)
1252
+ const cleanup = o.setupListeners()
1253
+
1254
+ const click = new MouseEvent("click", { bubbles: true })
1255
+ Object.defineProperty(click, "target", { value: triggerEl })
1256
+ window.dispatchEvent(click)
1257
+
1258
+ expect(o.active()).toBe(false)
1259
+ cleanup()
1260
+ })
1261
+
1262
+ it("manual mode: only showContent/hideContent toggle state", () => {
1263
+ const o = useOverlay({ openOn: "manual", closeOn: "manual" })
1264
+ const cleanup = o.setupListeners()
1265
+
1266
+ o.showContent()
1267
+ expect(o.active()).toBe(true)
1268
+
1269
+ o.hideContent()
1270
+ expect(o.active()).toBe(false)
1271
+ cleanup()
1272
+ })
1273
+ })
1274
+
1275
+ // =========================================================================
1276
+ // 20. Position with absolute + ancestor offset
1277
+ // =========================================================================
1278
+ describe("position absolute with ancestor offset", () => {
1279
+ it("adjusts position for offset parent", () => {
1280
+ const o = useOverlay({
1281
+ type: "dropdown",
1282
+ align: "bottom",
1283
+ alignX: "left",
1284
+ position: "absolute",
1285
+ isOpen: true,
1286
+ })
1287
+ const triggerEl = mockElement({
1288
+ top: 200,
1289
+ bottom: 230,
1290
+ left: 100,
1291
+ right: 200,
1292
+ width: 100,
1293
+ height: 30,
1294
+ })
1295
+ const contentEl = mockElement({
1296
+ width: 100,
1297
+ height: 50,
1298
+ })
1299
+
1300
+ // Mock offsetParent
1301
+ const offsetParent = document.createElement("div")
1302
+ offsetParent.getBoundingClientRect = () => ({
1303
+ top: 50,
1304
+ bottom: 400,
1305
+ left: 30,
1306
+ right: 500,
1307
+ width: 470,
1308
+ height: 350,
1309
+ x: 30,
1310
+ y: 50,
1311
+ toJSON: () => {},
1312
+ })
1313
+ Object.defineProperty(contentEl, "offsetParent", {
1314
+ value: offsetParent,
1315
+ configurable: true,
1316
+ })
1317
+
1318
+ o.triggerRef(triggerEl)
1319
+ o.contentRef(contentEl)
1320
+ const cleanup = o.setupListeners()
1321
+ window.dispatchEvent(new Event("resize"))
1322
+
1323
+ // Without ancestor: top = 230, left = 100
1324
+ // Adjusted: top = 230 - 50 = 180, left = 100 - 30 = 70
1325
+ expect(contentEl.style.top).toBe("180px")
1326
+ expect(contentEl.style.left).toBe("70px")
1327
+ cleanup()
1328
+ })
1329
+ })
1330
+ })