@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.41",
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.31",
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
- const STORAGE_VERSION = 'v1'
26
- const STORAGE_PREFIX = `uniweb:search:${STORAGE_VERSION}:`
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(`${STORAGE_PREFIX}${cacheKey}`)
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(`${STORAGE_PREFIX}${cacheKey}`, JSON.stringify(payload))
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
- // Check memory cache first
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
- // Check localStorage cache
113
- if (useStorage) {
114
- const cached = loadFromStorage(cacheKey)
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
- saveToStorage(cacheKey, payload)
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(`${STORAGE_PREFIX}${cacheKey}`)
221
+ storage.removeItem(STORAGE_KEY(cacheKey))
149
222
  }
150
223
  } else {
151
224
  indexCache.clear()