vultus-core 0.5.2 → 0.5.4

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.
Files changed (57) hide show
  1. package/README.md +176 -4
  2. package/dist/FieldContext.d.ts +37 -1
  3. package/dist/FieldContext.d.ts.map +1 -1
  4. package/dist/FieldContext.js +68 -11
  5. package/dist/FieldContext.js.map +1 -1
  6. package/dist/field.d.ts +12 -1
  7. package/dist/field.d.ts.map +1 -1
  8. package/dist/field.js +6 -1
  9. package/dist/field.js.map +1 -1
  10. package/dist/fieldGroupValues.d.ts +63 -0
  11. package/dist/fieldGroupValues.d.ts.map +1 -0
  12. package/dist/fieldGroupValues.js +119 -0
  13. package/dist/fieldGroupValues.js.map +1 -0
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +3 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/kindHooks.d.ts +23 -0
  19. package/dist/kindHooks.d.ts.map +1 -0
  20. package/dist/kindHooks.js +38 -0
  21. package/dist/kindHooks.js.map +1 -0
  22. package/dist/kinds.d.ts +19 -14
  23. package/dist/kinds.d.ts.map +1 -1
  24. package/dist/kinds.js +40 -14
  25. package/dist/kinds.js.map +1 -1
  26. package/dist/tree.d.ts +17 -0
  27. package/dist/tree.d.ts.map +1 -1
  28. package/dist/tree.js +27 -0
  29. package/dist/tree.js.map +1 -1
  30. package/dist/useArg.d.ts +6 -0
  31. package/dist/useArg.d.ts.map +1 -1
  32. package/dist/useArg.js +6 -2
  33. package/dist/useArg.js.map +1 -1
  34. package/dist/useFieldGroup.d.ts +80 -0
  35. package/dist/useFieldGroup.d.ts.map +1 -0
  36. package/dist/useFieldGroup.js +266 -0
  37. package/dist/useFieldGroup.js.map +1 -0
  38. package/dist/useOneOfTreeField.d.ts +79 -0
  39. package/dist/useOneOfTreeField.d.ts.map +1 -0
  40. package/dist/useOneOfTreeField.js +117 -0
  41. package/dist/useOneOfTreeField.js.map +1 -0
  42. package/dist/useStoredField.d.ts +9 -0
  43. package/dist/useStoredField.d.ts.map +1 -1
  44. package/dist/useStoredField.js +7 -5
  45. package/dist/useStoredField.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/FieldContext.tsx +73 -11
  48. package/src/field.ts +25 -5
  49. package/src/fieldGroupValues.ts +169 -0
  50. package/src/index.ts +7 -1
  51. package/src/kindHooks.ts +52 -0
  52. package/src/kinds.ts +38 -14
  53. package/src/tree.ts +34 -0
  54. package/src/useArg.ts +6 -2
  55. package/src/useFieldGroup.ts +346 -0
  56. package/src/useOneOfTreeField.ts +182 -0
  57. package/src/useStoredField.ts +10 -6
package/src/kinds.ts CHANGED
@@ -3,13 +3,14 @@ import type { FieldBaseOptions, FieldKind } from './field.js';
3
3
  import type { FieldCtx } from './FieldContext.js';
4
4
  import { clamp, fit, resolveNumeric } from './numeric.js';
5
5
  import { resolveIn } from './resolve.js';
6
- import { normalizeTreeValue, type TreeNode } from './tree.js';
6
+ import { choosableValues, normalizeTreeValue, type TreeNode } from './tree.js';
7
7
  import type { Fits, ValueOrFn } from './types.js';
8
8
  import { boolInitialValue, type useBoolField } from './useBoolField.js';
9
9
  import { manyOfInitialValue, type ManyOfOwnOptions, type useManyOfField } from './useManyOfField.js';
10
10
  import { mixedBoolInitialValue, type MixedBool, type useMixedBoolField } from './useMixedBoolField.js';
11
11
  import { numericInitialValue, type NumericOwnOptions, type useNumericField } from './useNumericField.js';
12
12
  import { oneOfInitialValue, type OneOfOwnOptions, type useOneOfField } from './useOneOfField.js';
13
+ import { oneOfTreeInitialValue, type OneOfTreeOwnOptions, type useOneOfTreeField } from './useOneOfTreeField.js';
13
14
  import { rangeInitialValue, type NumberRange, type RangeOwnOptions, type useRangeField } from './useRangeField.js';
