@uniweb/kit 0.9.41 → 0.9.43
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.43",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"fuse.js": "^7.0.0",
|
|
41
41
|
"shiki": "^3.0.0",
|
|
42
42
|
"tailwind-merge": "^3.6.0",
|
|
43
|
-
"@uniweb/core": "0.7.
|
|
43
|
+
"@uniweb/core": "0.7.33",
|
|
44
44
|
"@uniweb/scene": "0.1.2"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay — render above the page, from anywhere in the tree.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists (it is not "a portal wrapper")
|
|
5
|
+
*
|
|
6
|
+
* The runtime gives every layout area its own `view-transition-name` so the
|
|
7
|
+
* browser can animate header, sidebars and body independently instead of
|
|
8
|
+
* crossfading the whole page. That is on by default.
|
|
9
|
+
*
|
|
10
|
+
* A `view-transition-name` makes the element **a stacking context** and **a
|
|
11
|
+
* containing block for fixed-position descendants**. Both consequences bite
|
|
12
|
+
* the most ordinary thing a foundation builds: a modal opened from the header.
|
|
13
|
+
*
|
|
14
|
+
* div[view-transition-name: uw-header] ← area wrapper, z-index: auto
|
|
15
|
+
* └ div.fixed.inset-0.z-[100] ← your modal
|
|
16
|
+
* div[view-transition-name: uw-body] ← sibling area, z-index: auto
|
|
17
|
+
*
|
|
18
|
+
* The modal's `z-index: 100` is scoped *inside* `uw-header`. Both wrappers sit
|
|
19
|
+
* at `auto` in the root stacking context, so they paint in DOM order — and the
|
|
20
|
+
* body comes second. No z-index a foundation can write will lift the modal
|
|
21
|
+
* above the page content, because the two are not competing in the same
|
|
22
|
+
* stacking context. Raising the number looks like it should work and never
|
|
23
|
+
* does, which is what makes this worth a component rather than a doc note.
|
|
24
|
+
*
|
|
25
|
+
* Rendering into `document.body` leaves every area wrapper behind, so the
|
|
26
|
+
* overlay competes in the root stacking context where its z-index means what
|
|
27
|
+
* the author expects.
|
|
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.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { useEffect } from 'react'
|
|
44
|
+
import { createPortal } from 'react-dom'
|
|
45
|
+
import { cn } from '../../utils/index.js'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {object} props
|
|
49
|
+
* @param {React.ReactNode} props.children - The overlay content (your dialog).
|
|
50
|
+
* @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.
|
|
54
|
+
* @param {boolean} [props.closeOnEscape=true]
|
|
55
|
+
* @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]
|
|
60
|
+
*/
|
|
61
|
+
export function Overlay({
|
|
62
|
+
children,
|
|
63
|
+
onClose,
|
|
64
|
+
lockScroll = true,
|
|
65
|
+
closeOnEscape = true,
|
|
66
|
+
closeOnScrimClick = true,
|
|
67
|
+
className,
|
|
68
|
+
zIndex = 100,
|
|
69
|
+
...rest
|
|
70
|
+
}) {
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!closeOnEscape || !onClose) return
|
|
73
|
+
const onKeyDown = (event) => {
|
|
74
|
+
if (event.key === 'Escape') {
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
onClose(event)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
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.
|
|
81
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
82
|
+
return () => document.removeEventListener('keydown', onKeyDown, true)
|
|
83
|
+
}, [closeOnEscape, onClose])
|
|
84
|
+
|
|
85
|
+
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.
|
|
89
|
+
const previous = document.body.style.overflow
|
|
90
|
+
document.body.style.overflow = 'hidden'
|
|
91
|
+
return () => {
|
|
92
|
+
document.body.style.overflow = previous
|
|
93
|
+
}
|
|
94
|
+
}, [lockScroll])
|
|
95
|
+
|
|
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.
|
|
99
|
+
if (typeof document === 'undefined') return null
|
|
100
|
+
|
|
101
|
+
return createPortal(
|
|
102
|
+
<div
|
|
103
|
+
className={cn('fixed inset-0 flex items-start justify-center', className)}
|
|
104
|
+
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}
|
|
110
|
+
{...rest}
|
|
111
|
+
>
|
|
112
|
+
{children}
|
|
113
|
+
</div>,
|
|
114
|
+
document.body
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default Overlay
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Overlay, default } from './Overlay.jsx'
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// Navigation
|
|
16
16
|
export { Link } from './components/Link/index.js'
|
|
17
17
|
export { Image } from './components/Image/index.js'
|
|
18
|
+
// Renders above the page from anywhere in the tree — see Overlay.jsx for why
|
|
19
|
+
// a plain fixed+z-index element cannot escape a layout area.
|
|
20
|
+
export { Overlay } from './components/Overlay/index.js'
|
|
18
21
|
export { SafeHtml } from './components/SafeHtml/index.js'
|
|
19
22
|
export { Icon } from './components/Icon/index.js'
|
|
20
23
|
|
|
@@ -22,8 +22,12 @@ import { emptyResult } from './result.js'
|
|
|
22
22
|
const indexCache = new Map()
|
|
23
23
|
const fuseCache = new Map()
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Bumped to v2 when stored entries gained a validator. The version is part of
|
|
26
|
+
// the key, so every v1 entry — which had no way to be revalidated and could
|
|
27
|
+
// outlive any number of rebuilds — is orphaned rather than trusted.
|
|
28
|
+
const STORAGE_VERSION = 'v2'
|
|
29
|
+
const STORAGE_PREFIX = 'uniweb:search:'
|
|
30
|
+
const STORAGE_KEY = (cacheKey) => `${STORAGE_PREFIX}${STORAGE_VERSION}:${cacheKey}`
|
|
27
31
|
|
|
28
32
|
/**
|
|
29
33
|
* Default Fuse.js options optimized for site search
|
|
@@ -63,14 +67,12 @@ function loadFromStorage(cacheKey) {
|
|
|
63
67
|
const storage = getStorage()
|
|
64
68
|
if (!storage) return null
|
|
65
69
|
|
|
66
|
-
const raw = storage.getItem(
|
|
70
|
+
const raw = storage.getItem(STORAGE_KEY(cacheKey))
|
|
67
71
|
if (!raw) return null
|
|
68
72
|
|
|
69
73
|
try {
|
|
70
74
|
const parsed = JSON.parse(raw)
|
|
71
|
-
if (Array.isArray(parsed.entries))
|
|
72
|
-
return parsed
|
|
73
|
-
}
|
|
75
|
+
if (Array.isArray(parsed.payload?.entries)) return parsed
|
|
74
76
|
return null
|
|
75
77
|
} catch {
|
|
76
78
|
return null
|
|
@@ -78,21 +80,52 @@ function loadFromStorage(cacheKey) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
* Save index to localStorage
|
|
83
|
+
* Save index to localStorage, together with the validator that lets a later
|
|
84
|
+
* load ask the server whether it is still current.
|
|
85
|
+
*
|
|
82
86
|
* @param {string} cacheKey - Cache key
|
|
83
87
|
* @param {Object} payload - Index data
|
|
88
|
+
* @param {{etag?: string|null, lastModified?: string|null}} [validator]
|
|
84
89
|
*/
|
|
85
|
-
function saveToStorage(cacheKey, payload) {
|
|
90
|
+
function saveToStorage(cacheKey, payload, validator = {}) {
|
|
86
91
|
const storage = getStorage()
|
|
87
92
|
if (!storage) return
|
|
88
93
|
|
|
89
94
|
try {
|
|
90
|
-
storage.setItem(
|
|
95
|
+
storage.setItem(
|
|
96
|
+
STORAGE_KEY(cacheKey),
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
payload,
|
|
99
|
+
etag: validator.etag || null,
|
|
100
|
+
lastModified: validator.lastModified || null,
|
|
101
|
+
})
|
|
102
|
+
)
|
|
91
103
|
} catch {
|
|
92
104
|
// Ignore quota errors
|
|
93
105
|
}
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Drop entries written by an older storage schema.
|
|
110
|
+
*
|
|
111
|
+
* A search index runs to hundreds of kilobytes, so an orphaned one is a real
|
|
112
|
+
* bite out of a origin's storage quota rather than a tidiness issue.
|
|
113
|
+
*/
|
|
114
|
+
function pruneOldVersions() {
|
|
115
|
+
const storage = getStorage()
|
|
116
|
+
if (!storage) return
|
|
117
|
+
|
|
118
|
+
const current = `${STORAGE_PREFIX}${STORAGE_VERSION}:`
|
|
119
|
+
const stale = []
|
|
120
|
+
for (let i = 0; i < storage.length; i++) {
|
|
121
|
+
const key = storage.key(i)
|
|
122
|
+
if (key?.startsWith(STORAGE_PREFIX) && !key.startsWith(current)) stale.push(key)
|
|
123
|
+
}
|
|
124
|
+
stale.forEach((key) => {
|
|
125
|
+
try { storage.removeItem(key) } catch { /* ignore */ }
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
/**
|
|
97
130
|
* Load search index for a locale
|
|
98
131
|
* @param {string} indexUrl - URL to fetch the index from
|
|
@@ -104,32 +137,72 @@ function saveToStorage(cacheKey, payload) {
|
|
|
104
137
|
export async function loadSearchIndex(indexUrl, options = {}) {
|
|
105
138
|
const { cacheKey = indexUrl, useStorage = true } = options
|
|
106
139
|
|
|
107
|
-
//
|
|
140
|
+
// Memory cache: scoped to one page load, where the index cannot change
|
|
141
|
+
// underneath us. This is the only cache read that needs no validation.
|
|
108
142
|
if (indexCache.has(cacheKey)) {
|
|
109
143
|
return indexCache.get(cacheKey)
|
|
110
144
|
}
|
|
111
145
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
146
|
+
if (useStorage) pruneOldVersions()
|
|
147
|
+
const cached = useStorage ? loadFromStorage(cacheKey) : null
|
|
148
|
+
|
|
149
|
+
// ALWAYS ask the server, even holding a stored copy.
|
|
150
|
+
//
|
|
151
|
+
// Returning the stored index unconditionally — what this did before — has no
|
|
152
|
+
// expiry and no way to notice a rebuild, so a visitor who searched once kept
|
|
153
|
+
// answering from that index for as long as the entry survived. A redeploy
|
|
154
|
+
// did not dislodge it.
|
|
155
|
+
//
|
|
156
|
+
// It also collided across sites. The key is the index URL, which is
|
|
157
|
+
// `/search-index.json` for every Uniweb project, and localStorage is scoped
|
|
158
|
+
// to an origin — so two projects sharing a dev port shared one entry, and a
|
|
159
|
+
// search on one could return the other's pages. Revalidating settles that
|
|
160
|
+
// too: a different server answers 200 with its own index and replaces it.
|
|
161
|
+
const headers = {}
|
|
162
|
+
if (cached?.etag) headers['If-None-Match'] = cached.etag
|
|
163
|
+
else if (cached?.lastModified) headers['If-Modified-Since'] = cached.lastModified
|
|
164
|
+
|
|
165
|
+
let response
|
|
166
|
+
try {
|
|
167
|
+
// `no-cache` = revalidate, not "don't cache". `force-cache` (the previous
|
|
168
|
+
// value) told the browser to prefer any stored response regardless of age,
|
|
169
|
+
// which defeated revalidation a second time over.
|
|
170
|
+
response = await fetch(indexUrl, { headers, cache: 'no-cache' })
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// Offline or unreachable. A stored index is better than no search at all,
|
|
173
|
+
// and this is the one path where serving it unvalidated is the right call.
|
|
115
174
|
if (cached) {
|
|
116
|
-
indexCache.set(cacheKey, cached)
|
|
117
|
-
return cached
|
|
175
|
+
indexCache.set(cacheKey, cached.payload)
|
|
176
|
+
return cached.payload
|
|
118
177
|
}
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Unchanged since we stored it — the whole point of keeping the validator.
|
|
182
|
+
if (response.status === 304 && cached) {
|
|
183
|
+
indexCache.set(cacheKey, cached.payload)
|
|
184
|
+
return cached.payload
|
|
119
185
|
}
|
|
120
186
|
|
|
121
|
-
// Fetch from server
|
|
122
|
-
const response = await fetch(indexUrl, { cache: 'force-cache' })
|
|
123
187
|
if (!response.ok) {
|
|
188
|
+
if (cached) {
|
|
189
|
+
indexCache.set(cacheKey, cached.payload)
|
|
190
|
+
return cached.payload
|
|
191
|
+
}
|
|
124
192
|
throw new Error(`Failed to load search index: ${response.status}`)
|
|
125
193
|
}
|
|
126
194
|
|
|
127
195
|
const payload = await response.json()
|
|
128
196
|
|
|
129
|
-
// Cache the result
|
|
130
197
|
indexCache.set(cacheKey, payload)
|
|
131
198
|
if (useStorage) {
|
|
132
|
-
|
|
199
|
+
// A host that sends no validator still gets stored — it just costs a full
|
|
200
|
+
// fetch next time instead of a 304. What it never does is get served as
|
|
201
|
+
// though it were known to be current.
|
|
202
|
+
saveToStorage(cacheKey, payload, {
|
|
203
|
+
etag: response.headers?.get?.('etag'),
|
|
204
|
+
lastModified: response.headers?.get?.('last-modified'),
|
|
205
|
+
})
|
|
133
206
|
}
|
|
134
207
|
|
|
135
208
|
return payload
|
|
@@ -145,7 +218,7 @@ export function clearSearchCache(cacheKey) {
|
|
|
145
218
|
fuseCache.delete(cacheKey)
|
|
146
219
|
const storage = getStorage()
|
|
147
220
|
if (storage) {
|
|
148
|
-
storage.removeItem(
|
|
221
|
+
storage.removeItem(STORAGE_KEY(cacheKey))
|
|
149
222
|
}
|
|
150
223
|
} else {
|
|
151
224
|
indexCache.clear()
|