@uniweb/kit 0.10.20 → 0.10.22

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/README.md CHANGED
@@ -476,9 +476,15 @@ const client = createSearchClient(website, {
476
476
  defaultLimit: 10
477
477
  })
478
478
 
479
- // Query
479
+ // Query — returns SearchResult[]
480
480
  const results = await client.query('authentication', { limit: 5 })
481
481
 
482
+ // Same query, plus how many matched before `limit` — the 47 in "showing 10 of 47".
483
+ // `total` is null when the active provider cannot say (a deployment fact, not an
484
+ // error): the local index always knows it; an endpoint knows it only if it
485
+ // reports one. Render the count conditionally, the results unconditionally.
486
+ const { results: page, total } = await client.queryWithTotal('authentication', { limit: 5 })
487
+
482
488
  // Preload index
483
489
  await client.preload()
484
490
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.10.20",
3
+ "version": "0.10.22",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -43,9 +43,9 @@
43
43
  "fuse.js": "^7.0.0",
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
- "@uniweb/scene": "0.1.3",
46
+ "@uniweb/core": "0.8.2",
47
47
  "@uniweb/semantic-parser": "1.2.1",
48
- "@uniweb/core": "0.8.2"
48
+ "@uniweb/scene": "0.1.3"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^19.0.0",
@@ -37,3 +37,4 @@ export {
37
37
 
38
38
  // Form submission lifecycle for foundation Form components
39
39
  export { useFormSubmit } from './useFormSubmit.js'
40
+ export { useFormValues, valueAt } from './useFormValues.js'
@@ -0,0 +1,214 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Hold the values of a form an AUTHOR designed, so a foundation only writes the
5
+ * part that is actually its own — the controls.
6
+ *
7
+ * An authored form arrives as content (a ```` ```yaml:form ```` block at
8
+ * `content.data.form`), which makes a form-rendering component the inverse of
9
+ * every other one: it does not declare the fields, it receives them and draws
10
+ * whatever it is given. Everything between "receive a list of controls" and
11
+ * "call submit" is then identical in every such component — seeding defaults,
12
+ * tracking edits, spotting what is still empty, keeping files out of the JSON.
13
+ * That is undifferentiated boilerplate, and it is what this owns.
14
+ *
15
+ * What it deliberately does NOT own is the rendering. Which control a `type`
16
+ * maps to, how it looks, how an error reads — that is the foundation's design
17
+ * and its whole reason for existing. Same split as `useCollectionQueryable`:
18
+ * the kit hands over the metadata and the state, the foundation builds the
19
+ * controls against them.
20
+ *
21
+ * ```jsx
22
+ * const { controls, values, setValue, missing, formData, files } =
23
+ * useFormValues(content.data.form)
24
+ * const { submit, canSubmit, status } = useFormSubmit({ block })
25
+ *
26
+ * {controls.map((c) => (
27
+ * <MyControl key={c.path} control={c}
28
+ * value={valueAt(values, c.path)}
29
+ * onChange={(v) => setValue(c.path, v)} />
30
+ * ))}
31
+ *
32
+ * <button disabled={!canSubmit || missing.length > 0 || status === 'submitting'}
33
+ * onClick={() => submit(formData, { files })}>Send</button>
34
+ * ```
35
+ *
36
+ * ## Three returned shapes, because they are three different things
37
+ *
38
+ * `values` is what the UI binds to and holds whatever was set, `File` objects
39
+ * included, so a file input can show its selection. `formData` is what you
40
+ * submit. They differ for one reason that would otherwise be a silent data-
41
+ * shaped failure: `submitForm` sends `formData` through `JSON.stringify`, and a
42
+ * `File` serializes to `{}` — the attachment would appear to have been sent and
43
+ * would arrive empty. So file controls are **omitted from `formData`** and ride
44
+ * in `files` instead, each tagged with the control it came from. That tag is
45
+ * the `{ file, field }` shape `submitForm` accepts precisely so a form with two
46
+ * file inputs can say which is which; hand-rolled callers pass bare `File`s and
47
+ * silently lose the attribution.
48
+ *
49
+ * ## `missing` is computed, not enforced
50
+ *
51
+ * It lists the paths of `required` controls that are still empty. It does not
52
+ * block anything: whether an incomplete form disables the button, shows a
53
+ * message, or submits anyway is a design decision. `useFormSubmit` draws the
54
+ * same line with `canSubmit` / `unavailableReason` — the kit works out the
55
+ * fact, the foundation decides what it looks like.
56
+ *
57
+ * Empty means `undefined`, `null`, `''`, or `[]`. A `false` boolean is a VALUE,
58
+ * so a required checkbox that is unchecked is not "missing" — "must be ticked"
59
+ * is a stronger rule than `required` and belongs to the component that knows it
60
+ * is a consent box.
61
+ *
62
+ * ## Both authored shapes
63
+ *
64
+ * Accepts a list of controls, and also the older map keyed by control name —
65
+ * during a transition both exist in content, and a hook that handled one would
66
+ * be unusable with the other. A map is normalized to a list, taking each key as
67
+ * the control's `name`.
68
+ *
69
+ * @param {Array<object>|object} definition — `content.data.form`
70
+ * @returns {{
71
+ * controls: Array<object>,
72
+ * values: object,
73
+ * setValue: (path: string, value: unknown) => void,
74
+ * reset: () => void,
75
+ * missing: string[],
76
+ * formData: object,
77
+ * files: Array<{ file: File, field: string }>,
78
+ * }}
79
+ */
80
+ export function useFormValues(definition) {
81
+ const controls = useMemo(() => flatten(normalize(definition)), [definition])
82
+ const initial = useMemo(() => seed(controls), [controls])
83
+
84
+ const [values, setValues] = useState(initial)
85
+
86
+ // Re-seed when the DEFINITION changes, not on every render. An author editing
87
+ // the form in the visual app changes it under a mounted component, and values
88
+ // keyed to controls that no longer exist would linger in the payload.
89
+ const seededFrom = useRef(initial)
90
+ useEffect(() => {
91
+ if (seededFrom.current !== initial) {
92
+ seededFrom.current = initial
93
+ setValues(initial)
94
+ }
95
+ }, [initial])
96
+
97
+ const setValue = useCallback((path, value) => {
98
+ setValues((prev) => setIn(prev, String(path).split('.'), value))
99
+ }, [])
100
+
101
+ const reset = useCallback(() => setValues(seededFrom.current), [])
102
+
103
+ const missing = useMemo(
104
+ () => controls.filter((c) => c.required && isEmpty(valueAt(values, c.path))).map((c) => c.path),
105
+ [controls, values],
106
+ )
107
+
108
+ const { formData, files } = useMemo(() => split(controls, values), [controls, values])
109
+
110
+ return { controls, values, setValue, reset, missing, formData, files }
111
+ }
112
+
113
+ /**
114
+ * Read a value out of the nested `values` by dotted path — the companion of
115
+ * `setValue`, exported because a component rendering a control needs it and
116
+ * would otherwise write the same three lines.
117
+ */
118
+ export function valueAt(values, path) {
119
+ return String(path)
120
+ .split('.')
121
+ .reduce((node, key) => (node == null ? undefined : node[key]), values)
122
+ }
123
+
124
+ // --- internals ---------------------------------------------------------------
125
+
126
+ // A list as authored, or the older map keyed by control name.
127
+ function normalize(definition) {
128
+ if (Array.isArray(definition)) return definition.filter(isRecord)
129
+ if (isRecord(definition)) {
130
+ return Object.entries(definition)
131
+ .filter(([, spec]) => isRecord(spec))
132
+ .map(([name, spec]) => ({ name, ...spec }))
133
+ }
134
+ return []
135
+ }
136
+
137
+ /**
138
+ * Depth-first list of every control, each carrying the dotted `path` its value
139
+ * lives at. A container (`children`) contributes a nested object rather than a
140
+ * value of its own, which is why `group` is the author-facing spelling of
141
+ * `object`: a fieldset's answers nest exactly as the type says they do.
142
+ */
143
+ function flatten(list, prefix = '') {
144
+ const out = []
145
+ for (const control of list) {
146
+ const name = control?.name
147
+ if (typeof name !== 'string' || !name) continue // unaddressable — cannot hold a value
148
+ const path = prefix ? `${prefix}.${name}` : name
149
+ const children = Array.isArray(control.children) ? control.children : null
150
+ out.push({ ...control, path, isGroup: !!children })
151
+ if (children) out.push(...flatten(children, path))
152
+ }
153
+ return out
154
+ }
155
+
156
+ function seed(controls) {
157
+ let out = {}
158
+ for (const control of controls) {
159
+ if (control.isGroup) continue // its shape comes from its children
160
+ if (control.default === undefined) continue
161
+ out = setIn(out, control.path.split('.'), control.default)
162
+ }
163
+ return out
164
+ }
165
+
166
+ // Immutable nested set; creates the intermediate objects a group needs.
167
+ function setIn(node, [key, ...rest], value) {
168
+ const base = isRecord(node) ? node : {}
169
+ if (rest.length === 0) return { ...base, [key]: value }
170
+ return { ...base, [key]: setIn(base[key], rest, value) }
171
+ }
172
+
173
+ /**
174
+ * Split the held values into what is submitted and what is uploaded.
175
+ *
176
+ * File controls are omitted from `formData` rather than serialized: they would
177
+ * become `{}` and report an attachment nobody received. Their attribution is
178
+ * carried on each file entry's `field`, which is what the endpoint reads.
179
+ */
180
+ function split(controls, values) {
181
+ const files = []
182
+ let formData = {}
183
+
184
+ for (const control of controls) {
185
+ if (control.isGroup) continue
186
+ const value = valueAt(values, control.path)
187
+ if (value === undefined) continue
188
+
189
+ if (control.type === 'file') {
190
+ for (const file of [].concat(value).filter(isFile)) {
191
+ files.push({ file, field: control.path })
192
+ }
193
+ continue
194
+ }
195
+ formData = setIn(formData, control.path.split('.'), value)
196
+ }
197
+
198
+ return { formData, files }
199
+ }
200
+
201
+ function isEmpty(value) {
202
+ if (value === undefined || value === null || value === '') return true
203
+ return Array.isArray(value) && value.length === 0
204
+ }
205
+
206
+ function isFile(value) {
207
+ // Duck-typed rather than `instanceof File`: this runs under SSR and in tests
208
+ // where the constructor may not exist, and `submitForm` checks the same way.
209
+ return !!value && typeof value === 'object' && 'name' in value && 'size' in value
210
+ }
211
+
212
+ function isRecord(value) {
213
+ return !!value && typeof value === 'object' && !Array.isArray(value)
214
+ }
package/src/index.js CHANGED
@@ -89,7 +89,11 @@ export {
89
89
  formatShortcut,
90
90
  isApplePlatform,
91
91
  // Form submission lifecycle for foundation Form components
92
- useFormSubmit
92
+ useFormSubmit,
93
+ // The state of an AUTHORED form — seeds defaults, tracks edits, keeps Files
94
+ // out of the JSON payload. The foundation writes the controls and nothing else.
95
+ useFormValues,
96
+ valueAt
93
97
  } from './hooks/index.js'
94
98
 
95
99
  // ============================================================================
@@ -84,6 +84,25 @@ async function loadProviderFactory(name, transports) {
84
84
  * const search = createSearchClient(website)
85
85
  * const results = await search.query('authentication')
86
86
  */
87
+ /**
88
+ * Accept either provider return shape.
89
+ *
90
+ * A provider may return `SearchResult[]` or `{ results, total }`. Both are
91
+ * valid and the array form is not deprecated: `transport.query` is a public
92
+ * seam a third party implements, and its documented contract has always been
93
+ * "returns results". Widening that to an envelope would break every custom
94
+ * transport in the field to add a number most of them cannot supply.
95
+ *
96
+ * An array therefore means "no count offered" → `total: null`, which is exactly
97
+ * what null is for here. Our own two providers return the envelope.
98
+ */
99
+ function normalizeQueryResult(returned) {
100
+ if (Array.isArray(returned)) return { results: returned, total: null }
101
+ const results = Array.isArray(returned?.results) ? returned.results : []
102
+ const total = Number.isInteger(returned?.total) ? returned.total : null
103
+ return { results, total }
104
+ }
105
+
87
106
  export function createSearchClient(website, options = {}) {
88
107
  const {
89
108
  defaultLimit = 10,
@@ -207,7 +226,7 @@ export function createSearchClient(website, options = {}) {
207
226
  },
208
227
 
209
228
  /**
210
- * Perform a search query
229
+ * Perform a search query.
211
230
  *
212
231
  * @param {string} query - Search query
213
232
  * @param {Object} queryOptions - Query options
@@ -218,21 +237,47 @@ export function createSearchClient(website, options = {}) {
218
237
  * @returns {Promise<Array>} Search results
219
238
  */
220
239
  async query(query, queryOptions = {}) {
240
+ return (await this.queryWithTotal(query, queryOptions)).results
241
+ },
242
+
243
+ /**
244
+ * The same query, plus how many matched.
245
+ *
246
+ * Exists because `query()` returns an array and a count cannot ride on one
247
+ * without being silently dropped by `.slice()`/`.filter()`/spread — and
248
+ * `query()` is a published surface returning `SearchResult[]`, so widening
249
+ * its return would break every foundation iterating it.
250
+ *
251
+ * **`total` is the match count BEFORE `limit`** — the 47 in "showing 10 of
252
+ * 47". It is `null` when unknowable, and null is a real answer rather than
253
+ * a failure: a provider-optional field in the same sense as `matches` or
254
+ * `item` on a result. Whether a count arrives is a DEPLOYMENT fact — the
255
+ * local index always knows it, an endpoint knows it only if it says so —
256
+ * so a UI must render the count conditionally and the bare result list
257
+ * unconditionally.
258
+ *
259
+ * ⚠️ `total >= results.length` is NOT guaranteed to be checkable: a
260
+ * provider reporting a count for a set we then filtered locally reports
261
+ * null instead, precisely so the two numbers are never inconsistent.
262
+ *
263
+ * @returns {Promise<{results: Array, total: number|null}>}
264
+ */
265
+ async queryWithTotal(query, queryOptions = {}) {
221
266
  const { limit = defaultLimit, type, route, signal } = queryOptions
222
267
 
223
268
  const trimmed = query?.trim()
224
- if (!trimmed) return []
269
+ if (!trimmed) return { results: [], total: 0 }
225
270
 
226
271
  if (!website.isSearchEnabled()) {
227
272
  console.warn('Search is not enabled for this site')
228
- return []
273
+ return { results: [], total: 0 }
229
274
  }
230
275
 
231
276
  const opts = { limit, type, route, signal }
232
277
 
233
278
  try {
234
279
  const provider = await getProvider()
235
- return await provider.query(trimmed, opts)
280
+ return normalizeQueryResult(await provider.query(trimmed, opts))
236
281
  } catch (err) {
237
282
  // An aborted query is a caller decision, not a provider failure.
238
283
  if (err?.name === 'AbortError') throw err
@@ -240,13 +285,13 @@ export function createSearchClient(website, options = {}) {
240
285
  const fallback = await fallbackToIndex(err)
241
286
  if (!fallback) {
242
287
  console.warn(`[uniweb] Search failed: ${err?.message}`)
243
- return []
288
+ return { results: [], total: null }
244
289
  }
245
290
  try {
246
- return await fallback.query(trimmed, opts)
291
+ return normalizeQueryResult(await fallback.query(trimmed, opts))
247
292
  } catch (fallbackErr) {
248
293
  console.warn(`[uniweb] Search fallback failed: ${fallbackErr?.message}`)
249
- return []
294
+ return { results: [], total: null }
250
295
  }
251
296
  }
252
297
  },
@@ -17,12 +17,13 @@ import { useShortcut } from '../hooks/useShortcut.js'
17
17
  *
18
18
  * @example
19
19
  * function SearchComponent() {
20
- * const { query, results, isLoading, error } = useSearch(website)
20
+ * const { query, results, total, isLoading, error } = useSearch(website)
21
21
  *
22
22
  * return (
23
23
  * <div>
24
24
  * <input onChange={e => query(e.target.value)} />
25
25
  * {isLoading && <span>Searching...</span>}
26
+ * {total !== null && <span>Showing {results.length} of {total}</span>}
26
27
  * {results.map(r => <SearchResult key={r.id} result={r} />)}
27
28
  * </div>
28
29
  * )
@@ -32,6 +33,7 @@ export function useSearch(website, options = {}) {
32
33
  const { debounceMs = 150, ...clientOptions } = options
33
34
 
34
35
  const [results, setResults] = useState([])
36
+ const [total, setTotal] = useState(null)
35
37
  const [isLoading, setIsLoading] = useState(false)
36
38
  const [error, setError] = useState(null)
37
39
  const [lastQuery, setLastQuery] = useState('')
@@ -71,6 +73,7 @@ export function useSearch(website, options = {}) {
71
73
  // Empty query - clear results immediately
72
74
  if (!trimmed) {
73
75
  setResults([])
76
+ setTotal(0)
74
77
  setIsLoading(false)
75
78
  setError(null)
76
79
  return []
@@ -99,7 +102,8 @@ export function useSearch(website, options = {}) {
99
102
  setError(null)
100
103
 
101
104
  try {
102
- const searchResults = await client.query(trimmed, queryOptions)
105
+ const { results: searchResults, total: matchTotal } =
106
+ await client.queryWithTotal(trimmed, queryOptions)
103
107
 
104
108
  // Skip if a newer search was started
105
109
  if (pendingRef.current !== searchId) {
@@ -108,7 +112,10 @@ export function useSearch(website, options = {}) {
108
112
  }
109
113
 
110
114
  setResults(searchResults)
115
+ setTotal(matchTotal)
111
116
  setIsLoading(false)
117
+ // Resolves with the ARRAY, unchanged — this hook's promise is a
118
+ // published surface and the count is available as state instead.
112
119
  resolve(searchResults)
113
120
  } catch (err) {
114
121
  // Skip if a newer search was started
@@ -119,6 +126,7 @@ export function useSearch(website, options = {}) {
119
126
 
120
127
  setError(err)
121
128
  setResults([])
129
+ setTotal(null)
122
130
  setIsLoading(false)
123
131
  resolve([])
124
132
  }
@@ -136,6 +144,7 @@ export function useSearch(website, options = {}) {
136
144
  }
137
145
  pendingRef.current = null
138
146
  setResults([])
147
+ setTotal(null)
139
148
  setLastQuery('')
140
149
  setError(null)
141
150
  setIsLoading(false)
@@ -156,6 +165,10 @@ export function useSearch(website, options = {}) {
156
165
  return {
157
166
  // State
158
167
  results,
168
+ // How many matched before `limit` — the 47 in "showing 10 of 47".
169
+ // `null` means the active provider cannot say, which is a normal state and
170
+ // not an error: render the count conditionally, the results always.
171
+ total,
159
172
  isLoading,
160
173
  error,
161
174
  lastQuery,
@@ -149,7 +149,8 @@ export function createEndpointProvider(website, options = {}) {
149
149
  )
150
150
  }
151
151
 
152
- const results = extractResults(await response.json()).map(normalize)
152
+ const payload = await response.json()
153
+ const results = extractResults(payload).map(normalize)
153
154
 
154
155
  // Filters are applied client-side because they are not part of the wire
155
156
  // contract a third-party endpoint is expected to honor. A server that
@@ -158,7 +159,23 @@ export function createEndpointProvider(website, options = {}) {
158
159
  if (type) filtered = filtered.filter(r => r.type === type)
159
160
  if (route) filtered = filtered.filter(r => r.route?.startsWith(route))
160
161
 
161
- return filtered.slice(0, limit)
162
+ // How many matched, when the endpoint says. Null when it does not, and
163
+ // null is a real answer — see `total` in client.js.
164
+ //
165
+ // ⚠️ Discarded when a local filter removed anything, because then the
166
+ // server counted a DIFFERENT set: it does not know about `type`/`route`,
167
+ // so its number describes matches we just narrowed away. Reporting it
168
+ // would render "showing 3 of 47" beside a filter that produced the 3 —
169
+ // a number that is not wrong about anything the reader can see, which is
170
+ // the worst kind. We cannot recompute it either: what arrived was already
171
+ // capped at `limit`, so the filtered count is a floor, not a total.
172
+ const narrowed = filtered.length !== results.length
173
+ const stated = Number.isInteger(payload?.total) ? payload.total : null
174
+
175
+ return {
176
+ results: filtered.slice(0, limit),
177
+ total: narrowed ? null : stated
178
+ }
162
179
  },
163
180
 
164
181
  // Nothing to warm: there is no index to download. Defined so every provider
@@ -342,7 +342,13 @@ export function createIndexProvider(website, options = {}) {
342
342
  .sort((a, b) => a.tier - b.tier || a.i - b.i)
343
343
  .map(({ r }) => r)
344
344
 
345
- return results.slice(0, limit).map(({ item, matches }) => {
345
+ // Exact here, unlike the endpoint provider: the whole corpus is local, so
346
+ // this counts every match after filtering and before the cut — the number
347
+ // a "showing 10 of 47" needs, and 47 is knowable only on this side of the
348
+ // slice.
349
+ const total = results.length
350
+
351
+ const page = results.slice(0, limit).map(({ item, matches }) => {
346
352
  const snippet = buildSnippet(item.content, matches, { key: 'content' })
347
353
 
348
354
  return {
@@ -363,6 +369,8 @@ export function createIndexProvider(website, options = {}) {
363
369
  matches
364
370
  }
365
371
  })
372
+
373
+ return { results: page, total }
366
374
  },
367
375
 
368
376
  async preload() {