@pyreon/elements 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.
- package/package.json +14 -12
- package/src/Element/component.tsx +211 -0
- package/src/Element/constants.ts +96 -0
- package/src/Element/index.ts +6 -0
- package/src/Element/types.ts +168 -0
- package/src/Element/utils.ts +15 -0
- package/src/List/component.tsx +57 -0
- package/src/List/index.ts +5 -0
- package/src/Overlay/component.tsx +131 -0
- package/src/Overlay/context.tsx +37 -0
- package/src/Overlay/index.ts +7 -0
- package/src/Overlay/useOverlay.tsx +616 -0
- package/src/Portal/component.tsx +41 -0
- package/src/Portal/index.ts +5 -0
- package/src/Text/component.tsx +65 -0
- package/src/Text/index.ts +5 -0
- package/src/Text/styled.ts +30 -0
- package/src/Util/component.tsx +43 -0
- package/src/Util/index.ts +5 -0
- package/src/__tests__/Content.test.tsx +115 -0
- package/src/__tests__/Element.test.ts +604 -0
- package/src/__tests__/Iterator.test.ts +483 -0
- package/src/__tests__/List.test.ts +199 -0
- package/src/__tests__/Overlay.test.ts +485 -0
- package/src/__tests__/Portal.test.ts +82 -0
- package/src/__tests__/Text.test.ts +274 -0
- package/src/__tests__/Util.test.ts +63 -0
- package/src/__tests__/Wrapper.test.tsx +152 -0
- package/src/__tests__/equalBeforeAfter.test.ts +122 -0
- package/src/__tests__/helpers.test.ts +65 -0
- package/src/__tests__/overlayContext.test.tsx +78 -0
- package/src/__tests__/responsiveProps.test.ts +298 -0
- package/src/__tests__/useOverlay.test.ts +1330 -0
- package/src/__tests__/utils.test.ts +69 -0
- package/src/constants.ts +1 -0
- package/src/helpers/Content/component.tsx +51 -0
- package/src/helpers/Content/index.ts +3 -0
- package/src/helpers/Content/styled.ts +105 -0
- package/src/helpers/Content/types.ts +49 -0
- package/src/helpers/Iterator/component.tsx +252 -0
- package/src/helpers/Iterator/index.ts +13 -0
- package/src/helpers/Iterator/types.ts +79 -0
- package/src/helpers/Wrapper/component.tsx +78 -0
- package/src/helpers/Wrapper/constants.ts +10 -0
- package/src/helpers/Wrapper/index.ts +3 -0
- package/src/helpers/Wrapper/styled.ts +69 -0
- package/src/helpers/Wrapper/types.ts +56 -0
- package/src/helpers/Wrapper/utils.ts +7 -0
- package/src/helpers/index.ts +4 -0
- package/src/index.ts +37 -0
- package/src/types.ts +81 -0
- 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
|