vultus-core 0.4.0 → 0.5.0

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 (120) hide show
  1. package/README.md +304 -6
  2. package/dist/ArgStoreContext.d.ts +10 -0
  3. package/dist/ArgStoreContext.d.ts.map +1 -0
  4. package/dist/ArgStoreContext.js +15 -0
  5. package/dist/ArgStoreContext.js.map +1 -0
  6. package/dist/FieldContext.d.ts +38 -0
  7. package/dist/FieldContext.d.ts.map +1 -0
  8. package/dist/FieldContext.js +82 -0
  9. package/dist/FieldContext.js.map +1 -0
  10. package/dist/argStore.d.ts +26 -0
  11. package/dist/argStore.d.ts.map +1 -0
  12. package/dist/argStore.js +27 -0
  13. package/dist/argStore.js.map +1 -0
  14. package/dist/choices.d.ts +43 -0
  15. package/dist/choices.d.ts.map +1 -0
  16. package/dist/choices.js +20 -0
  17. package/dist/choices.js.map +1 -0
  18. package/dist/field.d.ts +42 -15
  19. package/dist/field.d.ts.map +1 -1
  20. package/dist/field.js +20 -7
  21. package/dist/field.js.map +1 -1
  22. package/dist/index.d.ts +21 -4
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +20 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/kinds.d.ts +51 -0
  27. package/dist/kinds.d.ts.map +1 -0
  28. package/dist/kinds.js +143 -0
  29. package/dist/kinds.js.map +1 -0
  30. package/dist/localStorageStore.d.ts +16 -0
  31. package/dist/localStorageStore.d.ts.map +1 -0
  32. package/dist/localStorageStore.js +72 -0
  33. package/dist/localStorageStore.js.map +1 -0
  34. package/dist/normalizeText.d.ts +6 -0
  35. package/dist/normalizeText.d.ts.map +1 -0
  36. package/dist/normalizeText.js +8 -0
  37. package/dist/normalizeText.js.map +1 -0
  38. package/dist/numeric.d.ts +38 -0
  39. package/dist/numeric.d.ts.map +1 -0
  40. package/dist/numeric.js +43 -0
  41. package/dist/numeric.js.map +1 -0
  42. package/dist/resolve.d.ts +13 -0
  43. package/dist/resolve.d.ts.map +1 -1
  44. package/dist/resolve.js +25 -0
  45. package/dist/resolve.js.map +1 -1
  46. package/dist/tree.d.ts +69 -0
  47. package/dist/tree.d.ts.map +1 -0
  48. package/dist/tree.js +151 -0
  49. package/dist/tree.js.map +1 -0
  50. package/dist/types.d.ts +15 -7
  51. package/dist/types.d.ts.map +1 -1
  52. package/dist/useArg.d.ts +45 -0
  53. package/dist/useArg.d.ts.map +1 -0
  54. package/dist/useArg.js +83 -0
  55. package/dist/useArg.js.map +1 -0
  56. package/dist/useBoolField.d.ts +8 -3
  57. package/dist/useBoolField.d.ts.map +1 -1
  58. package/dist/useBoolField.js +9 -1
  59. package/dist/useBoolField.js.map +1 -1
  60. package/dist/useManyOfField.d.ts +35 -0
  61. package/dist/useManyOfField.d.ts.map +1 -0
  62. package/dist/useManyOfField.js +74 -0
  63. package/dist/useManyOfField.js.map +1 -0
  64. package/dist/useMixedBoolField.d.ts +8 -3
  65. package/dist/useMixedBoolField.d.ts.map +1 -1
  66. package/dist/useMixedBoolField.js +9 -1
  67. package/dist/useMixedBoolField.js.map +1 -1
  68. package/dist/useNumericField.d.ts +25 -0
  69. package/dist/useNumericField.d.ts.map +1 -0
  70. package/dist/useNumericField.js +52 -0
  71. package/dist/useNumericField.js.map +1 -0
  72. package/dist/useOneOfField.d.ts +18 -34
  73. package/dist/useOneOfField.d.ts.map +1 -1
  74. package/dist/useOneOfField.js +18 -18
  75. package/dist/useOneOfField.js.map +1 -1
  76. package/dist/useRangeField.d.ts +34 -0
  77. package/dist/useRangeField.d.ts.map +1 -0
  78. package/dist/useRangeField.js +63 -0
  79. package/dist/useRangeField.js.map +1 -0
  80. package/dist/useStoredField.d.ts +61 -0
  81. package/dist/useStoredField.d.ts.map +1 -0
  82. package/dist/useStoredField.js +151 -0
  83. package/dist/useStoredField.js.map +1 -0
  84. package/dist/useTextField.d.ts +22 -0
  85. package/dist/useTextField.d.ts.map +1 -0
  86. package/dist/useTextField.js +21 -0
  87. package/dist/useTextField.js.map +1 -0
  88. package/dist/useTreeField.d.ts +39 -0
  89. package/dist/useTreeField.d.ts.map +1 -0
  90. package/dist/useTreeField.js +66 -0
  91. package/dist/useTreeField.js.map +1 -0
  92. package/dist/valueTypes.d.ts +28 -0
  93. package/dist/valueTypes.d.ts.map +1 -0
  94. package/dist/valueTypes.js +65 -0
  95. package/dist/valueTypes.js.map +1 -0
  96. package/package.json +1 -1
  97. package/src/ArgStoreContext.tsx +16 -0
  98. package/src/FieldContext.tsx +98 -0
  99. package/src/argStore.ts +49 -0
  100. package/src/choices.ts +62 -0
  101. package/src/field.ts +64 -25
  102. package/src/index.ts +50 -4
  103. package/src/kinds.ts +197 -0
  104. package/src/localStorageStore.ts +68 -0
  105. package/src/normalizeText.ts +7 -0
  106. package/src/numeric.ts +68 -0
  107. package/src/resolve.ts +23 -0
  108. package/src/tree.ts +175 -0
  109. package/src/types.ts +28 -7
  110. package/src/useArg.ts +121 -0
  111. package/src/useBoolField.ts +19 -6
  112. package/src/useManyOfField.ts +107 -0
  113. package/src/useMixedBoolField.ts +19 -6
  114. package/src/useNumericField.ts +69 -0
  115. package/src/useOneOfField.ts +38 -58
  116. package/src/useRangeField.ts +86 -0
  117. package/src/useStoredField.ts +205 -0
  118. package/src/useTextField.ts +39 -0
  119. package/src/useTreeField.ts +108 -0
  120. package/src/valueTypes.ts +77 -0
