@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,616 @@
1
+ /**
2
+ * Core hook powering the Overlay component. Manages open/close state, DOM
3
+ * event listeners (click, hover, scroll, resize, ESC key), and dynamic
4
+ * positioning of overlay content relative to its trigger. Supports dropdown,
5
+ * tooltip, popover, and modal types with automatic edge-of-viewport flipping.
6
+ * Event handlers are throttled for performance, and nested overlay blocking
7
+ * is coordinated through the overlay context.
8
+ */
9
+
10
+ import { signal } from "@pyreon/reactivity"
11
+ import { throttle } from "@pyreon/ui-core"
12
+ import { value } from "@pyreon/unistyle"
13
+ import { IS_DEVELOPMENT } from "../utils"
14
+ import Provider, { useOverlayContext } from "./context"
15
+
16
+ type OverlayPosition = Partial<{
17
+ top: number | string
18
+ bottom: number | string
19
+ left: number | string
20
+ right: number | string
21
+ }>
22
+
23
+ type Align = "bottom" | "top" | "left" | "right"
24
+ type AlignX = "left" | "center" | "right"
25
+ type AlignY = "bottom" | "top" | "center"
26
+
27
+ export type UseOverlayProps = Partial<{
28
+ isOpen: boolean
29
+ openOn: "click" | "hover" | "manual"
30
+ closeOn: "click" | "clickOnTrigger" | "clickOutsideContent" | "hover" | "manual"
31
+ type: "dropdown" | "tooltip" | "popover" | "modal" | "custom"
32
+ position: "absolute" | "fixed" | "relative" | "static"
33
+ align: Align
34
+ alignX: AlignX
35
+ alignY: AlignY
36
+ offsetX: number
37
+ offsetY: number
38
+ throttleDelay: number
39
+ parentContainer: HTMLElement | null
40
+ closeOnEsc: boolean
41
+ hoverDelay: number
42
+ disabled: boolean
43
+ onOpen: () => void
44
+ onClose: () => void
45
+ }>
46
+
47
+ type PositionResult = {
48
+ pos: OverlayPosition
49
+ resolvedAlignX: AlignX
50
+ resolvedAlignY: AlignY
51
+ }
52
+
53
+ // Reference counter for nested modals sharing document.body overflow lock.
54
+ let modalOverflowCount = 0
55
+
56
+ const sel = <T,>(cond: boolean, a: T, b: T): T => (cond ? a : b)
57
+
58
+ const devWarn = (msg: string) => {
59
+ if (!IS_DEVELOPMENT) return
60
+ // biome-ignore lint/suspicious/noConsole: dev-mode warning
61
+ console.warn(msg)
62
+ }
63
+
64
+ const calcDropdownVertical = (
65
+ c: DOMRect,
66
+ t: DOMRect,
67
+ align: "top" | "bottom",
68
+ alignX: AlignX,
69
+ offsetX: number,
70
+ offsetY: number,
71
+ ): PositionResult => {
72
+ const pos: OverlayPosition = {}
73
+
74
+ const topPos = t.top - offsetY - c.height
75
+ const bottomPos = t.bottom + offsetY
76
+ const leftPos = t.left + offsetX
77
+ const rightPos = t.right - offsetX - c.width
78
+
79
+ const fitsTop = topPos >= 0
80
+ const fitsBottom = bottomPos + c.height <= window.innerHeight
81
+ const fitsLeft = leftPos + c.width <= window.innerWidth
82
+ const fitsRight = rightPos >= 0
83
+
84
+ const useTop = sel(align === "top", fitsTop, !fitsBottom)
85
+ pos.top = sel(useTop, topPos, bottomPos)
86
+ const resolvedAlignY: AlignY = sel(useTop, "top", "bottom")
87
+
88
+ let resolvedAlignX: AlignX = alignX
89
+ if (alignX === "left") {
90
+ pos.left = sel(fitsLeft, leftPos, rightPos)
91
+ resolvedAlignX = sel(fitsLeft, "left", "right")
92
+ } else if (alignX === "right") {
93
+ pos.left = sel(fitsRight, rightPos, leftPos)
94
+ resolvedAlignX = sel(fitsRight, "right", "left")
95
+ } else {
96
+ const center = t.left + (t.right - t.left) / 2 - c.width / 2
97
+ const fitsCL = center >= 0
98
+ const fitsCR = center + c.width <= window.innerWidth
99
+
100
+ if (fitsCL && fitsCR) {
101
+ resolvedAlignX = "center"
102
+ pos.left = center
103
+ } else if (fitsCL) {
104
+ resolvedAlignX = "left"
105
+ pos.left = leftPos
106
+ } else if (fitsCR) {
107
+ resolvedAlignX = "right"
108
+ pos.left = rightPos
109
+ }
110
+ }
111
+
112
+ return { pos, resolvedAlignX, resolvedAlignY }
113
+ }
114
+
115
+ const calcDropdownHorizontal = (
116
+ c: DOMRect,
117
+ t: DOMRect,
118
+ align: "left" | "right",
119
+ alignY: AlignY,
120
+ offsetX: number,
121
+ offsetY: number,
122
+ ): PositionResult => {
123
+ const pos: OverlayPosition = {}
124
+
125
+ const leftPos = t.left - offsetX - c.width
126
+ const rightPos = t.right + offsetX
127
+ const topPos = t.top + offsetY
128
+ const bottomPos = t.bottom - offsetY - c.height
129
+
130
+ const fitsLeft = leftPos >= 0
131
+ const fitsRight = rightPos + c.width <= window.innerWidth
132
+ const fitsTop = topPos + c.height <= window.innerHeight
133
+ const fitsBottom = bottomPos >= 0
134
+
135
+ const useLeft = sel(align === "left", fitsLeft, !fitsRight)
136
+ pos.left = sel(useLeft, leftPos, rightPos)
137
+ const resolvedAlignX: AlignX = sel(useLeft, "left", "right")
138
+
139
+ let resolvedAlignY: AlignY = alignY
140
+ if (alignY === "top") {
141
+ pos.top = sel(fitsTop, topPos, bottomPos)
142
+ resolvedAlignY = sel(fitsTop, "top", "bottom")
143
+ } else if (alignY === "bottom") {
144
+ pos.top = sel(fitsBottom, bottomPos, topPos)
145
+ resolvedAlignY = sel(fitsBottom, "bottom", "top")
146
+ } else {
147
+ const center = t.top + (t.bottom - t.top) / 2 - c.height / 2
148
+ const fitsCT = center >= 0
149
+ const fitsCB = center + c.height <= window.innerHeight
150
+
151
+ if (fitsCT && fitsCB) {
152
+ resolvedAlignY = "center"
153
+ pos.top = center
154
+ } else if (fitsCT) {
155
+ resolvedAlignY = "top"
156
+ pos.top = topPos
157
+ } else if (fitsCB) {
158
+ resolvedAlignY = "bottom"
159
+ pos.top = bottomPos
160
+ }
161
+ }
162
+
163
+ return { pos, resolvedAlignX, resolvedAlignY }
164
+ }
165
+
166
+ const calcModalPos = (
167
+ c: DOMRect,
168
+ alignX: AlignX,
169
+ alignY: AlignY,
170
+ offsetX: number,
171
+ offsetY: number,
172
+ ): OverlayPosition => {
173
+ const pos: OverlayPosition = {}
174
+
175
+ switch (alignX) {
176
+ case "right":
177
+ pos.right = offsetX
178
+ break
179
+ case "left":
180
+ pos.left = offsetX
181
+ break
182
+ case "center":
183
+ pos.left = window.innerWidth / 2 - c.width / 2
184
+ break
185
+ default:
186
+ pos.right = offsetX
187
+ }
188
+
189
+ switch (alignY) {
190
+ case "top":
191
+ pos.top = offsetY
192
+ break
193
+ case "center":
194
+ pos.top = window.innerHeight / 2 - c.height / 2
195
+ break
196
+ case "bottom":
197
+ pos.bottom = offsetY
198
+ break
199
+ default:
200
+ pos.top = offsetY
201
+ }
202
+
203
+ return pos
204
+ }
205
+
206
+ const adjustForAncestor = (
207
+ pos: OverlayPosition,
208
+ ancestor: { top: number; left: number },
209
+ ): OverlayPosition => {
210
+ if (ancestor.top === 0 && ancestor.left === 0) return pos
211
+
212
+ const result = { ...pos }
213
+ if (typeof result.top === "number") result.top -= ancestor.top
214
+ if (typeof result.bottom === "number") result.bottom += ancestor.top
215
+ if (typeof result.left === "number") result.left -= ancestor.left
216
+ if (typeof result.right === "number") result.right += ancestor.left
217
+
218
+ return result
219
+ }
220
+
221
+ type ComputeResult = {
222
+ pos: OverlayPosition
223
+ resolvedAlignX?: AlignX
224
+ resolvedAlignY?: AlignY
225
+ }
226
+
227
+ const computePosition = (
228
+ type: string,
229
+ align: Align,
230
+ alignX: AlignX,
231
+ alignY: AlignY,
232
+ offsetX: number,
233
+ offsetY: number,
234
+ triggerEl: HTMLElement | null,
235
+ contentEl: HTMLElement | null,
236
+ ancestorOffset: { top: number; left: number },
237
+ ): ComputeResult => {
238
+ const isDropdown = ["dropdown", "tooltip", "popover"].includes(type)
239
+
240
+ if (isDropdown && (!triggerEl || !contentEl)) {
241
+ devWarn(
242
+ `[@pyreon/elements] Overlay (${type}): ` +
243
+ `${triggerEl ? "contentRef" : "triggerRef"} is not attached. ` +
244
+ "Position cannot be calculated without both refs.",
245
+ )
246
+ return { pos: {} }
247
+ }
248
+
249
+ if (isDropdown && triggerEl && contentEl) {
250
+ const c = contentEl.getBoundingClientRect()
251
+ const t = triggerEl.getBoundingClientRect()
252
+ const result =
253
+ align === "top" || align === "bottom"
254
+ ? calcDropdownVertical(c, t, align, alignX, offsetX, offsetY)
255
+ : calcDropdownHorizontal(c, t, align as "left" | "right", alignY, offsetX, offsetY)
256
+
257
+ return {
258
+ pos: adjustForAncestor(result.pos, ancestorOffset),
259
+ resolvedAlignX: result.resolvedAlignX,
260
+ resolvedAlignY: result.resolvedAlignY,
261
+ }
262
+ }
263
+
264
+ if (type === "modal") {
265
+ if (!contentEl) {
266
+ devWarn(
267
+ "[@pyreon/elements] Overlay (modal): contentRef is not attached. " +
268
+ "Modal position cannot be calculated without a content element.",
269
+ )
270
+ return { pos: {} }
271
+ }
272
+ const c = contentEl.getBoundingClientRect()
273
+ return {
274
+ pos: adjustForAncestor(calcModalPos(c, alignX, alignY, offsetX, offsetY), ancestorOffset),
275
+ }
276
+ }
277
+
278
+ return { pos: {} }
279
+ }
280
+
281
+ const processVisibilityEvent = (
282
+ e: Event,
283
+ active: boolean,
284
+ openOn: string,
285
+ closeOn: string,
286
+ isTrigger: (evt: Event) => boolean,
287
+ isContent: (evt: Event) => boolean,
288
+ showContent: () => void,
289
+ hideContent: () => void,
290
+ ) => {
291
+ if (!active && openOn === "click" && e.type === "click" && isTrigger(e)) {
292
+ showContent()
293
+ return
294
+ }
295
+
296
+ if (!active) return
297
+
298
+ if (closeOn === "hover" && e.type === "scroll") {
299
+ hideContent()
300
+ return
301
+ }
302
+
303
+ if (e.type !== "click") return
304
+
305
+ if (closeOn === "click") {
306
+ hideContent()
307
+ } else if (closeOn === "clickOnTrigger" && isTrigger(e)) {
308
+ hideContent()
309
+ } else if (closeOn === "clickOutsideContent" && !isContent(e)) {
310
+ hideContent()
311
+ }
312
+ }
313
+
314
+ const useOverlay = ({
315
+ isOpen = false,
316
+ openOn = "click",
317
+ closeOn = "click",
318
+ type = "dropdown",
319
+ position = "fixed",
320
+ align = "bottom",
321
+ alignX: propAlignX = "left",
322
+ alignY: propAlignY = "bottom",
323
+ offsetX = 0,
324
+ offsetY = 0,
325
+ throttleDelay = 200,
326
+ parentContainer,
327
+ closeOnEsc = true,
328
+ hoverDelay = 100,
329
+ disabled,
330
+ onOpen,
331
+ onClose,
332
+ }: Partial<UseOverlayProps> = {}) => {
333
+ const ctx = useOverlayContext()
334
+
335
+ // Signal-based state
336
+ const active = signal(isOpen)
337
+ const isContentLoaded = signal(false)
338
+ const innerAlignX = signal(propAlignX)
339
+ const innerAlignY = signal(propAlignY)
340
+ const blockedCount = signal(0)
341
+
342
+ const blocked = () => blockedCount() > 0
343
+
344
+ // DOM refs (plain variables, component runs once)
345
+ let triggerEl: HTMLElement | null = null
346
+ let contentEl: HTMLElement | null = null
347
+ const _prevFocusEl: HTMLElement | null = null
348
+ let hoverTimeout: ReturnType<typeof setTimeout> | null = null
349
+
350
+ const triggerRef = (node: HTMLElement | null) => {
351
+ triggerEl = node
352
+ }
353
+
354
+ const contentRefCallback = (node: HTMLElement | null) => {
355
+ contentEl = node
356
+ isContentLoaded.set(!!node)
357
+ }
358
+
359
+ const setBlocked = () => blockedCount.update((c) => c + 1)
360
+ const setUnblocked = () => blockedCount.update((c) => Math.max(0, c - 1))
361
+
362
+ const showContent = () => {
363
+ active.set(true)
364
+ onOpen?.()
365
+ ctx.setBlocked?.()
366
+ }
367
+
368
+ const hideContent = () => {
369
+ active.set(false)
370
+ isContentLoaded.set(false)
371
+ onClose?.()
372
+ ctx.setUnblocked?.()
373
+ }
374
+
375
+ // Position calculation helpers
376
+ const getAncestorOffset = () => {
377
+ if (position !== "absolute" || !contentEl) {
378
+ return { top: 0, left: 0 }
379
+ }
380
+
381
+ const offsetParent = contentEl.offsetParent as HTMLElement | null
382
+ if (!offsetParent || offsetParent === document.body) {
383
+ return { top: 0, left: 0 }
384
+ }
385
+
386
+ const rect = offsetParent.getBoundingClientRect()
387
+ return { top: rect.top, left: rect.left }
388
+ }
389
+
390
+ const calculateContentPosition = () => {
391
+ if (!active() || !isContentLoaded()) return {}
392
+
393
+ const result = computePosition(
394
+ type,
395
+ align,
396
+ propAlignX,
397
+ propAlignY,
398
+ offsetX,
399
+ offsetY,
400
+ triggerEl,
401
+ contentEl,
402
+ getAncestorOffset(),
403
+ )
404
+
405
+ if (result.resolvedAlignX) innerAlignX.set(result.resolvedAlignX)
406
+ if (result.resolvedAlignY) innerAlignY.set(result.resolvedAlignY)
407
+
408
+ return result.pos
409
+ }
410
+
411
+ const assignContentPosition = (values: OverlayPosition = {}) => {
412
+ if (!contentEl) return
413
+
414
+ const el = contentEl
415
+ const setValue = (param?: string | number) => value(param, 16) as string
416
+
417
+ el.style.position = position
418
+
419
+ el.style.top = values.top != null ? setValue(values.top) : ""
420
+ el.style.bottom = values.bottom != null ? setValue(values.bottom) : ""
421
+ el.style.left = values.left != null ? setValue(values.left) : ""
422
+ el.style.right = values.right != null ? setValue(values.right) : ""
423
+ }
424
+
425
+ const setContentPosition = () => {
426
+ const currentPosition = calculateContentPosition()
427
+ assignContentPosition(currentPosition)
428
+ }
429
+
430
+ const isNodeOrChild = (getRef: () => HTMLElement | null) => (e: Event) => {
431
+ const ref = getRef()
432
+ if (e?.target && ref) {
433
+ return ref.contains(e.target as Element) || e.target === ref
434
+ }
435
+ return false
436
+ }
437
+
438
+ const handleVisibilityByEventType = (e: Event) => {
439
+ if (blocked() || disabled) return
440
+
441
+ processVisibilityEvent(
442
+ e,
443
+ active(),
444
+ openOn,
445
+ closeOn,
446
+ isNodeOrChild(() => triggerEl),
447
+ isNodeOrChild(() => contentEl),
448
+ showContent,
449
+ hideContent,
450
+ )
451
+ }
452
+
453
+ const handleContentPosition = throttle(() => setContentPosition(), throttleDelay)
454
+
455
+ const handleClick = (e: Event) => handleVisibilityByEventType(e)
456
+
457
+ const handleVisibility = throttle((e: Event) => handleVisibilityByEventType(e), throttleDelay)
458
+
459
+ // --------------------------------------------------------------------------
460
+ // Set up all event listeners on mount, clean up on unmount
461
+ // --------------------------------------------------------------------------
462
+ const setupListeners = () => {
463
+ const cleanups: (() => void)[] = []
464
+
465
+ // Click-based open/close
466
+ const enabledClick =
467
+ openOn === "click" || ["click", "clickOnTrigger", "clickOutsideContent"].includes(closeOn)
468
+
469
+ if (enabledClick) {
470
+ window.addEventListener("click", handleClick)
471
+ cleanups.push(() => window.removeEventListener("click", handleClick))
472
+ }
473
+
474
+ // ESC key
475
+ if (closeOnEsc) {
476
+ const handleEscKey = (e: KeyboardEvent) => {
477
+ if (e.key === "Escape" && active() && !blocked()) {
478
+ hideContent()
479
+ }
480
+ }
481
+ window.addEventListener("keydown", handleEscKey)
482
+ cleanups.push(() => window.removeEventListener("keydown", handleEscKey))
483
+ }
484
+
485
+ // Hover-based open/close
486
+ const enabledHover = openOn === "hover" || closeOn === "hover"
487
+ if (enabledHover) {
488
+ const clearHoverTimeout = () => {
489
+ if (hoverTimeout != null) {
490
+ clearTimeout(hoverTimeout)
491
+ hoverTimeout = null
492
+ }
493
+ }
494
+
495
+ const scheduleHide = () => {
496
+ clearHoverTimeout()
497
+ hoverTimeout = setTimeout(hideContent, hoverDelay)
498
+ }
499
+
500
+ const onTriggerEnter = () => {
501
+ clearHoverTimeout()
502
+ if (openOn === "hover" && !active()) showContent()
503
+ }
504
+
505
+ const onTriggerLeave = () => {
506
+ if (closeOn === "hover" && active()) scheduleHide()
507
+ }
508
+
509
+ const onContentEnter = () => {
510
+ clearHoverTimeout()
511
+ }
512
+
513
+ const onContentLeave = () => {
514
+ if (closeOn === "hover" && active()) scheduleHide()
515
+ }
516
+
517
+ // We need to defer listener attachment until refs are available
518
+ const attachHoverListeners = () => {
519
+ if (triggerEl) {
520
+ triggerEl.addEventListener("mouseenter", onTriggerEnter)
521
+ triggerEl.addEventListener("mouseleave", onTriggerLeave)
522
+ }
523
+ if (contentEl) {
524
+ contentEl.addEventListener("mouseenter", onContentEnter)
525
+ contentEl.addEventListener("mouseleave", onContentLeave)
526
+ }
527
+ }
528
+
529
+ attachHoverListeners()
530
+
531
+ cleanups.push(() => {
532
+ clearHoverTimeout()
533
+ if (triggerEl) {
534
+ triggerEl.removeEventListener("mouseenter", onTriggerEnter)
535
+ triggerEl.removeEventListener("mouseleave", onTriggerLeave)
536
+ }
537
+ if (contentEl) {
538
+ contentEl.removeEventListener("mouseenter", onContentEnter)
539
+ contentEl.removeEventListener("mouseleave", onContentLeave)
540
+ }
541
+ })
542
+ }
543
+
544
+ // Resize/scroll repositioning
545
+ const shouldSetOverflow = type === "modal"
546
+
547
+ const onScroll = (e: Event) => {
548
+ handleContentPosition()
549
+ handleVisibility(e)
550
+ }
551
+
552
+ if (shouldSetOverflow) {
553
+ modalOverflowCount++
554
+ if (modalOverflowCount === 1) document.body.style.overflow = "hidden"
555
+ }
556
+
557
+ window.addEventListener("resize", handleContentPosition)
558
+ window.addEventListener("scroll", onScroll, { passive: true })
559
+ cleanups.push(() => {
560
+ handleContentPosition.cancel()
561
+ handleVisibility.cancel()
562
+ if (shouldSetOverflow) {
563
+ modalOverflowCount--
564
+ if (modalOverflowCount === 0) document.body.style.overflow = ""
565
+ }
566
+ window.removeEventListener("resize", handleContentPosition)
567
+ window.removeEventListener("scroll", onScroll)
568
+ })
569
+
570
+ // Parent container scroll
571
+ if (parentContainer) {
572
+ if (closeOn !== "hover") parentContainer.style.overflow = "hidden"
573
+
574
+ const onParentScroll = (e: Event) => {
575
+ handleContentPosition()
576
+ handleVisibility(e)
577
+ }
578
+
579
+ parentContainer.addEventListener("scroll", onParentScroll, {
580
+ passive: true,
581
+ })
582
+ cleanups.push(() => {
583
+ parentContainer.style.overflow = ""
584
+ parentContainer.removeEventListener("scroll", onParentScroll)
585
+ })
586
+ }
587
+
588
+ // Cleanup function
589
+ return () => {
590
+ for (const cleanup of cleanups) cleanup()
591
+ }
592
+ }
593
+
594
+ // Handle disabled state
595
+ if (disabled) {
596
+ active.set(false)
597
+ }
598
+
599
+ return {
600
+ triggerRef,
601
+ contentRef: contentRefCallback,
602
+ active,
603
+ align,
604
+ alignX: innerAlignX,
605
+ alignY: innerAlignY,
606
+ showContent,
607
+ hideContent,
608
+ blocked,
609
+ setBlocked,
610
+ setUnblocked,
611
+ setupListeners,
612
+ Provider,
613
+ }
614
+ }
615
+
616
+ export default useOverlay
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Portal component stub. In Pyreon, the actual Portal is provided by
3
+ * @pyreon/core's runtime-dom. This component re-exports it for API
4
+ * compatibility with the elements package structure.
5
+ */
6
+
7
+ import type { VNodeChild } from "@pyreon/core"
8
+ import { Portal as CorePortal } from "@pyreon/core"
9
+ import { PKG_NAME } from "../constants"
10
+ import type { PyreonComponent } from "../types"
11
+
12
+ export interface Props {
13
+ /**
14
+ * Defines a HTML DOM where children to be appended.
15
+ */
16
+ DOMLocation?: HTMLElement
17
+ /**
18
+ * Children to be rendered within **Portal** component.
19
+ */
20
+ children: VNodeChild
21
+ /**
22
+ * Valid HTML Tag
23
+ */
24
+ tag?: string
25
+ }
26
+
27
+ const Component: PyreonComponent<Props> = ({ DOMLocation, tag: _tag = "div", children }) => {
28
+ const target = DOMLocation ?? (typeof document !== "undefined" ? document.body : undefined)
29
+
30
+ if (!target) return null
31
+
32
+ return <CorePortal target={target}>{children}</CorePortal>
33
+ }
34
+
35
+ const name = `${PKG_NAME}/Portal` as const
36
+
37
+ Component.displayName = name
38
+ Component.pkgName = PKG_NAME
39
+ Component.PYREON__COMPONENT = name
40
+
41
+ export default Component
@@ -0,0 +1,5 @@
1
+ import type { Props } from "./component"
2
+ import component from "./component"
3
+
4
+ export type { Props as PortalProps }
5
+ export { component as Portal }