@uniweb/kit 0.9.43 → 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.43",
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}
@@ -41,10 +41,43 @@ const DEFAULT_FUSE_OPTIONS = {
41
41
  ],
42
42
  threshold: 0.35,
43
43
  includeMatches: true,
44
+ // Exposed so ranking is inspectable, and so a foundation that wants its own
45
+ // ordering has something to order by. Fuse omits `score` entirely without it,
46
+ // which is why nothing could be ranked on relevance before.
47
+ includeScore: true,
44
48
  ignoreLocation: true,
45
49
  minMatchCharLength: 2
46
50
  }
47
51
 
52
+ /**
53
+ * Does this entry literally contain every word of the query?
54
+ *
55
+ * Fuse is approximate by design, and on fields the size of a whole page that
56
+ * turns into a real problem: searching "inset" on a documentation site
57
+ * returned 68 hits of which 4 contained the word, and all four ranked below
58
+ * the tenth result — so every result the reader saw was a near-miss on a page
59
+ * that never mentions the term.
60
+ *
61
+ * Fuzzy is the right FALLBACK — it is what tolerates a typo — but it must not
62
+ * outrank an exact match. Checking containment directly is the cheap, honest
63
+ * signal Fuse's score does not provide here.
64
+ *
65
+ * Every token must appear, so "Inset Components" does not match a page that
66
+ * merely says "components". Matching is substring-based rather than
67
+ * word-boundary so that "inset" still finds "insets".
68
+ */
69
+ function literalTier(item, tokens) {
70
+ if (!tokens.length) return 2
71
+
72
+ const title = `${item.title || ''} ${item.pageTitle || ''}`.toLowerCase()
73
+ if (tokens.every((t) => title.includes(t))) return 0
74
+
75
+ const body = `${title} ${item.content || ''} ${item.excerpt || ''}`.toLowerCase()
76
+ if (tokens.every((t) => body.includes(t))) return 1
77
+
78
+ return 2
79
+ }
80
+
48
81
  /**
49
82
  * Get localStorage safely (handles SSR and access errors)
50
83
  * @returns {Storage|null}
@@ -292,6 +325,23 @@ export function createIndexProvider(website, options = {}) {
292
325
  results = results.filter(({ item }) => item.route?.startsWith(route))
293
326
  }
294
327
 
328
+ // Rank pages that actually contain the words above Fuse's near-misses.
329
+ //
330
+ // Fuse sorts by its own score, which on page-sized content fields rates
331
+ // an approximate match as highly as an exact one — so a page containing
332
+ // the search term could sit below ten pages that never mention it, and
333
+ // the reader concludes the site has nothing on the subject.
334
+ //
335
+ // A stable sort by tier keeps Fuse's ordering *within* each tier, so
336
+ // relevance still decides among equals and the fuzzy tail is preserved
337
+ // rather than discarded. Applied before `limit`, because the cutoff is
338
+ // exactly where the problem showed up.
339
+ const tokens = String(text || '').toLowerCase().split(/\s+/).filter(Boolean)
340
+ results = results
341
+ .map((r, i) => ({ r, i, tier: literalTier(r.item, tokens) }))
342
+ .sort((a, b) => a.tier - b.tier || a.i - b.i)
343
+ .map(({ r }) => r)
344
+
295
345
  return results.slice(0, limit).map(({ item, matches }) => {
296
346
  const snippet = buildSnippet(item.content, matches, { key: 'content' })
297
347