package/src/numeric.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { NO_FIELD_CONTEXT, readingContext, type FieldCtx } from './FieldContext.js';
2
+ import { resolveIn } from './resolve.js';
3
+ import type { ValueOrFn } from './types.js';
4
+
5
+ /**
6
+ * The bounds and formatting a numeric field has, shared with the range kind (whose two ends
7
+ * reuse the same bounds). Each may be a context function, resolved like any other field option.
8
+ * `format` is not itself a ValueOrFn — it always takes the number as its first argument — so it
9
+ * is resolved by its own rule, below.
10
+ */
11
+ export interface NumericBounds<Ctx = any> {
12
+ min?: ValueOrFn<number | undefined, Ctx>;
13
+ max?: ValueOrFn<number | undefined, Ctx>;
14
+ step?: ValueOrFn<number | undefined, Ctx>;
15
+ integer?: ValueOrFn<boolean, Ctx>;
16
+ unit?: ValueOrFn<string | undefined, Ctx>;
17
+ /** Formats a value for display; default: the number followed by the unit, if any. */
18
+ format?: (n: number, ctx: Ctx) => string;
19
+ }
20
+
21
+ /** A NumericBounds with every option resolved through the field context. */
22
+ export interface ResolvedNumeric {
23
+ min: number | undefined;
24
+ max: number | undefined;
25
+ step: number | undefined;
26
+ integer: boolean;
27
+ unit: string | undefined;
28
+ format(n: number): string;
29
+ }
30
+
31
+ /** Clamps `n` into [min, max]; either end left out leaves that side open. */
32
+ export function clamp(n: number, min?: number, max?: number): number {
33
+ let result = n;
34
+ if (min !== undefined) result = Math.max(result, min);
35
+ if (max !== undefined) result = Math.min(result, max);
36
+ return result;
37
+ }
38
+
39
+ /**
40
+ * Resolves a numeric field's bounds and formatter with the field context. `format` goes by its
41
+ * own rule, not resolveIn's, as it always receives the number first; otherwise it is treated
42
+ * the same way. With a provider above, it receives the context as its second argument, and a
43
+ * key it reads that no provider gave throws naming the field and the option `'format'`. With
44
+ * none, a `format` that declares the context parameter (`format.length >= 2`) throws the same
45
+ * message shape resolveIn uses, and one that does not is called with the number alone.
46
+ */
47
+ export function resolveNumeric(id: string, opts: NumericBounds, ctx: FieldCtx): ResolvedNumeric {
48
+ const where = (name: string) => `Field '${id}': option '${name}'`;
49
+ const min = resolveIn<number | undefined>(opts.min, undefined, ctx, where('min'));
50
+ const max = resolveIn<number | undefined>(opts.max, undefined, ctx, where('max'));
51
+ const step = resolveIn<number | undefined>(opts.step, undefined, ctx, where('step'));
52
+ const integer = resolveIn<boolean>(opts.integer, false, ctx, where('integer'));
53
+ const unit = resolveIn<string | undefined>(opts.unit, undefined, ctx, where('unit'));
54
+
55
+ const { format } = opts;
56
+ let resolvedFormat: (n: number) => string;
57
+ if (format === undefined) {
58
+ resolvedFormat = (n) => `${n}${unit ?? ''}`;
59
+ } else if (ctx !== NO_FIELD_CONTEXT) {
60
+ resolvedFormat = (n) => readingContext(where('format'), () => format(n, ctx as never));
61
+ } else if (format.length < 2) {
62
+ resolvedFormat = (n) => format(n, undefined as never);
63
+ } else {
64
+ throw new Error(`${where('format')} is a function of the field context, but no FieldContextProvider is above it`);
65
+ }
66
+
67
+ return { min, max, step, integer, unit, format: resolvedFormat };
68
+ }
package/src/resolve.ts CHANGED
@@ -1,6 +1,29 @@
1
+ import { NO_FIELD_CONTEXT, readingContext, type FieldCtx } from './FieldContext.js';
1
2
  import type { ValueOrFn } from './types.js';
2
3
 
