@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.9.44",
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
- * ## Usage
30
- *
31
- * {isOpen && (
32
- * <Overlay onClose={close}>
33
- * <div role="dialog" aria-modal="true">…</div>
34
- * </Overlay>
35
- * )}
36
- *
37
- * Owns only what every overlay needs and nothing about how it looks: the
38
- * portal, the scrim, Escape, a click outside, and the page-scroll lock. The
39
- * dialog itself its shape, its focus handling, its content is the
40
- * foundation's design, the same way kit ships no layout.
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 (your dialog).
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 a non-dismissible overlay.
52
- * @param {boolean} [props.lockScroll=true] - Prevent the page behind from
53
- * scrolling while open.
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 {string} [props.className] - Classes for the scrim/positioning layer.
57
- * Defaults place the content centred near the top, the usual command-palette
58
- * position; pass your own to change it.
59
- * @param {number|string} [props.zIndex=100]
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
- lockScroll = true,
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 (!closeOnEscape || !onClose) return
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 === 'Escape') {
75
- event.preventDefault()
76
- onClose(event)
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: a dialog's own input may stop propagation on keydown,
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, onClose])
244
+ }, [closeOnEscape, modal])
84
245
 
246
+ // ── Scroll lock ───────────────────────────────────────────────────
85
247
  useEffect(() => {
86
- if (!lockScroll) return
87
- // Restore the previous value rather than clearing: two stacked overlays
88
- // would otherwise have the inner one unlock the page on close.
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.body.style.overflow = previous
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
- }, [lockScroll])
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 definition
97
- // interactive, so there is nothing meaningful to emit into static HTML —
98
- // and emitting it would put a scrim over the prerendered page.
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
- className={cn('fixed inset-0 flex items-start justify-center', className)}
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={closeOnScrimClick && onClose ? (e) => {
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}