@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.
|
|
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}
|
|
@@ -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
|
|