3
4
  export function resolve<T>(v: ValueOrFn<T> | undefined, fallback: T): T {
4
5
  if (v === undefined) return fallback;
5
6
  return typeof v === 'function' ? (v as () => T)() : v;
6
7
  }
8
+
9
+ /**
10
+ * Resolves a field's option with its context. With a provider above, every function receives
11
+ * `ctx`, so one whose parameter has a default (`(ctx = {}) => ...`) gets it too. With none, a
12
+ * function without declared parameters is called with nothing, so it works without a provider,
13
+ * and one that declares a parameter throws, `where` (`Field '<id>': option '<name>'`) saying
14
+ * whose it is. A key the function reads that no provider gave throws with `where` in front, so
15
+ * that error names the field and the option too. A function that returns `undefined` counts as
16
+ * an absent option, and yields `fallback` — a context lookup that misses (e.g. `(ctx) =>
17
+ * ctx.items[id]`) should fall back exactly as leaving the option out would, not leave the field
18
+ * with no value at all.
19
+ */
20
+ export function resolveIn<T>(v: ValueOrFn<T> | undefined, fallback: T, ctx: FieldCtx, where: string): T {
21
+ if (v === undefined) return fallback;
22
+ if (typeof v !== 'function') return v;
23
+ const fn = v as (ctx?: object) => T;
24
+ if (ctx !== NO_FIELD_CONTEXT) return readingContext(where, () => fn(ctx)) ?? fallback;
25
+ if (fn.length > 0) {
26
+ throw new Error(`${where} is a function of the field context, but no FieldContextProvider is above it`);
27
+ }
28
+ return fn() ?? fallback;
29
+ }
package/src/tree.ts ADDED
@@ -0,0 +1,175 @@
1
+ import type { OneOfChoice, OneOfChoiceControls } from './choices.js';
2
+ import { normalizeText } from './normalizeText.js';
3
+
4
+ /** A node of a tree field (Praeceptor's categories): a choice with children. */
5
+ export interface TreeNode<T extends string = string> extends OneOfChoice<T> {
6
+ children?: TreeNode<T>[];
7
+ }
8
+
9
+ /** A node as the widgets get it: the resolved choice, its children likewise, and whether it shows checked. */
10
+ export interface TreeNodeControls<T extends string = string> extends OneOfChoiceControls<T> {
11
+ children: TreeNodeControls<T>[] | undefined;
12
+ /** Covered by the value (in the cascading selections), or in the value (`'independent'`). */
13
+ checked: boolean;
14
+ }
15
+
16
+ /**
17
+ * What a tree field's value lists, as antd TreeSelect's `showCheckedStrategy` and
18
+ * `checkStrictly` do. In the three cascading selections a node stands for its subtree, and
19
+ * a node all of whose children are covered is covered too; they differ only in which of the
20
+ * covered nodes the value names:
21
+ * - `'topmost'` (`SHOW_PARENT`, the default): the covered nodes whose parent is not covered;
22
+ * - `'all'` (`SHOW_ALL`): every covered node;
23
+ * - `'leaves'` (`SHOW_CHILD`): the covered leaves.
24
+ *
25
+ * `'independent'` (`checkStrictly`, moxb's `strictSelect`): each node is chosen on its own.
26
+ */
27
+ export type TreeSelection = 'topmost' | 'all' | 'leaves' | 'independent';
28
+
29
+ /**
30
+ * Which nodes a tree widget opens at first: `'top'` (the default), the roots with children;
31
+ * `'none'`; `'selected'` (moxb's `expandValues`), the ancestors of the value's nodes; `'all'`,
32
+ * every node with children.
33
+ */
34
+ export type TreeExpand = 'top' | 'none' | 'selected' | 'all';
35
+
36
+ function hasChildren(node: { children?: unknown[] }): boolean {
37
+ return (node.children?.length ?? 0) > 0;
38
+ }
39
+
40
+ /** The nodes in pre-order, and each node's parent. */
41
+ function indexTree<T extends string>(nodes: TreeNode<T>[]): { order: TreeNode<T>[]; parent: Map<T, T> } {
42
+ const order: TreeNode<T>[] = [];
43
+ const parent = new Map<T, T>();
44
+ const walk = (list: TreeNode<T>[], up: T | undefined) => {
45
+ for (const node of list) {
46
+ order.push(node);
47
+ if (up !== undefined) parent.set(node.value, up);
48
+ if (node.children) walk(node.children, node.value);
49
+ }
50
+ };
51
+ walk(nodes, undefined);
52
+ return { order, parent };
53
+ }
54
+
55
+ /** The values that name no node, in their order, without duplicates (kept, so a stale value is not silently dropped). */
56
+ function unknownValues<T extends string>(order: TreeNode<T>[], value: T[]): T[] {
57
+ const known = new Set(order.map((n) => n.value));
58
+ return [...new Set(value.filter((v) => !known.has(v)))];
59
+ }
60
+
61
+ /** The nodes a value covers in the cascading selections: the value's nodes, their descendants, and every node all of whose children are covered. */
62
+ function cascade<T extends string>(nodes: TreeNode<T>[], value: T[]): Set<T> {
63
+ const listed = new Set(value);
64
+ const covered = new Set<T>();
65
+ const walk = (node: TreeNode<T>, inherited: boolean): boolean => {
66
+ const self = inherited || listed.has(node.value);
67
+ const children = (node.children ?? []).map((child) => walk(child, self));
68
+ const isCovered = self || (children.length > 0 && children.every(Boolean));
69
+ if (isCovered) covered.add(node.value);
70
+ return isCovered;
71
+ };
72
+ for (const node of nodes) walk(node, false);
73
+ return covered;
74
+ }
75
+
76
+ /** The covered nodes the value names under `selection` (see TreeSelection), in pre-order. */
77
+ function namedNodes<T extends string>(order: TreeNode<T>[], parent: Map<T, T>, covered: Set<T>, selection: TreeSelection): T[] {
78
+ const named = order.filter((node) => {
79
+ if (!covered.has(node.value)) return false;
80
+ if (selection === 'leaves') return !hasChildren(node);
81
+ if (selection === 'topmost') {
82
+ const up = parent.get(node.value);
83
+ return up === undefined || !covered.has(up);
84
+ }
85
+ return true;
86
+ });
87
+ return named.map((node) => node.value);
88
+ }
89
+
90
+ /**
91
+ * A tree field's value in its canonical form under `selection` (see TreeSelection): the
92
+ * nodes it names in tree pre-order, followed by the values that name no node, without
93
+ * duplicates. With `'topmost'`, `['A1', 'A']` becomes `['A']`, and a node's children all
94
+ * listed become the node.
95
+ */
96
+ export function normalizeTreeValue<T extends string>(nodes: TreeNode<T>[], value: T[], selection: TreeSelection): T[] {
97
+ const { order, parent } = indexTree(nodes);
98
+ const covered = selection === 'independent' ? new Set(value) : cascade(nodes, value);
99
+ const named = selection === 'independent'
100
+ ? order.filter((node) => covered.has(node.value)).map((node) => node.value)
101
+ : namedNodes(order, parent, covered, selection);
102
+ return [...named, ...unknownValues(order, value)];
103
+ }
104
+
105
+ /**
106
+ * Every node a tree field's value stands for, in pre-order (what an item filed under some
107
+ * node matches against): in the cascading selections the covered nodes (the value's nodes,
108
+ * their descendants, and every node all of whose children are covered); with
109
+ * `'independent'` the value's nodes themselves. Values that name no node follow, as in
110
+ * normalizeTreeValue.
111
+ */
112
+ export function coveredNodes<T extends string>(nodes: TreeNode<T>[], value: T[], selection: TreeSelection = 'topmost'): T[] {
113
+ return normalizeTreeValue(nodes, value, selection === 'independent' ? 'independent' : 'all');
114
+ }
115
+
116
+ /**
117
+ * The value after flipping `node`, normalized. In the cascading selections a covered node
118
+ * is removed with its descendants and its ancestors (they no longer have all their
119
+ * children), and an uncovered one is added with its descendants; with `'independent'` the
120
+ * node alone is added or removed. A `node` that is not one of the nodes changes nothing.
121
+ */
122
+ export function toggleTreeValue<T extends string>(nodes: TreeNode<T>[], value: T[], node: T, selection: TreeSelection): T[] {
123
+ const { order, parent } = indexTree(nodes);
124
+ const target = order.find((n) => n.value === node);
125
+ if (!target) return normalizeTreeValue(nodes, value, selection);
126
+ if (selection === 'independent') {
127
+ return normalizeTreeValue(nodes, value.includes(node) ? value.filter((v) => v !== node) : [...value, node], selection);
128
+ }
129
+ const covered = cascade(nodes, value);
130
+ const subtree = indexTree([target]).order.map((n) => n.value);
131
+ if (covered.has(node)) {
132
+ for (const v of subtree) covered.delete(v);
133
+ for (let up = parent.get(node); up !== undefined; up = parent.get(up)) covered.delete(up);
134
+ } else {
135
+ for (const v of subtree) covered.add(v);
136
+ }
137
+ return normalizeTreeValue(nodes, [...covered, ...unknownValues(order, value)], selection);
138
+ }
139
+
140
+ /** The nodes a tree widget opens at first under `expand` (see TreeExpand), in pre-order. */
141
+ export function expandedTreeKeys<T extends string>(nodes: TreeNode<T>[], value: T[], expand: TreeExpand): T[] {
142
+ if (expand === 'none') return [];
143
+ if (expand === 'top') return nodes.filter(hasChildren).map((node) => node.value);
144
+ const { order, parent } = indexTree(nodes);
145
+ if (expand === 'all') return order.filter(hasChildren).map((node) => node.value);
146
+ const ancestors = new Set<T>();
147
+ for (const v of value) {
148
+ for (let up = parent.get(v); up !== undefined; up = parent.get(up)) ancestors.add(up);
149
+ }
150
+ return order.filter((node) => ancestors.has(node.value)).map((node) => node.value);
151
+ }
152
+
153
+ /**
154
+ * The nodes matching a search (Taskmaster's category search), by antd TreeSelect's rule, so a
155
+ * dropdown and a drawer over the same nodes agree: a node matches if its label (else its
156
+ * value) contains the query, ignoring accents, case and the query's surrounding spaces. A
157
+ * matching node stays with its whole subtree; any other node stays if one of its descendants
158
+ * matches, keeping only the children that stay. A node that stays with no children has
159
+ * `children` undefined, not an empty list. Generic over the node's shape, so the widgets can
160
+ * filter resolved node controls; the nodes given are not changed. An empty query returns
161
+ * them as they are.
162
+ */
163
+ export function filterTree<N extends { label?: string; value: string; children?: N[] }>(nodes: N[], query: string): N[] {
164
+ const q = normalizeText(query.trim());
165
+ if (!q) return nodes;
166
+ /** The nodes of `list` that stay; `all` when an ancestor matched, so every node does. */
167
+ const keep = (list: N[], all: boolean): N[] => list.flatMap((node) => {
168
+ const matches = all || normalizeText(node.label ?? node.value).includes(q);
169
+ const children = keep(node.children ?? [], matches);
170
+ if (!matches && children.length === 0) return [];
171
+ if (node.children === undefined) return [node];
172
+ return [{ ...node, children: children.length > 0 ? children : undefined }];
173
+ });
174
+ return keep(nodes, false);
175
+ }
package/src/types.ts CHANGED
@@ -10,7 +10,16 @@ export interface FormLike {
10
10
  }
