@uniweb/kit 0.9.44 → 0.9.45
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 +3 -1
- package/src/components/Overlay/Overlay.jsx +279 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.45",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
"react-dom": "^19.0.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
+
"@testing-library/react": "^16.3.2",
|
|
52
|
+
"jsdom": "^25.0.1",
|
|
51
53
|
"tailwindcss": "^4.0.0",
|
|
52
54
|
"vitest": "^4.1.7"
|
|
53
55
|
},
|
|
@@ -26,87 +26,320 @@
|
|
|
26
26
|
* overlay competes in the root stacking context where its z-index means what
|
|
27
27
|
* the author expects.
|
|
28
28
|
*
|
|
29
|
-
* ##
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
29
|
+
* ## What it owns
|
|
30
|
+
*
|
|
31
|
+
* The undifferentiated half of every overlay: the portal, the scrim, Escape, a
|
|
32
|
+
* click outside, the page-scroll lock, **focus containment**, and the stacking
|
|
33
|
+
* of one overlay over another. How the thing looks — its box, its placement,
|
|
34
|
+
* its animation — is the foundation's design, the same way kit ships no layout.
|
|
35
|
+
*
|
|
36
|
+
* Focus containment is not optional decoration. `aria-modal="true"` tells
|
|
37
|
+
* assistive technology that everything outside the dialog is unreachable; if
|
|
38
|
+
* focus can Tab out, that is simply false, and a keyboard user lands on
|
|
39
|
+
* controls a screen-reader user has been told do not exist. So a modal overlay
|
|
40
|
+
* traps focus, moves focus in on open, marks the rest of the page `inert`, and
|
|
41
|
+
* restores focus to whatever opened it — by default, without being asked.
|
|
42
|
+
*
|
|
43
|
+
* ## Modal and non-modal
|
|
44
|
+
*
|
|
45
|
+
* `modal` (default `true`) is the master switch, because the cluster it
|
|
46
|
+
* governs only makes sense together: scrim, focus trap, scroll lock, inert
|
|
47
|
+
* background. A dialog, command palette, drawer or lightbox wants all of it.
|
|
48
|
+
*
|
|
49
|
+
* `modal={false}` is the other real case — a toast, a notification, a
|
|
50
|
+
* non-blocking hint. It portals out of the stacking context and does nothing
|
|
51
|
+
* else: no scrim, no trap, no scroll lock, and pointer events pass through the
|
|
52
|
+
* layer so the page underneath stays usable. Individual props still override.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* // Dialog — a dimmed scrim comes with `modal`, so this adds only placement.
|
|
56
|
+
* {isOpen && (
|
|
57
|
+
* <Overlay onClose={close} className="items-center">
|
|
58
|
+
* <div role="dialog" aria-modal="true" aria-labelledby="t">…</div>
|
|
59
|
+
* </Overlay>
|
|
60
|
+
* )}
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // A themed scrim, blurred, with the content near the top. The default is a
|
|
64
|
+
* // starting point, not a constraint.
|
|
65
|
+
* <Overlay onClose={close} className="bg-primary/20 backdrop-blur-sm pt-[12vh]">…</Overlay>
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* // Toast — above the page, but the page keeps working
|
|
69
|
+
* <Overlay modal={false} className="items-end justify-end p-6">
|
|
70
|
+
* <div className="pointer-events-auto rounded bg-card p-4">Saved</div>
|
|
71
|
+
* </Overlay>
|
|
41
72
|
*/
|
|
42
73
|
|
|
43
|
-
import { useEffect } from 'react'
|
|
74
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
44
75
|
import { createPortal } from 'react-dom'
|
|
45
76
|
import { cn } from '../../utils/index.js'
|
|
46
77
|
|
|
78
|
+
/**
|
|
79
|
+
* What counts as focusable. `:not([inert])` matters because this component
|
|
80
|
+
* marks background content inert — without it, a trap could hand focus to an
|
|
81
|
+
* element it has just declared unreachable.
|
|
82
|
+
*/
|
|
83
|
+
const FOCUSABLE = [
|
|
84
|
+
'a[href]',
|
|
85
|
+
'button:not([disabled])',
|
|
86
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
87
|
+
'select:not([disabled])',
|
|
88
|
+
'textarea:not([disabled])',
|
|
89
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
90
|
+
'audio[controls]',
|
|
91
|
+
'video[controls]',
|
|
92
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
93
|
+
].map((s) => `${s}:not([inert]):not([aria-hidden="true"])`).join(',')
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The open modal overlays, outermost first.
|
|
97
|
+
*
|
|
98
|
+
* Nesting is ordinary — a confirm dialog over a settings dialog, a lightbox
|
|
99
|
+
* over a palette — and the pieces that must not fight are Escape (only the
|
|
100
|
+
* top one closes), the scroll lock (the inner one closing must not unlock the
|
|
101
|
+
* page while the outer is still open) and the inert background (likewise).
|
|
102
|
+
* A stack is the smallest thing that gets all three right.
|
|
103
|
+
*/
|
|
104
|
+
const modalStack = []
|
|
105
|
+
|
|
106
|
+
function isTopmost(node) {
|
|
107
|
+
return modalStack.length > 0 && modalStack[modalStack.length - 1] === node
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function focusableWithin(root) {
|
|
111
|
+
if (!root) return []
|
|
112
|
+
return Array.from(root.querySelectorAll(FOCUSABLE)).filter(isVisible)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Skip elements that are present but not actually reachable.
|
|
117
|
+
*
|
|
118
|
+
* `checkVisibility()` is the browser's own answer and accounts for
|
|
119
|
+
* `display: none`, `content-visibility` and a `hidden` ancestor. Where it does
|
|
120
|
+
* not exist the element is INCLUDED rather than excluded: a trap that wrongly
|
|
121
|
+
* drops a control leaves focus with nowhere to go, which is worse than one
|
|
122
|
+
* that wrongly keeps a hidden control in the cycle.
|
|
123
|
+
*
|
|
124
|
+
* An earlier version tested `offsetParent !== null`, which is a layout
|
|
125
|
+
* property — always `null` under jsdom, and null in a browser for anything
|
|
126
|
+
* inside a `display: none` subtree *or* positioned in ways that have nothing
|
|
127
|
+
* to do with visibility. It emptied the focusable list outright.
|
|
128
|
+
*/
|
|
129
|
+
function isVisible(el) {
|
|
130
|
+
if (el.hidden) return false
|
|
131
|
+
return typeof el.checkVisibility === 'function' ? el.checkVisibility() : true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Resolve `initialFocus` — a ref, a selector, or nothing. */
|
|
135
|
+
function resolveInitialFocus(initialFocus, root) {
|
|
136
|
+
if (initialFocus === false) return null
|
|
137
|
+
if (initialFocus && typeof initialFocus === 'object' && 'current' in initialFocus) {
|
|
138
|
+
return initialFocus.current || null
|
|
139
|
+
}
|
|
140
|
+
if (typeof initialFocus === 'string') return root?.querySelector(initialFocus) || null
|
|
141
|
+
return focusableWithin(root)[0] || root || null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Mark everything except this overlay unreachable while a modal is open.
|
|
146
|
+
*
|
|
147
|
+
* This is what makes `aria-modal` true rather than merely asserted: the focus
|
|
148
|
+
* trap stops Tab, and `inert` stops the rest — screen-reader virtual cursors,
|
|
149
|
+
* clicks, find-in-page. Applied to body children rather than a single wrapper
|
|
150
|
+
* because other portals (including a lower overlay) are body children too.
|
|
151
|
+
*
|
|
152
|
+
* Returns an undo function; only the outermost modal applies it, so unwinding
|
|
153
|
+
* an inner overlay never un-inerts the page underneath an outer one.
|
|
154
|
+
*/
|
|
155
|
+
function inertBackground(exceptNode) {
|
|
156
|
+
const changed = []
|
|
157
|
+
for (const child of Array.from(document.body.children)) {
|
|
158
|
+
// Set the ATTRIBUTE, not the IDL property. Both work in a browser, but the
|
|
159
|
+
// attribute is also observable where the property is not implemented, and
|
|
160
|
+
// it is what an author sees when inspecting the page.
|
|
161
|
+
if (child === exceptNode || child.hasAttribute('inert')) continue
|
|
162
|
+
child.setAttribute('inert', '')
|
|
163
|
+
changed.push(child)
|
|
164
|
+
}
|
|
165
|
+
return () => changed.forEach((el) => el.removeAttribute('inert'))
|
|
166
|
+
}
|
|
167
|
+
|
|
47
168
|
/**
|
|
48
169
|
* @param {object} props
|
|
49
|
-
* @param {React.ReactNode} props.children - The overlay content
|
|
170
|
+
* @param {React.ReactNode} props.children - The overlay content.
|
|
50
171
|
* @param {Function} [props.onClose] - Called on Escape and on a scrim click.
|
|
51
|
-
* Omit for
|
|
52
|
-
* @param {boolean} [props.
|
|
53
|
-
*
|
|
172
|
+
* Omit for an overlay that dismisses some other way.
|
|
173
|
+
* @param {boolean} [props.modal=true] - Blocks the page: scrim, focus trap,
|
|
174
|
+
* scroll lock, inert background. `false` for a toast or other non-blocking
|
|
175
|
+
* layer that only needs to escape the stacking context.
|
|
176
|
+
* @param {string} [props.className] - Classes for the scrim / positioning
|
|
177
|
+
* layer, applied last. `cn` resolves Tailwind conflicts, so anything here
|
|
178
|
+
* overrides the defaults rather than fighting them: `bg-primary/10` recolours
|
|
179
|
+
* the scrim (a theme token works here as readily as a fixed one),
|
|
180
|
+
* `bg-transparent` removes it, `items-center` re-places the content,
|
|
181
|
+
* `backdrop-blur-sm` adds to it. The box itself is yours.
|
|
182
|
+
* @param {number|string} [props.zIndex=100]
|
|
54
183
|
* @param {boolean} [props.closeOnEscape=true]
|
|
55
184
|
* @param {boolean} [props.closeOnScrimClick=true]
|
|
56
|
-
* @param {
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* @param {
|
|
185
|
+
* @param {boolean} [props.lockScroll] - Defaults to `modal`.
|
|
186
|
+
* @param {boolean} [props.trapFocus] - Defaults to `modal`. Turning it off on
|
|
187
|
+
* a modal overlay makes `aria-modal` a false claim; prefer `modal={false}`.
|
|
188
|
+
* @param {React.RefObject|string|false} [props.initialFocus] - What to focus
|
|
189
|
+
* on open: a ref, a selector, or `false` to leave focus alone. Defaults to
|
|
190
|
+
* the first focusable element, falling back to the layer itself.
|
|
191
|
+
* @param {boolean} [props.returnFocus=true] - Restore focus to whatever was
|
|
192
|
+
* focused before opening — usually the control that opened it.
|
|
60
193
|
*/
|
|
61
194
|
export function Overlay({
|
|
62
195
|
children,
|
|
63
196
|
onClose,
|
|
64
|
-
|
|
65
|
-
closeOnEscape = true,
|
|
66
|
-
closeOnScrimClick = true,
|
|
197
|
+
modal = true,
|
|
67
198
|
className,
|
|
68
199
|
zIndex = 100,
|
|
200
|
+
closeOnEscape = true,
|
|
201
|
+
closeOnScrimClick = true,
|
|
202
|
+
lockScroll,
|
|
203
|
+
trapFocus,
|
|
204
|
+
initialFocus,
|
|
205
|
+
returnFocus = true,
|
|
69
206
|
...rest
|
|
70
207
|
}) {
|
|
208
|
+
const layerRef = useRef(null)
|
|
209
|
+
const shouldLockScroll = lockScroll ?? modal
|
|
210
|
+
const shouldTrapFocus = trapFocus ?? modal
|
|
211
|
+
|
|
212
|
+
// Read through a ref so an inline `onClose={() => setOpen(false)}` — the
|
|
213
|
+
// natural call — does not tear the listener down and re-add it every render.
|
|
214
|
+
const onCloseRef = useRef(onClose)
|
|
215
|
+
onCloseRef.current = onClose
|
|
216
|
+
|
|
217
|
+
// ── Stack membership ──────────────────────────────────────────────
|
|
71
218
|
useEffect(() => {
|
|
72
|
-
if (!
|
|
219
|
+
if (!modal) return
|
|
220
|
+
const node = layerRef.current
|
|
221
|
+
modalStack.push(node)
|
|
222
|
+
return () => {
|
|
223
|
+
const i = modalStack.indexOf(node)
|
|
224
|
+
if (i !== -1) modalStack.splice(i, 1)
|
|
225
|
+
}
|
|
226
|
+
}, [modal])
|
|
227
|
+
|
|
228
|
+
// ── Escape ────────────────────────────────────────────────────────
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (!closeOnEscape) return
|
|
73
231
|
const onKeyDown = (event) => {
|
|
74
|
-
if (event.key
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
232
|
+
if (event.key !== 'Escape') return
|
|
233
|
+
// Only the topmost overlay closes: Escape inside a confirm dialog must
|
|
234
|
+
// not also dismiss the dialog behind it.
|
|
235
|
+
if (modal && !isTopmost(layerRef.current)) return
|
|
236
|
+
if (typeof onCloseRef.current !== 'function') return
|
|
237
|
+
event.preventDefault()
|
|
238
|
+
onCloseRef.current(event)
|
|
78
239
|
}
|
|
79
|
-
// Capture phase:
|
|
80
|
-
// and Escape still has to close the thing it is typed into.
|
|
240
|
+
// Capture phase: an input inside the overlay may stop propagation on
|
|
241
|
+
// keydown, and Escape still has to close the thing it is typed into.
|
|
81
242
|
document.addEventListener('keydown', onKeyDown, true)
|
|
82
243
|
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
83
|
-
}, [closeOnEscape,
|
|
244
|
+
}, [closeOnEscape, modal])
|
|
84
245
|
|
|
246
|
+
// ── Scroll lock ───────────────────────────────────────────────────
|
|
85
247
|
useEffect(() => {
|
|
86
|
-
if (!
|
|
87
|
-
// Restore the previous value rather than clearing
|
|
88
|
-
//
|
|
248
|
+
if (!shouldLockScroll) return
|
|
249
|
+
// Restore the previous value rather than clearing, so an inner overlay
|
|
250
|
+
// closing does not unlock the page while an outer one is still open.
|
|
89
251
|
const previous = document.body.style.overflow
|
|
90
252
|
document.body.style.overflow = 'hidden'
|
|
253
|
+
return () => { document.body.style.overflow = previous }
|
|
254
|
+
}, [shouldLockScroll])
|
|
255
|
+
|
|
256
|
+
// ── Focus: move in, contain, restore ──────────────────────────────
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
if (!shouldTrapFocus) return
|
|
259
|
+
const layer = layerRef.current
|
|
260
|
+
if (!layer) return
|
|
261
|
+
|
|
262
|
+
const previouslyFocused = document.activeElement
|
|
263
|
+
const undoInert = modalStack.length <= 1 ? inertBackground(layer) : null
|
|
264
|
+
|
|
265
|
+
const target = resolveInitialFocus(initialFocus, layer)
|
|
266
|
+
target?.focus?.({ preventScroll: true })
|
|
267
|
+
|
|
268
|
+
const onKeyDown = (event) => {
|
|
269
|
+
if (event.key !== 'Tab') return
|
|
270
|
+
if (!isTopmost(layer)) return
|
|
271
|
+
|
|
272
|
+
const items = focusableWithin(layer)
|
|
273
|
+
if (items.length === 0) {
|
|
274
|
+
// Nothing to move to, but focus must not leave either.
|
|
275
|
+
event.preventDefault()
|
|
276
|
+
layer.focus?.({ preventScroll: true })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const first = items[0]
|
|
281
|
+
const last = items[items.length - 1]
|
|
282
|
+
const active = document.activeElement
|
|
283
|
+
|
|
284
|
+
// Wrap at both ends, and pull focus back if it has escaped the overlay
|
|
285
|
+
// entirely (a click on the page behind, a programmatic focus call).
|
|
286
|
+
if (event.shiftKey && (active === first || !layer.contains(active))) {
|
|
287
|
+
event.preventDefault()
|
|
288
|
+
last.focus({ preventScroll: true })
|
|
289
|
+
} else if (!event.shiftKey && (active === last || !layer.contains(active))) {
|
|
290
|
+
event.preventDefault()
|
|
291
|
+
first.focus({ preventScroll: true })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
91
296
|
return () => {
|
|
92
|
-
document.
|
|
297
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
298
|
+
undoInert?.()
|
|
299
|
+
// Back to whatever opened it — usually the trigger, which is where a
|
|
300
|
+
// keyboard user expects to be when the dialog goes away.
|
|
301
|
+
if (returnFocus && previouslyFocused?.focus) {
|
|
302
|
+
previouslyFocused.focus({ preventScroll: true })
|
|
303
|
+
}
|
|
93
304
|
}
|
|
94
|
-
|
|
305
|
+
// `initialFocus` is read once on open by design: re-running this would
|
|
306
|
+
// yank focus out from under the user mid-interaction.
|
|
307
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
308
|
+
}, [shouldTrapFocus, returnFocus])
|
|
95
309
|
|
|
96
|
-
// No DOM: prerender and any Node render. An overlay is by
|
|
97
|
-
//
|
|
98
|
-
//
|
|
310
|
+
// No DOM: prerender, and any Node render. An overlay is interactive by
|
|
311
|
+
// definition, so there is nothing meaningful to emit into static HTML — and
|
|
312
|
+
// emitting it would put a scrim over the prerendered page.
|
|
99
313
|
if (typeof document === 'undefined') return null
|
|
100
314
|
|
|
315
|
+
const handleScrimClick =
|
|
316
|
+
modal && closeOnScrimClick && onClose
|
|
317
|
+
? (event) => {
|
|
318
|
+
// Only a click on the scrim itself. One that bubbled out of the
|
|
319
|
+
// content would close on every stray click inside, and would fire
|
|
320
|
+
// when a text selection happens to end outside the box.
|
|
321
|
+
if (event.target === event.currentTarget) onClose(event)
|
|
322
|
+
}
|
|
323
|
+
: undefined
|
|
324
|
+
|
|
101
325
|
return createPortal(
|
|
102
326
|
<div
|
|
103
|
-
|
|
327
|
+
ref={layerRef}
|
|
328
|
+
tabIndex={-1}
|
|
329
|
+
className={cn(
|
|
330
|
+
'fixed inset-0 flex items-start justify-center',
|
|
331
|
+
// kit-palette-ok: a modal scrim is the same black in either scheme —
|
|
332
|
+
// it dims the page rather than colouring a surface, so a theme token
|
|
333
|
+
// would be the wrong tool. Same call as Disclaimer's scrim.
|
|
334
|
+
modal ? 'bg-black/50' : 'pointer-events-none',
|
|
335
|
+
// Last, so a foundation's classes win: `cn` resolves Tailwind
|
|
336
|
+
// conflicts, which makes every one of these a plain override —
|
|
337
|
+
// `bg-primary/10` recolours it, `bg-transparent` removes it,
|
|
338
|
+
// `items-center` re-places the content, `backdrop-blur-sm` adds to it.
|
|
339
|
+
className
|
|
340
|
+
)}
|
|
104
341
|
style={{ zIndex }}
|
|
105
|
-
onClick={
|
|
106
|
-
// Only a click on the scrim itself, never one that bubbled out of the
|
|
107
|
-
// dialog — otherwise selecting text and releasing outside closes it.
|
|
108
|
-
if (e.target === e.currentTarget) onClose(e)
|
|
109
|
-
} : undefined}
|
|
342
|
+
onClick={handleScrimClick}
|
|
110
343
|
{...rest}
|
|
111
344
|
>
|
|
112
345
|
{children}
|