@uniweb/kit 0.10.19 → 0.10.21

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.10.19",
3
+ "version": "0.10.21",
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/core": "0.8.2",
47
- "@uniweb/scene": "0.1.2",
48
- "@uniweb/semantic-parser": "1.2.1"
46
+ "@uniweb/semantic-parser": "1.2.1",
47
+ "@uniweb/scene": "0.1.3",
48
+ "@uniweb/core": "0.8.2"
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
  // ============================================================================