11
11
 
12
12
  export type Decision = boolean | { verdict: boolean; reason?: string };
13
- export type ValueOrFn<T> = T | (() => T);
13
+ /**
14
+ * A value, or a function giving it, as a field's options take them. With a FieldContextProvider
15
+ * above, a field calls every such function with its field context (the provider's value); with
16
+ * none, a function without parameters is called with nothing and one that declares a parameter
17
+ * throws.
18
+ */
19
+ export type ValueOrFn<T, Ctx = any> = T | ((ctx: Ctx) => T);
20
+
21
+ /** A value, or a function of no arguments giving it, as an action's options take them: an action calls its functions with nothing. */
22
+ export type Thunk<T> = T | (() => T);
14
23
 
15
24
  export type Severity = 'info' | 'warning' | 'error';
16
25
 
@@ -53,17 +62,29 @@ export type ConfirmationSpec =
53
62
 
54
63
  export interface ActionOptions<TArgs = void, TResult = void, RouteId extends string = string> {
55
64
  id: string;
56
- label: ValueOrFn<string>;
57
- icon?: ValueOrFn<React.ReactNode>;
58
- variant?: ValueOrFn<'default' | 'primary' | 'danger'>;
59
- enabled?: ValueOrFn<Decision>;
60
- invisible?: ValueOrFn<boolean>;
61
- confirmation?: ValueOrFn<ConfirmationSpec | undefined>;
65
+ label: Thunk<string>;
66
+ icon?: Thunk<React.ReactNode>;
67
+ variant?: Thunk<'default' | 'primary' | 'danger'>;
68
+ enabled?: Thunk<Decision>;
69
+ invisible?: Thunk<boolean>;
70
+ confirmation?: Thunk<ConfirmationSpec | undefined>;
62
71
  errorRoute?: RouteId;
63
72
  fire: (args: TArgs, ctx: ExecutionContext) => Promise<TResult> | TResult;
64
73
  onSuccess?: (result: TResult) => void;
65
74
  }