14
15
  import { textInitialValue, type TextOwnOptions, type useTextField } from './useTextField.js';
15
16
  import { manyOfTreeInitialValue, type ManyOfTreeOwnOptions, type useManyOfTreeField } from './useManyOfTreeField.js';
@@ -36,6 +37,7 @@ export type FieldDefinition<Ctx = any> =
36
37
  | Definition<'numeric', number, NumericOwnOptions<Ctx>, Ctx>
37
38
  | Definition<'range', NumberRange, RangeOwnOptions<Ctx>, Ctx>
38
39
  | Definition<'manyOfTree', string[], ManyOfTreeOwnOptions<string, Ctx>, Ctx>
40
+ | Definition<'oneOfTree', string, OneOfTreeOwnOptions<string, Ctx>, Ctx>
39
41
  | Definition<'text', string, TextOwnOptions, Ctx>;
40
42
 
41
43
  type DefinitionOf<K extends FieldKind> = Extract<FieldDefinition, { kind: K }>;
@@ -53,6 +55,7 @@ type DefinitionsFitTheirHooks = [
53
55
  Fits<DefinitionOf<'numeric'>, HookOptions<typeof useNumericField>>,
54
56
  Fits<DefinitionOf<'range'>, HookOptions<typeof useRangeField>>,
55
57
  Fits<DefinitionOf<'manyOfTree'>, HookOptions<typeof useManyOfTreeField>>,
58
+ Fits<DefinitionOf<'oneOfTree'>, HookOptions<typeof useOneOfTreeField>>,
56
59
  Fits<DefinitionOf<'text'>, HookOptions<typeof useTextField>>,
57
60
  ];
58
61
 