66
75
 
76
+ /** Compiles only when `A` is assignable to `B`. */
77
+ type Fits<A extends B, B> = A;
78
+
79
+ /**
80
+ * A compile-time check: an action calls its options' functions with nothing, so one that takes
81
+ * a parameter (the field context, say) must not compile, rather than crash when it is called.
82
+ */
83
+ type ActionFunctionsTakeNoParameter = [
84
+ // @ts-expect-error -- a function of a parameter is not a zero-argument thunk.
85
+ Fits<(ctx: { t(key: string): string }) => string, ActionOptions['label']>,
86
+ ];
87
+
67
88
  // Conditional rest-tuple lets call sites omit args entirely when TArgs is
68
89
  // void (e.g. plain ActionButton click) and requires the value otherwise
69
90
  // (e.g. FormSubmitButton passing typed form values).
package/src/useArg.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
2
+ import type { RawChange, UpdateMethod } from './argStore.js';
3
+ import { useArgStore } from './ArgStoreContext.js';
4
+ import { useFieldContext } from './FieldContext.js';
5
+ import { resolveIn } from './resolve.js';
6
+ import type { ValueOrFn } from './types.js';
7
+ import type { ValueType } from './valueTypes.js';
8
+
9
+ /** An argument's shape (moxb's url-arg definition, without a `permanent` flag — that is a router concern). */
10
+ export interface ArgDefinition<T, Ctx = any> {
11
+ key: string;
12
+ valueType: ValueType<T>;
13
+ default: ValueOrFn<T, Ctx>;
14
+ }
15
+
16
+ export interface ArgControls<T> {
17
+ value: T;
18
+ isDefault: boolean;
19
+ set(value: T, method?: UpdateMethod): void;
20
+ reset(method?: UpdateMethod): void;
21
+ /** The href `set(value)` would produce, where the store supports it (a URL store); `undefined` otherwise. */
22
+ hrefFor(value: T): string | undefined;
23
+ }
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 {
27
+ return valueType.isEqual(value, defaultValue) ? undefined : valueType.format(value);
28
+ }
29
+
30
+ /**
31
+ * An argument read from and written to the nearest ArgStore. An absent key reads as the default;
32
+ * writing the default removes the key.
33
+ *
34
+ * `parse(raw)` is memoized on `[raw, key, valueType]` (not the default, which may be a context
35
+ * function with its own, independently changing inputs): re-rendering with the same raw value
36
+ * does not re-parse and does not re-warn; a raw value that does not parse warns once per such
37
+ * change and the default is used instead. `value` itself then keeps its previous identity
38
+ * whenever it is still `isEqual` to the freshly computed one (parsed-or-default), so a default
39
+ * that builds a fresh value every render (`() => []`) does not change `value`'s identity either —
40
+ * effects keyed on `value` do not re-run just because nothing meaningful changed.
41
+ */
42
+ export function useArg<T>(definition: ArgDefinition<T>): ArgControls<T> {
43
+ const { key, valueType, default: defaultOrFn } = definition;
44
+ const store = useArgStore();
45
+ const ctx = useFieldContext();
46
+ // Called on the store, not passed detached: a store written as a class uses `this`.
47
+ const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);
48
+ const read = useCallback(() => store.read(key), [store, key]);
49
+ const raw = useSyncExternalStore(subscribe, read);
50
+ const defaultValue = resolveIn<T>(defaultOrFn, undefined as T, ctx, `Argument '${key}': option 'default'`);
51
+
52
+ const parsed = useMemo<T | undefined>(() => {
53
+ if (raw === undefined) return undefined;
54
+ const p = valueType.parse(raw);
55
+ if (p === undefined) console.warn(`Argument '${key}': cannot read '${raw}', using the default`);
56
+ return p;
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [raw, key, valueType]);
59
+ const candidate = parsed ?? defaultValue;
60
+
61
+ const previousRef = useRef<{ value: T } | undefined>(undefined);
62
+ const value = previousRef.current !== undefined && valueType.isEqual(previousRef.current.value, candidate)
63
+ ? previousRef.current.value
64
+ : candidate;
65
+ previousRef.current = { value };
66
+
67
+ const set = useCallback(
68
+ (next: T, method: UpdateMethod = 'replace') => {
69
+ store.write([{ key, raw: rawFor(next, defaultValue, valueType) }], method);
70
+ },
71
+ [store, key, valueType, defaultValue],
72
+ );
73
+ const reset = useCallback(
74
+ (method: UpdateMethod = 'replace') => {
75
+ store.write([{ key, raw: undefined }], method);
76
+ },
77
+ [store, key],
78
+ );
79
+ const hrefFor = useCallback(
80
+ (next: T): string | undefined => {
81
+ if (!store.hrefFor) return undefined;
82
+ return store.hrefFor([{ key, raw: rawFor(next, defaultValue, valueType) }]);
83
+ },
84
+ [store, key, valueType, defaultValue],
85
+ );
86
+
87
+ return { value, isDefault: valueType.isEqual(value, defaultValue), set, reset, hrefFor };
88
+ }
89
+
90
+ /** One argument's planned change in a batch (moxb's ArgChange): a new value, or a reset. */
91
+ export type ArgChange = { definition: ArgDefinition<any>; value?: unknown; reset?: true };
92
+
93
+ export function setArg<T>(definition: ArgDefinition<T>, value: T): ArgChange {
94
+ return { definition, value };
95
+ }
96
+
97
+ export function resetArg(definition: ArgDefinition<any>): ArgChange {
98
+ return { definition, reset: true };
99
+ }
100
+
101
+ /**
102
+ * Applies several argument changes as one store write (one history entry: "clear all", a chip
103
+ * that also closes a task). Each change's default is resolved at call time, from the field
104
+ * context captured when this hook rendered.
105
+ */
106
+ export function useArgWriter(): (changes: ArgChange[], method?: UpdateMethod) => void {
107
+ const store = useArgStore();
108
+ const ctx = useFieldContext();
109
+ return useCallback(
110
+ (changes: ArgChange[], method: UpdateMethod = 'replace') => {
111
+ const rawChanges: RawChange[] = changes.map((change) => {
112
+ const { key, valueType, default: defaultOrFn } = change.definition;
113
+ if (change.reset) return { key, raw: undefined };
114
+ const defaultValue = resolveIn(defaultOrFn, undefined, ctx, `Argument '${key}': option 'default'`);
115
+ return { key, raw: rawFor(change.value, defaultValue, valueType) };
116
+ });
117
+ store.write(rawChanges, method);
118
+ },
119
+ [store, ctx],
120
+ );
121
+ }
@@ -1,7 +1,9 @@
1
1
  import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
2
- import type { ErrorRoutesRegistry } from './types.js';
2
+ import { useFieldContext, type FieldCtx } from './FieldContext.js';
3
+ import { resolveIn } from './resolve.js';
4
+ import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
3
5
 
4
- export type BoolFieldOptions<RouteId extends string = string> = FieldOptions<boolean, boolean, RouteId>;
6
+ export type BoolFieldOptions<RouteId extends string = string, Ctx = any> = FieldOptions<boolean, boolean, RouteId, Ctx>;
5
7
 
6
8
  export interface BoolFieldControls extends FieldControls<boolean, boolean> {
7
9
  /**
@@ -11,19 +13,30 @@ export interface BoolFieldControls extends FieldControls<boolean, boolean> {
11
13
  toggle(): void;
12
14
  }
13
15
 
16
+ /** The implicit initial value for a boolean field: `initialValue` resolved, else false. */
17
+ export function boolInitialValue(
18
+ opts: { initialValue?: ValueOrFn<boolean> },
19
+ ctx: FieldCtx,
20
+ id: string,
21
+ ): boolean {
22
+ return resolveIn<boolean>(opts.initialValue, false, ctx, `Field '${id}': option 'initialValue'`);
23
+ }
24
+
14
25
  /**
15
26
  * A boolean field (moxb's Bool, the sidedao boolean field). Rendered by
16
27
  * vultus-antd's BoolCheckbox and BoolSwitch, which take the same controls.
17
28
  * Inside the field the value starts at `initialValue` (default false).
18
29
  */
19
- export function useBoolField<RouteId extends string = string>(
20
- opts: BoolFieldOptions<RouteId>,
30
+ export function useBoolField<RouteId extends string = string, Ctx = any>(
31
+ opts: BoolFieldOptions<RouteId, Ctx>,
21
32
  registry?: ErrorRoutesRegistry<RouteId>,
22
33
  ): BoolFieldControls {
34
+ const ctx = useFieldContext();
23
35
  const inside = !opts.onChange && !opts.commit;
24
- const field = useFieldCore<boolean, boolean, RouteId>(
36
+ const initialValue = () => boolInitialValue(opts, ctx, opts.id);
37
+ const field = useFieldCore<boolean, boolean, RouteId, Ctx>(
25
38
  'boolean',
26
- inside ? { ...opts, initialValue: opts.initialValue ?? false } : opts,
39
+ inside ? { ...opts, initialValue } : opts,
27
40
  registry,
28
41
  );
29
42
  return { ...field, toggle: () => field.setValue(!field.value) };
@@ -0,0 +1,107 @@
1
+ import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
2
+ import { resolveChoice, type OneOfChoice, type OneOfChoiceControls } from './choices.js';
3
+ import { useFieldContext, type FieldCtx } from './FieldContext.js';
4
+ import { resolveIn } from './resolve.js';
5
+ import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
6
+
7
+ export type ManyOfOwnOptions<T extends string = string, Ctx = any> = {
8
+ /** The choices, in order; static or derived (a function, of the field context if it declares a parameter). */
9
+ choices: ValueOrFn<OneOfChoice<T>[], Ctx>;
10
+ };
11
+
12
+ export type ManyOfFieldOptions<T extends string = string, RouteId extends string = string, Ctx = any> =
13
+ FieldOptions<T[], T[], RouteId, Ctx> & ManyOfOwnOptions<T, Ctx>;
14
+
15
+ export interface ManyOfChoiceControls<T extends string = string> extends OneOfChoiceControls<T> {
16
+ selected: boolean;
17
+ }
18
+
19
+ export interface ManyOfFieldControls<T extends string = string> extends FieldControls<T[], T[]> {
20
+ choices: ManyOfChoiceControls<T>[];
21
+ isSelected(value: T): boolean;
22
+ /** Flips a choice's membership in the value; ignored (with a warning) for an unknown or disabled choice. */
23
+ toggle(value: T): void;
24
+ }
25
+
26
+ /** The implicit initial value for a many-of field: `initialValue` resolved, else the empty list. */
27
+ export function manyOfInitialValue<T extends string>(
28
+ opts: { initialValue?: ValueOrFn<T[]> },
29
+ ctx: FieldCtx,
30
+ id: string,
31
+ ): T[] {
32
+ return resolveIn<T[]>(opts.initialValue, [], ctx, `Field '${id}': option 'initialValue'`);
33
+ }
34
+
35
+ /**
36
+ * A field whose value is a set of choices (moxb's ManyOf). A request (to
37
+ * `setValue` or `toggle`) is normalized to the known choices in their order,
38
+ * without duplicates, followed by the values the field already holds that are
39
+ * no longer among the choices and that the request keeps (in their current
40
+ * order: `toggle` keeps them, so a stored value naming a choice that was
41
+ * removed is not silently dropped, while `setValue([])` clears it). A request
42
+ * naming a value that is neither a choice nor already held, or that changes a
43
+ * disabled choice's membership, is ignored with a warning — as useOneOfField
44
+ * refuses a disabled or unknown choice.
45
+ */
46
+ export function useManyOfField<T extends string = string, RouteId extends string = string, Ctx = any>(
47
+ opts: ManyOfFieldOptions<T, RouteId, Ctx>,
48
+ registry?: ErrorRoutesRegistry<RouteId>,
49
+ ): ManyOfFieldControls<T> {
50
+ const ctx = useFieldContext();
51
+ const resolvedChoices = resolveIn<OneOfChoice<T>[]>(opts.choices, [], ctx, `Field '${opts.id}': option 'choices'`)
52
+ .map((c) => resolveChoice(opts.id, c));
53
+ const byValue = new Map(resolvedChoices.map((c) => [c.value, c]));
54
+ const choiceOrder = resolvedChoices.map((c) => c.value);
55
+
56
+ const inside = !opts.onChange && !opts.commit;
57
+ const initialValue = () => manyOfInitialValue<T>(opts, ctx, opts.id);
58
+ const field = useFieldCore<T[], T[], RouteId, Ctx>('manyOf', inside ? { ...opts, initialValue } : opts, registry);
59
+
60
+ /** The normalized list a request becomes, or undefined when the request is refused (with a warning already issued). */
61
+ const normalize = (request: T[], current: T[]): T[] | undefined => {
62
+ const requestSet = new Set(request);
63
+ const currentSet = new Set(current);
64
+ const heldUnknown = current.filter((v) => !byValue.has(v));
65
+ const heldUnknownSet = new Set(heldUnknown);
66
+ for (const v of request) {
67
+ if (!byValue.has(v) && !heldUnknownSet.has(v)) {
68
+ console.warn(`Field '${opts.id}': '${v}' is not one of its choices`);
69
+ return undefined;
70
+ }
71
+ }
72
+ for (const choice of resolvedChoices) {
73
+ if (choice.enabled) continue;
74
+ if (currentSet.has(choice.value) !== requestSet.has(choice.value)) {
75
+ console.warn(`Field '${opts.id}': choice '${choice.value}' is disabled — ${choice.whyDisabled ?? '(no reason)'}`);
76
+ return undefined;
77
+ }
78
+ }
79
+ return [...choiceOrder.filter((v) => requestSet.has(v)), ...heldUnknown.filter((v) => requestSet.has(v))];
80
+ };
81
+
82
+ const setValue = (next: T[]) => {
83
+ const normalized = normalize(next, field.value);
84
+ if (normalized === undefined) return;
85
+ field.setValue(normalized);
86
+ };
87
+
88
+ const isSelected = (value: T): boolean => field.value.includes(value);
89
+
90
+ const toggle = (value: T) => {
91
+ const choice = byValue.get(value);
92
+ if (!choice) {
93
+ console.warn(`Field '${opts.id}': '${value}' is not one of its choices`);
94
+ return;
95
+ }
96
+ if (!choice.enabled) {
97
+ console.warn(`Field '${opts.id}': choice '${value}' is disabled — ${choice.whyDisabled ?? '(no reason)'}`);
98
+ return;
99
+ }
100
+ const current = field.value;
101
+ setValue(current.includes(value) ? current.filter((v) => v !== value) : [...current, value]);
102
+ };
103
+
104
+ const choices: ManyOfChoiceControls<T>[] = resolvedChoices.map((c) => ({ ...c, selected: field.value.includes(c.value) }));
105
+
106
+ return { ...field, setValue, choices, isSelected, toggle };
107
+ }
@@ -1,16 +1,27 @@
1
1
  import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
2
- import type { ErrorRoutesRegistry } from './types.js';
2
+ import { useFieldContext, type FieldCtx } from './FieldContext.js';
3
+ import { resolveIn } from './resolve.js';
4
+ import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
3
5
 
4
6
  /** What a mixed boolean field shows: on, off, or some of its items on and some off. */
5
7
  export type MixedBool = boolean | 'mixed';
6
8
 
7
- export type MixedBoolFieldOptions<RouteId extends string = string> = FieldOptions<MixedBool, boolean, RouteId>;
9
+ export type MixedBoolFieldOptions<RouteId extends string = string, Ctx = any> = FieldOptions<MixedBool, boolean, RouteId, Ctx>;
8
10
 
9
11
  export interface MixedBoolFieldControls extends FieldControls<MixedBool, boolean> {
10
12
  /** Request the opposite: off when on, on when off or mixed. Programmatic use, as in BoolFieldControls. */
11
13
  toggle(): void;
12
14
  }
13
15
 
16
+ /** The implicit initial value for a mixed-boolean field: `initialValue` resolved, else false. */
17
+ export function mixedBoolInitialValue(
18
+ opts: { initialValue?: ValueOrFn<MixedBool> },
19
+ ctx: FieldCtx,
20
+ id: string,
21
+ ): MixedBool {
22
+ return resolveIn<MixedBool>(opts.initialValue, false, ctx, `Field '${id}': option 'initialValue'`);
23
+ }
24
+
14
25
  /**
15
26
  * A boolean field over an aggregate (moxb's indeterminate Bool, ARIA's
16
27
  * "mixed"): it shows true, false or 'mixed', and can be asked for true or
@@ -18,14 +29,16 @@ export interface MixedBoolFieldControls extends FieldControls<MixedBool, boolean
18
29
  * its knob in the middle, BoolCheckbox is indeterminate. Derive the value with
19
30
  * `aggregateBool`; with no items at all, disable the field with a reason.
20
31
  */
21
- export function useMixedBoolField<RouteId extends string = string>(
22
- opts: MixedBoolFieldOptions<RouteId>,
32
+ export function useMixedBoolField<RouteId extends string = string, Ctx = any>(
33
+ opts: MixedBoolFieldOptions<RouteId, Ctx>,
23
34
  registry?: ErrorRoutesRegistry<RouteId>,
24
35
  ): MixedBoolFieldControls {
36
+ const ctx = useFieldContext();
25
37
  const inside = !opts.onChange && !opts.commit;
26
- const field = useFieldCore<MixedBool, boolean, RouteId>(
38
+ const initialValue = () => mixedBoolInitialValue(opts, ctx, opts.id);
39
+ const field = useFieldCore<MixedBool, boolean, RouteId, Ctx>(
27
40
  'mixed-boolean',
28
- inside ? { ...opts, initialValue: opts.initialValue ?? false } : opts,
41
+ inside ? { ...opts, initialValue } : opts,
29
42
  registry,
30
43
  );
31
44
  return { ...field, toggle: () => field.setValue(field.value !== true) };