@@ -87,10 +90,11 @@ function nodesOf(definition: { id: string; nodes: ValueOrFn<TreeNode[]> }, ctx:
87
90
  /**
88
91
  * A definition's default: `initialValue` resolved with the field context, else its kind's
89
92
  * implicit initial value (false; the first enabled choice; the empty list; `min`, or 0, clamped
90
- * into the bounds; `[min, max]`; the empty string). A tree's comes normalized under its
93
+ * into the bounds; `[min, max]`; the empty string). A many-of tree's comes normalized under its
91
94
  * selection, as the hook shows it, so a value equal to what the field shows by default is equal
92
- * to the default too. It is what a store falls back to, what a reset restores, and what "is it
93
- * set?" compares against; the kind's hook starts at the same value.
95
+ * to the default too; a one-of tree's is `''` when `optional`, else the first node it can choose
96
+ * in pre-order, else `''` (none can be chosen). It is what a store falls back to, what a reset
97
+ * restores, and what "is it set?" compares against; the kind's hook starts at the same value.
94
98
  */
95
99
  export function initialValueOf(definition: FieldDefinition, ctx: FieldCtx): unknown {
96
100
  const { id } = definition;
@@ -103,6 +107,7 @@ export function initialValueOf(definition: FieldDefinition, ctx: FieldCtx): unkn
103
107
  case 'range': return rangeInitialValue(definition, ctx, id);
104
108
  case 'manyOfTree':
105
109
  return normalizeTreeValue(nodesOf(definition, ctx), manyOfTreeInitialValue(definition, ctx, id), definition.selection ?? 'topmost');
110
+ case 'oneOfTree': return oneOfTreeInitialValue(definition, ctx, id, nodesOf(definition, ctx));
106
111
  case 'text': return textInitialValue(definition, ctx, id);
107
112
  default: return unknownKind(definition);
108
113
  }
@@ -113,6 +118,7 @@ export function valueTypeOf(kind: FieldKind): ValueType<any> {
113
118
  switch (kind) {
114
119
  case 'boolean': return booleanType;
115
120
  case 'oneOf':
121
+ case 'oneOfTree':
116
122
  case 'text': return stringType;
117
123
  case 'manyOf':
118
124
  case 'manyOfTree': return stringSetType;
@@ -132,16 +138,19 @@ function warnDropped(id: string, among: 'choices' | 'nodes', dropped: string[],
132
138
  /**
133
139
  * A stored value (already parsed by the kind's value type) made fit for the definition as it is
134
140
  * now: choices, nodes and bounds follow the data, and an old URL may not. A one-of value that is
135
- * not a choice becomes the initial value; many-of and tree values that are not choices or nodes
136
- * are dropped; numbers and ranges are clamped into the bounds, then rounded when the field is
137
- * `integer` (so a stored `2.5` reads `3`; a whole number inside the bounds, see `fit`), with one
138
- * warning for whichever of the two actually changed the value: "outside its bounds" when
139
- * clamping did (whether or not rounding also did), "not a whole number" ("has ends that are not
140
- * whole numbers" for a range) when only rounding did. Each such change warns, naming the
141
- * field: the value came from outside, so it is not an error. Reordering is silent: lists come
142
- * back in choice order or normalized under the tree's selection (a store keeps them sorted, so a
143
- * warning would come on every read), and a range's ends in order. The warnings go to `warn`,
144
- * console.warn unless a caller that only summarizes (useStoredFields) silences them.
141
+ * not a choice becomes the initial value; many-of values that are not choices or nodes are
142
+ * dropped. A one-of tree value is replaced by the initial value too, when it names no node, when
143
+ * the node it names cannot be chosen (a folder, or one a context change disabled), or when it is
144
+ * `''` and the field is not optional. Numbers and ranges are clamped into the bounds, then
145
+ * rounded when the field is `integer` (so a stored `2.5` reads `3`; a whole number inside the
146
+ * bounds, see `fit`), with one warning for whichever of the two actually changed the value:
147
+ * "outside its bounds" when clamping did (whether or not rounding also did), "not a whole
148
+ * number" ("has ends that are not whole numbers" for a range) when only rounding did. Each such
149
+ * change warns, naming the field: the value came from outside, so it is not an error. Reordering
150
+ * is silent: lists come back in choice order or normalized under the tree's selection (a store
151
+ * keeps them sorted, so a warning would come on every read), and a range's ends in order. The
152
+ * warnings go to `warn`, console.warn unless a caller that only summarizes (useStoredFields)
153
+ * silences them.
145
154
  */
146
155
  export function sanitizeStored(
147
156
  definition: FieldDefinition,
@@ -176,6 +185,21 @@ export function sanitizeStored(
176
185
  warnDropped(id, 'nodes', stored.filter((v) => !known.has(v)), warn);
177
186
  return normalizeTreeValue(nodes, stored.filter((v) => known.has(v)), definition.selection ?? 'topmost');
178
187
  }
188
+ case 'oneOfTree': {
189
+ const stored = value as string;
190
+ const nodes = nodesOf(definition, ctx);
191
+ if (stored === '') {
192
+ if (definition.optional ?? false) return stored;
193
+ const fallback = oneOfTreeInitialValue(definition, ctx, id, nodes);
194
+ if (fallback !== stored) warn(`Field '${id}': stored value '' is empty but the field is not optional, using '${fallback}'`);
195
+ return fallback;
196
+ }
197
+ if (choosableValues(nodes, definition.choose ?? 'any').includes(stored)) return stored;
198
+ const fallback = oneOfTreeInitialValue(definition, ctx, id, nodes);
199
+ const reason = nodeValues(nodes).has(stored) ? 'cannot be chosen' : 'names no node';
200
+ if (fallback !== stored) warn(`Field '${id}': stored value '${stored}' ${reason}, using '${fallback}'`);
201
+ return fallback;
202
+ }
179
203
  case 'numeric': {
180
204
  const stored = value as number;
181
205
  const { min, max, integer } = resolveNumeric(id, definition, ctx);
package/src/tree.ts CHANGED
@@ -5,6 +5,8 @@ import { normalizeText } from './normalizeText.js';
5
5
  /** A node of a tree field (Praeceptor's categories): a choice with children. */
6
6
  export interface TreeNode<T extends string = string> extends OneOfChoice<T> {
7
7
  children?: TreeNode<T>[];
8
+ /** Whether the one-of tree may choose this node (default true); a folder when false. Ignored by the many-of tree. */
9
+ choosable?: boolean;
8
10
  }
9
11
 
10
12
  /** A node as the widgets get it: the resolved choice, its children likewise, and whether it shows checked. */
@@ -224,3 +226,35 @@ export function filterTree<N extends { label?: string; value: string; children?:
224
226
  });
225
227
  return keep(nodes, false);
226
228
  }
229
+
230
+ /**
231
+ * Which nodes the one-of tree field may choose: `'any'` node, or only `'leaves'` (a node with
232
+ * no children).
233
+ */
234
+ export type TreeChoose = 'any' | 'leaves';
235
+
236
+ /**
237
+ * Whether the one-of tree field may choose `node` under `choose` (see TreeChoose): choosable
238
+ * (its `choosable` is not `false`), enabled (as nodeEnabled resolves it), and, under `'leaves'`,
239
+ * has no children.
240
+ */
241
+ export function canChooseNode(node: TreeNode, choose: TreeChoose): boolean {
242
+ if (node.choosable === false) return false;
243
+ if (!nodeEnabled(node)) return false;
244
+ return choose !== 'leaves' || !hasChildren(node);
245
+ }
246
+
247
+ /** The values canChooseNode accepts, in tree pre-order. */
248
+ export function choosableValues<T extends string>(nodes: TreeNode<T>[], choose: TreeChoose): T[] {
249
+ const { order } = indexTree(nodes);
250
+ return order.filter((node) => canChooseNode(node, choose)).map((node) => node.value);
251
+ }
252
+
253
+ /** The values from the root down to `value` inclusive, in tree order; `[]` when no node has it. */
254
+ export function treePath<T extends string>(nodes: TreeNode<T>[], value: T): T[] {
255
+ const { order, parent } = indexTree(nodes);
256
+ if (!order.some((node) => node.value === value)) return [];
257
+ const path: T[] = [value];
258
+ for (let up = parent.get(value); up !== undefined; up = parent.get(up)) path.unshift(up);
259
+ return path;
260
+ }
package/src/useArg.ts CHANGED
@@ -22,8 +22,12 @@ export interface ArgControls<T> {
22
22
  hrefFor(value: T): string | undefined;
23
23
  }
24
24
 
25
- /** The raw value a write needs for `value`: `undefined` (removes the key) when it equals the default, else its formatted form. */
26
- function rawFor<T>(value: T, defaultValue: T, valueType: ValueType<T>): string | undefined {
25
+ /**
26
+ * The raw value a write needs for `value`: `undefined` (removes the key) when it equals the
27
+ * default, else its formatted form. Exported for the field group (useFieldGroup), which writes a
28
+ * store by the same rule; not part of the package's API.
29
+ */
30
+ export function rawFor<T>(value: T, defaultValue: T, valueType: ValueType<T>): string | undefined {
27
31
  return valueType.isEqual(value, defaultValue) ? undefined : valueType.format(value);
28
32
  }
29
33
 
@@ -0,0 +1,346 @@
1
+ import { useCallback, useRef, useState, useSyncExternalStore } from 'react';
2
+ import type { ArgStore, UpdateMethod } from './argStore.js';
3
+ import { useArgStore } from './ArgStoreContext.js';
4
+ import type { FieldControls } from './field.js';
5
+ import { useFieldContext, withFieldContext, type FieldCtx } from './FieldContext.js';
6
+ import { createGroupValues, type GroupValues } from './fieldGroupValues.js';
7
+ import { useKindField } from './kindHooks.js';
8
+ import { valueTypeOf, type FieldDefinition } from './kinds.js';
9
+ import { rawFor } from './useArg.js';
10
+ import { sameValue } from './useStoredField.js';
11
+ import type { ValueType } from './valueTypes.js';
12
+
13
+ /** A field group's fields as one component runs them (see useFieldGroup). */
14
+ export interface FieldGroup {
15
+ /**
16
+ * Each field's controls, exactly as its kind's hook returns them, in definition order: what the
17
+ * widgets render. Typed `FieldControls`; for a kind's own controls, cast by kind
18
+ * (`group.byId.toc as OneOfTreeFieldControls<string>`).
19
+ */
20
+ fields: FieldControls<any, any>[];
21
+ /** The same controls, by field id. */
22
+ byId: Record<string, FieldControls<any, any>>;
23
+ /**
24
+ * The fields' shown values by id (after the fallback rule), as of this render: a plain object,
25
+ * which keeps its identity until a shown value changes (an effect keyed on it runs only then).
26
+ */
27
+ values: Readonly<Record<string, unknown>>;
28
+ /** Whether the field with this id shows its current default; an id not in the group throws. */
29
+ isDefault(id: string): boolean;
30
+ /** How many of the fields do not show their defaults ("more filters (3)"). */
31
+ changedCount: number;
32
+ /**
33
+ * Every field back to its default: no field keeps a value any more. Stored, one store write
34
+ * removes every field's key (`method`, 'replace' unless told otherwise); the group's own state
35
+ * ignores `method`.
36
+ */
37
+ resetAll(method?: UpdateMethod): void;
38
+ }
39
+
40
+ /** The values the group keeps, by field id; a field without one shows its current default. */
41
+ type Kept = Readonly<Record<string, unknown>>;
42
+
43
+ const NOTHING_KEPT: Kept = {};
44
+ const WHERE = 'Field group';
45
+
46
+ function silently(_id: string, _message: string): void {}
47
+
48
+ /** A field's kept value in `kept`, or `undefined` for none (an id such as `toString` is not Object's member). */
49
+ function keptIn(kept: Kept): (id: string) => unknown {
50
+ return (id) => (Object.hasOwn(kept, id) ? kept[id] : undefined);
51
+ }
52
+
53
+ /** The ids and kinds of a list of definitions, in order: what has to stay the same across renders. */
54
+ function shapeOf(definitions: readonly FieldDefinition[]): string {
55
+ return definitions.map(({ id, kind }) => `${id} (${String(kind)})`).join(', ');
56
+ }
57
+
58
+ /** Where a group keeps its values, in the words of the error for a change of it. */
59
+ function storageOf(stored: boolean): string {
60
+ return stored ? 'the nearest ArgStore ({ stored: true })' : "the group's own state";
61
+ }
62
+
63
+ /**
64
+ * Whether a field shows its default, by useStoredField's rule: nothing is kept, or the kept
65
+ * value equals the current default, or what it shows does (a kept value that falls back to it).
66
+ */
67
+ function showsDefault(definition: FieldDefinition, kept: unknown, group: GroupValues): boolean {
68
+ if (kept === undefined) return true;
69
+ const { isEqual } = valueTypeOf(definition.kind);
70
+ const fallback = group.defaultOf(definition.id);
71
+ return isEqual(kept, fallback) || isEqual(group.valueOf(definition.id), fallback);
72
+ }
73
+
74
+ /**
75
+ * A field's default under the given kept values (the state a write applies to, not necessarily
76
+ * this render's), computed afresh and silently. A write compares with it, so a write batched
77
+ * after another field's (in one event) compares with the default that one gives.
78
+ */
79
+ function defaultUnder(
80
+ definitions: readonly FieldDefinition[], appCtx: FieldCtx, kept: (id: string) => unknown, id: string,
81
+ ): unknown {
82
+ return createGroupValues({ definitions, appCtx, kept, warn: silently }).defaultOf(id);
83
+ }
84
+
85
+ /** A field's writer and link, as its kind's hook takes them (the outside form's `onChange` and `hrefFor`). */
86
+ interface Outside {
87
+ onChange(value: unknown): void;
88
+ hrefFor?(value: unknown): string | undefined;
89
+ }
90
+
91
+ /** Where a group keeps its values (its own state, or the nearest ArgStore), as one render sees them. */
92
+ interface Keeping {
93
+ /** A field's kept value; `undefined` for none, so the field shows its current default. */
94
+ kept(id: string): unknown;
95
+ /** Where a kept value's sanitizing warnings go, with the field's id (see createGroupValues). */
96
+ warn(id: string, message: string): void;
97
+ /** A field's writer and link, given this render's values. */
98
+ outside(definition: FieldDefinition, group: GroupValues): Outside;
99
+ /** Every field back to its default (see FieldGroup). */
100
+ resetAll(method?: UpdateMethod): void;
101
+ }
102
+
103
+ /**
104
+ * The group's own state: the kept values by id. A write keeps the value unless it equals the
105
+ * field's default under the state the write applies to, so a write batched after another
106
+ * field's (in one event) compares with the default that one gives. A fallback is silent: a value
107
+ * another field made invalid is how a group behaves, not bad input. There are no links.
108
+ */
109
+ function useOwnState(definitions: readonly FieldDefinition[], appCtx: FieldCtx): Keeping {
110
+ const [kept, setKept] = useState<Kept>(NOTHING_KEPT);
111
+ const resetAll = useCallback(() => setKept((state) => (Object.keys(state).length === 0 ? state : NOTHING_KEPT)), []);
112
+
113
+ const write = ({ id, kind }: FieldDefinition) => (value: unknown) => setKept((state) => {
114
+ if (!valueTypeOf(kind).isEqual(value, defaultUnder(definitions, appCtx, keptIn(state), id))) return { ...state, [id]: value };
115
+ if (!Object.hasOwn(state, id)) return state;
116
+ const { [id]: _dropped, ...rest } = state;
117
+ return rest;
118
+ });
119
+
120
+ return { kept: keptIn(kept), warn: silently, outside: (definition) => ({ onChange: write(definition) }), resetAll };
121
+ }
122
+
123
+ /** How a field's value is kept in a store; an unknown kind throws, naming the field (as argOf does). */
124
+ function valueTypeFor({ id, kind }: FieldDefinition): ValueType<any> {
125
+ try {
126
+ return valueTypeOf(kind);
127
+ } catch (error) {
128
+ throw new Error(`Field '${id}': ${(error as Error).message}`);
129
+ }
130
+ }
131
+
132
+ /** A stored raw value as the field's value: `undefined` for an absent key, or for a raw value its kind's value type cannot read. */
133
+ function parseRaw(definition: FieldDefinition, raw: string | undefined): unknown {
134
+ return raw === undefined ? undefined : valueTypeFor(definition).parse(raw);
135
+ }
136
+
137
+ /** A field's link, with what it was made from: kept while those stay the same, so the controls' `hrefFor` keeps its identity. */
138
+ interface Link {
139
+ store: ArgStore;
140
+ defaultValue: unknown;
141
+ hrefFor(value: unknown): string | undefined;
142
+ }
143
+
144
+ /**
145
+ * The nearest ArgStore, under each field's id, as useStoredField keeps a field there, read in one
146
+ * subscription (as useStoredFields): its snapshot is the fields' raw values joined into one
147
+ * string, which changes exactly when one of them does. A raw value is parsed by the kind's value
148
+ * type; one that does not parse is no kept value, so the field shows its current default.
149
+ *
150
+ * A write carries the value's raw form, the key absent when the value equals the field's default
151
+ * as the store has it at that moment: the values computed afresh, silently, from the store's
152
+ * current raw values, which include its own writes not rendered yet (every store reads them back
153
+ * at once). So, as in the group's own state, a write batched after another field's (in one
154
+ * event) compares with the default that one gives. A link (the store's `hrefFor`, where it has
155
+ * one) is the render's: its key is absent when the value equals the default as rendered, and it
156
+ * keeps its identity while the store and that default stay the same (a tree list rebuilds its
157
+ * rows for a new one, and asks again when a link is used).
158
+ *
159
+ * A raw value warns (it does not parse, or it falls back) once, as useStoredField's does: in the
160
+ * render that first reads it, not again on a re-render, nor when another field's change makes it
161
+ * fall back later; another raw value warns again. The warnings are for raw values from elsewhere
162
+ * (the URL, another tab): the one the group itself last wrote for a field, read by the next render,
163
+ * does not warn, as a write chained after another field's in one event may fall back at once
164
+ * (`layout.setValue('rendered'); paper.setValue('letter')`, Letter offered under the old layout).
165
+ */
166
+ function useStoredValues(definitions: readonly FieldDefinition[], appCtx: FieldCtx): Keeping {
167
+ const store = useArgStore();
168
+ // The first render's ids: the shape check keeps them the same.
169
+ const [ids] = useState(() => definitions.map((definition) => definition.id));
170
+ // Called on the store, not passed detached: a store written as a class uses `this`.
171
+ const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);
172
+ const readAll = useCallback(() => JSON.stringify(ids.map((id) => store.read(id) ?? null)), [store, ids]);
173
+ const snapshot = useSyncExternalStore(subscribe, readAll);
174
+ const rawList = JSON.parse(snapshot) as (string | null)[];
175
+ const raws = new Map(ids.map((id, i) => [id, rawList[i]]));
176
+
177
+ const seenRef = useRef(new Map<string, string | null>());
178
+ /** The raw value the group itself last wrote for a field, until the next render reads the store. */
179
+ const writtenRef = useRef(new Map<string, string | undefined>());
180
+ const seen = seenRef.current;
181
+ const written = writtenRef.current;
182
+ /** The fields whose raw value this render reads first, from elsewhere than the group's own write: each may warn, once. */
183
+ const fresh = new Set<string>();
184
+ for (const id of ids) {
185
+ const raw = raws.get(id) ?? null;
186
+ if (raw !== seen.get(id) && raw !== null && raw !== written.get(id)) fresh.add(id);
187
+ }
188
+ // Every store reads its own writes back at once, so the render after a write reads it: the memory
189
+ // lasts until then, and a write undone before any render is not remembered later.
190
+ written.clear();
191
+ seenRef.current = raws;
192
+ const warn = (id: string, message: string) => {
193
+ if (fresh.delete(id)) console.warn(message);
194
+ };
195
+
196
+ const byId = new Map(definitions.map((definition) => [definition.id, definition]));
197
+ const parsed = new Map<string, unknown>();
198
+ const kept = (id: string) => {
199
+ if (!parsed.has(id)) {
200
+ const raw = raws.get(id) ?? undefined;
201
+ const value = parseRaw(byId.get(id)!, raw);
202
+ if (raw !== undefined && value === undefined) warn(id, `Argument '${id}': cannot read '${raw}', using the default`);
203
+ parsed.set(id, value);
204
+ }
205
+ return parsed.get(id);
206
+ };
207
+
208
+ /** A field's default as the store has it now: under its current raw values, its own writes not rendered yet included. */
209
+ const defaultNow = (id: string) => defaultUnder(definitions, appCtx, (key) => parseRaw(byId.get(key)!, store.read(key)), id);
210
+
211
+ const linksRef = useRef(new Map<string, Link>());
212
+ const outside = (definition: FieldDefinition, group: GroupValues): Outside => {
213
+ const { id } = definition;
214
+ const valueType = valueTypeFor(definition);
215
+ const onChange = (value: unknown) => {
216
+ const raw = rawFor(value, defaultNow(id), valueType);
217
+ written.set(id, raw);
218
+ store.write([{ key: id, raw }], 'replace');
219
+ };
220
+ if (store.hrefFor === undefined) return { onChange };
221
+ const defaultValue = group.defaultOf(id);
222
+ let link = linksRef.current.get(id);
223
+ if (link === undefined || link.store !== store || !valueType.isEqual(link.defaultValue, defaultValue)) {
224
+ link = { store, defaultValue, hrefFor: (value) => store.hrefFor?.([{ key: id, raw: rawFor(value, defaultValue, valueType) }]) };
225
+ linksRef.current.set(id, link);
226
+ }
227
+ return { onChange, hrefFor: link.hrefFor };
228
+ };
229
+
230
+ const resetAll = useCallback(
231
+ (method: UpdateMethod = 'replace') => store.write(ids.map((id) => ({ key: id, raw: undefined })), method),
232
+ [store, ids],
233
+ );
234
+
235
+ return { kept, warn, outside, resetAll };
236
+ }
237
+
238
+ /**
239
+ * Runs a list of field definitions together, as a group: every option function of each
240
+ * definition (but `initialValue`) receives `ctx.values`, the fields' shown values by id, beside
241
+ * the app's own field context, so a field's choices, bounds or visibility can follow another
242
+ * field. A field's default is its `initialValue` (which cannot read `values`), else its kind's
243
+ * implicit initial value from its options as they are now, so it moves with the fields those
244
+ * read. It throws, naming the field (and the option), for an option reading an id not in the
245
+ * group, two definitions with the same id and a mixed-boolean definition; naming the cycle for
246
+ * fields whose values depend on each other; and for an app context that already has `values`
247
+ * (`Field group: the field context already has 'values'; a field group provides it`).
248
+ *
249
+ * Storage, for the whole group, is the caller's choice. By default the group keeps the values in
250
+ * its own state, as a store keeps them: a field never set, or set to its current default, keeps
251
+ * nothing and shows its current default, which moves with the fields its options read. A kept
252
+ * value that another field's change made invalid shows the field's fallback, silently (that is
253
+ * how a group behaves, not bad input), and shows again once it is valid again; it is never
254
+ * rewritten. `resetAll()` clears every kept value (the update method is for a store; here it is
255
+ * ignored).
256
+ *
257
+ * With `{ stored: true }` the values live in the nearest ArgStore (the URL, localStorage) under
258
+ * each field's id, as useStoredField keeps a field there, all read in one subscription: an absent
259
+ * key reads as the field's current default, and a value equal to it is written as an absent key
260
+ * (`'replace'`). The default compared with is the one under the store's values at the moment of
261
+ * the write, as in the group's own state, so a write batched after another field's (in one
262
+ * event) compares with the default that one gives. The controls' `hrefFor` links a value
263
+ * through the store's `hrefFor` (`undefined` where the store has none), as rendered. The fallback
264
+ * rule is the same, and the stored value is never rewritten either, but a raw value that does not
265
+ * parse, or falls back, warns once, as useStoredField's does: an unparsable one reads as the
266
+ * current default. A raw value the group itself wrote does not warn (a write chained after
267
+ * another field's in one event may fall back at once). `resetAll(method = 'replace')` removes
268
+ * every field's key in one write.
269
+ *
270
+ * Each field runs its kind's hook with the definition in its outside form (the group's value and
271
+ * writer), with the group's context as its field context, so its controls are the kind's own,
272
+ * and a request goes through the kind's own checks before it is kept. The hooks run per field,
273
+ * so the ids and kinds, in order, are kept from the first render, and so is the storage: a list
274
+ * of another shape, or another `stored`, throws (remount the component running the group, with a
275
+ * React `key`, to swap lists). The definitions' contents may change, and new objects of the same
276
+ * shape on every render (inline literals) are fine.
277
+ *
278
+ * A field's shown value, in `values` and as passed to its kind's hook, keeps its identity while it
279
+ * stays equal (element by element, as useStoredField's does), so a list does not become a new array
280
+ * on every render; the `values` object keeps its identity until a shown value changes, so an
281
+ * effect keyed on it (one that loads data, say, and feeds the app's field context) runs only
282
+ * then. A kind's own controls may still rebuild a composite value: range's `[from, to]` pair is
283
+ * put in order and within bounds anew on every render.
284
+ */
285
+ export function useFieldGroup(definitions: readonly FieldDefinition[], options: { stored?: boolean } = {}): FieldGroup {
286
+ const stored = options.stored === true;
287
+ const appCtx = useFieldContext();
288
+
289
+ const shape = shapeOf(definitions);
290
+ const firstRef = useRef({ shape, stored });
291
+ const first = firstRef.current;
292
+ if (stored !== first.stored) {
293
+ throw new Error(`${WHERE}: the storage changed from ${storageOf(first.stored)} to ${storageOf(stored)}; a group runs `
294
+ + 'other hooks for each, so it keeps its storage across renders: to switch, remount the component running it with a React `key`');
295
+ }
296
+ if (shape !== first.shape) {
297
+ throw new Error(`${WHERE}: the fields changed from ${first.shape} to ${shape}; a group runs hooks per field, so it `
298
+ + 'keeps the same ids and kinds, in order, across renders: to swap lists, remount the component running it with a React `key`');
299
+ }
300
+
301
+ const shownRef = useRef(new Map<string, unknown>());
302
+ const valuesRef = useRef<Readonly<Record<string, unknown>> | undefined>(undefined);
303
+ // The storage is the first render's (checked above), so the hooks it runs stay the same.
304
+ const keeping = stored ? useStoredValues(definitions, appCtx) : useOwnState(definitions, appCtx);
305
+ const group = createGroupValues({ definitions, appCtx, kept: keeping.kept, warn: keeping.warn });
306
+
307
+ const previous = shownRef.current;
308
+ const shown = new Map<string, unknown>();
309
+ for (const { id } of definitions) {
310
+ const value = group.valueOf(id);
311
+ shown.set(id, previous.has(id) && sameValue(previous.get(id), value) ? previous.get(id) : value);
312
+ }
313
+ shownRef.current = shown;
314
+ // The previous object while every shown value is the previous one (kept above while it stays
315
+ // equal): a host's effect keyed on `values` runs when a value changes, not on every render.
316
+ const unchanged = valuesRef.current !== undefined
317
+ && definitions.every(({ id }) => previous.has(id) && Object.is(previous.get(id), shown.get(id)));
318
+ const values = unchanged ? valuesRef.current! : Object.fromEntries(shown);
319
+ valuesRef.current = values;
320
+
321
+ // One kind hook per definition, in order; the shape check above keeps the hooks' order.
322
+ const fields = definitions.map((definition) => {
323
+ const { initialValue: _default, ...outside } = definition;
324
+ const { onChange, hrefFor } = keeping.outside(definition, group);
325
+ return withFieldContext(group.ctx, () => useKindField(definition, {
326
+ ...outside, value: shown.get(definition.id), onChange, hrefFor,
327
+ }));
328
+ });
329
+
330
+ const changed = new Set(definitions.filter((d) => !showsDefault(d, keeping.kept(d.id), group)).map((d) => d.id));
331
+ const isDefault = (id: string) => {
332
+ if (!shown.has(id)) {
333
+ throw new Error(`${WHERE}: no field '${id}' in the group (fields: ${definitions.map((d) => d.id).join(', ')})`);
334
+ }
335
+ return !changed.has(id);
336
+ };
337
+
338
+ return {
339
+ fields,
340
+ byId: Object.fromEntries(definitions.map((d, i) => [d.id, fields[i]])),
341
+ values,
342
+ isDefault,
343
+ changedCount: changed.size,
344
+ resetAll: keeping.resetAll,
345
+ };
346
+ }