vultus-core 0.4.0 → 0.5.1
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 +313 -6
- package/dist/ArgStoreContext.d.ts +10 -0
- package/dist/ArgStoreContext.d.ts.map +1 -0
- package/dist/ArgStoreContext.js +15 -0
- package/dist/ArgStoreContext.js.map +1 -0
- package/dist/FieldContext.d.ts +38 -0
- package/dist/FieldContext.d.ts.map +1 -0
- package/dist/FieldContext.js +82 -0
- package/dist/FieldContext.js.map +1 -0
- package/dist/argStore.d.ts +26 -0
- package/dist/argStore.d.ts.map +1 -0
- package/dist/argStore.js +27 -0
- package/dist/argStore.js.map +1 -0
- package/dist/choices.d.ts +43 -0
- package/dist/choices.d.ts.map +1 -0
- package/dist/choices.js +20 -0
- package/dist/choices.js.map +1 -0
- package/dist/field.d.ts +42 -15
- package/dist/field.d.ts.map +1 -1
- package/dist/field.js +20 -7
- package/dist/field.js.map +1 -1
- package/dist/index.d.ts +22 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/kinds.d.ts +55 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +158 -0
- package/dist/kinds.js.map +1 -0
- package/dist/localStorageStore.d.ts +16 -0
- package/dist/localStorageStore.d.ts.map +1 -0
- package/dist/localStorageStore.js +72 -0
- package/dist/localStorageStore.js.map +1 -0
- package/dist/normalizeText.d.ts +6 -0
- package/dist/normalizeText.d.ts.map +1 -0
- package/dist/normalizeText.js +8 -0
- package/dist/normalizeText.js.map +1 -0
- package/dist/numeric.d.ts +45 -0
- package/dist/numeric.d.ts.map +1 -0
- package/dist/numeric.js +55 -0
- package/dist/numeric.js.map +1 -0
- package/dist/resolve.d.ts +13 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +25 -0
- package/dist/resolve.js.map +1 -1
- package/dist/tree.d.ts +69 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +151 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +17 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/useArg.d.ts +45 -0
- package/dist/useArg.d.ts.map +1 -0
- package/dist/useArg.js +83 -0
- package/dist/useArg.js.map +1 -0
- package/dist/useBoolField.d.ts +8 -3
- package/dist/useBoolField.d.ts.map +1 -1
- package/dist/useBoolField.js +9 -1
- package/dist/useBoolField.js.map +1 -1
- package/dist/useManyOfField.d.ts +35 -0
- package/dist/useManyOfField.d.ts.map +1 -0
- package/dist/useManyOfField.js +74 -0
- package/dist/useManyOfField.js.map +1 -0
- package/dist/useMixedBoolField.d.ts +8 -3
- package/dist/useMixedBoolField.d.ts.map +1 -1
- package/dist/useMixedBoolField.js +9 -1
- package/dist/useMixedBoolField.js.map +1 -1
- package/dist/useNumericField.d.ts +25 -0
- package/dist/useNumericField.d.ts.map +1 -0
- package/dist/useNumericField.js +49 -0
- package/dist/useNumericField.js.map +1 -0
- package/dist/useOneOfField.d.ts +18 -34
- package/dist/useOneOfField.d.ts.map +1 -1
- package/dist/useOneOfField.js +18 -18
- package/dist/useOneOfField.js.map +1 -1
- package/dist/useRangeField.d.ts +34 -0
- package/dist/useRangeField.d.ts.map +1 -0
- package/dist/useRangeField.js +60 -0
- package/dist/useRangeField.js.map +1 -0
- package/dist/useStoredField.d.ts +61 -0
- package/dist/useStoredField.d.ts.map +1 -0
- package/dist/useStoredField.js +151 -0
- package/dist/useStoredField.js.map +1 -0
- package/dist/useTextField.d.ts +22 -0
- package/dist/useTextField.d.ts.map +1 -0
- package/dist/useTextField.js +21 -0
- package/dist/useTextField.js.map +1 -0
- package/dist/useTreeField.d.ts +39 -0
- package/dist/useTreeField.d.ts.map +1 -0
- package/dist/useTreeField.js +66 -0
- package/dist/useTreeField.js.map +1 -0
- package/dist/valueTypes.d.ts +28 -0
- package/dist/valueTypes.d.ts.map +1 -0
- package/dist/valueTypes.js +65 -0
- package/dist/valueTypes.js.map +1 -0
- package/package.json +1 -1
- package/src/ArgStoreContext.tsx +16 -0
- package/src/FieldContext.tsx +98 -0
- package/src/argStore.ts +49 -0
- package/src/choices.ts +62 -0
- package/src/field.ts +64 -25
- package/src/index.ts +55 -5
- package/src/kinds.ts +208 -0
- package/src/localStorageStore.ts +68 -0
- package/src/normalizeText.ts +7 -0
- package/src/numeric.ts +80 -0
- package/src/resolve.ts +23 -0
- package/src/tree.ts +175 -0
- package/src/types.ts +28 -7
- package/src/useArg.ts +121 -0
- package/src/useBoolField.ts +19 -6
- package/src/useManyOfField.ts +107 -0
- package/src/useMixedBoolField.ts +19 -6
- package/src/useNumericField.ts +66 -0
- package/src/useOneOfField.ts +38 -58
- package/src/useRangeField.ts +83 -0
- package/src/useStoredField.ts +205 -0
- package/src/useTextField.ts +39 -0
- package/src/useTreeField.ts +108 -0
- package/src/valueTypes.ts +77 -0
package/src/kinds.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { resolveChoice, type OneOfChoice, type OneOfChoiceControls } from './choices.js';
|
|
2
|
+
import type { FieldBaseOptions, FieldKind } from './field.js';
|
|
3
|
+
import type { FieldCtx } from './FieldContext.js';
|
|
4
|
+
import { clamp, fit, resolveNumeric } from './numeric.js';
|
|
5
|
+
import { resolveIn } from './resolve.js';
|
|
6
|
+
import { normalizeTreeValue, type TreeNode } from './tree.js';
|
|
7
|
+
import type { Fits, ValueOrFn } from './types.js';
|
|
8
|
+
import { boolInitialValue, type useBoolField } from './useBoolField.js';
|
|
9
|
+
import { manyOfInitialValue, type ManyOfOwnOptions, type useManyOfField } from './useManyOfField.js';
|
|
10
|
+
import { mixedBoolInitialValue, type MixedBool, type useMixedBoolField } from './useMixedBoolField.js';
|
|
11
|
+
import { numericInitialValue, type NumericOwnOptions, type useNumericField } from './useNumericField.js';
|
|
12
|
+
import { oneOfInitialValue, type OneOfOwnOptions, type useOneOfField } from './useOneOfField.js';
|
|
13
|
+
import { rangeInitialValue, type NumberRange, type RangeOwnOptions, type useRangeField } from './useRangeField.js';
|
|
14
|
+
import { textInitialValue, type TextOwnOptions, type useTextField } from './useTextField.js';
|
|
15
|
+
import { treeInitialValue, type TreeOwnOptions, type useTreeField } from './useTreeField.js';
|
|
16
|
+
import { booleanType, numberType, rangeType, stringSetType, stringType, type ValueType } from './valueTypes.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A definition of kind `K`: the kind's hook options in their inside form, with `kind` required.
|
|
20
|
+
* It has no `value`, `onChange` or `commit`: where the value lives is chosen by whoever runs the
|
|
21
|
+
* field, and a store holds the definition's initial value as its default.
|
|
22
|
+
*/
|
|
23
|
+
type Definition<K extends FieldKind, V, Own, Ctx> =
|
|
24
|
+
{ kind: K } & FieldBaseOptions<V, Ctx> & { initialValue?: ValueOrFn<V, Ctx> } & Own;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A field as plain data (a module's filters, a dialog's options), tagged by its kind. Narrowed
|
|
28
|
+
* by `kind`, a definition is its kind's hook options as they are: `useOneOfField(definition)`.
|
|
29
|
+
* Its functions receive the field context `Ctx` (see FieldContextProvider).
|
|
30
|
+
*/
|
|
31
|
+
export type FieldDefinition<Ctx = any> =
|
|
32
|
+
| Definition<'boolean', boolean, {}, Ctx>
|
|
33
|
+
| Definition<'mixed-boolean', MixedBool, {}, Ctx>
|
|
34
|
+
| Definition<'oneOf', string, OneOfOwnOptions<string, Ctx>, Ctx>
|
|
35
|
+
| Definition<'manyOf', string[], ManyOfOwnOptions<string, Ctx>, Ctx>
|
|
36
|
+
| Definition<'numeric', number, NumericOwnOptions<Ctx>, Ctx>
|
|
37
|
+
| Definition<'range', NumberRange, RangeOwnOptions<Ctx>, Ctx>
|
|
38
|
+
| Definition<'tree', string[], TreeOwnOptions<string, Ctx>, Ctx>
|
|
39
|
+
| Definition<'text', string, TextOwnOptions, Ctx>;
|
|
40
|
+
|
|
41
|
+
type DefinitionOf<K extends FieldKind> = Extract<FieldDefinition, { kind: K }>;
|
|
42
|
+
type HookOptions<H extends (...args: any[]) => unknown> = Parameters<H>[0];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A compile-time check, the gate for "a definition is its kind's hook options as they are": if a
|
|
46
|
+
* hook's options and the definition drift apart, `tsc` fails here.
|
|
47
|
+
*/
|
|
48
|
+
type DefinitionsFitTheirHooks = [
|
|
49
|
+
Fits<DefinitionOf<'boolean'>, HookOptions<typeof useBoolField>>,
|
|
50
|
+
Fits<DefinitionOf<'mixed-boolean'>, HookOptions<typeof useMixedBoolField>>,
|
|
51
|
+
Fits<DefinitionOf<'oneOf'>, HookOptions<typeof useOneOfField>>,
|
|
52
|
+
Fits<DefinitionOf<'manyOf'>, HookOptions<typeof useManyOfField>>,
|
|
53
|
+
Fits<DefinitionOf<'numeric'>, HookOptions<typeof useNumericField>>,
|
|
54
|
+
Fits<DefinitionOf<'range'>, HookOptions<typeof useRangeField>>,
|
|
55
|
+
Fits<DefinitionOf<'tree'>, HookOptions<typeof useTreeField>>,
|
|
56
|
+
Fits<DefinitionOf<'text'>, HookOptions<typeof useTextField>>,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const NOT_STORABLE = 'a mixed-boolean field is an aggregate of other data and cannot be stored';
|
|
60
|
+
|
|
61
|
+
/** A definition whose kind is none of FieldKind's (plain data can carry anything): fail loudly rather than give nothing. */
|
|
62
|
+
function unknownKind(definition: never): never {
|
|
63
|
+
const { id, kind } = definition as { id: string; kind: unknown };
|
|
64
|
+
throw new Error(`Field '${id}': unknown kind '${String(kind)}'`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The choices of a one-of or many-of definition, resolved as the hooks resolve them. */
|
|
68
|
+
function choicesOf(definition: { id: string; choices: ValueOrFn<OneOfChoice[]> }, ctx: FieldCtx): OneOfChoiceControls[] {
|
|
69
|
+
const { id, choices } = definition;
|
|
70
|
+
return resolveIn<OneOfChoice[]>(choices, [], ctx, `Field '${id}': option 'choices'`).map((c) => resolveChoice(id, c));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every node's value, at any depth. */
|
|
74
|
+
function nodeValues(nodes: TreeNode[], into = new Set<string>()): Set<string> {
|
|
75
|
+
for (const node of nodes) {
|
|
76
|
+
into.add(node.value);
|
|
77
|
+
if (node.children) nodeValues(node.children, into);
|
|
78
|
+
}
|
|
79
|
+
return into;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A tree definition's nodes, resolved as useTreeField resolves them. */
|
|
83
|
+
function nodesOf(definition: { id: string; nodes: ValueOrFn<TreeNode[]> }, ctx: FieldCtx): TreeNode[] {
|
|
84
|
+
return resolveIn<TreeNode[]>(definition.nodes, [], ctx, `Field '${definition.id}': option 'nodes'`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A definition's default: `initialValue` resolved with the field context, else its kind's
|
|
89
|
+
* 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
|
|
91
|
+
* 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.
|
|
94
|
+
*/
|
|
95
|
+
export function initialValueOf(definition: FieldDefinition, ctx: FieldCtx): unknown {
|
|
96
|
+
const { id } = definition;
|
|
97
|
+
switch (definition.kind) {
|
|
98
|
+
case 'boolean': return boolInitialValue(definition, ctx, id);
|
|
99
|
+
case 'mixed-boolean': return mixedBoolInitialValue(definition, ctx, id);
|
|
100
|
+
case 'oneOf': return oneOfInitialValue(definition, ctx, id, choicesOf(definition, ctx));
|
|
101
|
+
case 'manyOf': return manyOfInitialValue(definition, ctx, id);
|
|
102
|
+
case 'numeric': return numericInitialValue(definition, ctx, id);
|
|
103
|
+
case 'range': return rangeInitialValue(definition, ctx, id);
|
|
104
|
+
case 'tree':
|
|
105
|
+
return normalizeTreeValue(nodesOf(definition, ctx), treeInitialValue(definition, ctx, id), definition.selection ?? 'topmost');
|
|
106
|
+
case 'text': return textInitialValue(definition, ctx, id);
|
|
107
|
+
default: return unknownKind(definition);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** How a kind's value is kept in a store. A mixed-boolean field shows an aggregate of other data, so it has none and throws. */
|
|
112
|
+
export function valueTypeOf(kind: FieldKind): ValueType<any> {
|
|
113
|
+
switch (kind) {
|
|
114
|
+
case 'boolean': return booleanType;
|
|
115
|
+
case 'oneOf':
|
|
116
|
+
case 'text': return stringType;
|
|
117
|
+
case 'manyOf':
|
|
118
|
+
case 'tree': return stringSetType;
|
|
119
|
+
case 'numeric': return numberType;
|
|
120
|
+
case 'range': return rangeType;
|
|
121
|
+
case 'mixed-boolean': throw new Error(NOT_STORABLE);
|
|
122
|
+
default: throw new Error(`unknown field kind '${String(kind as unknown)}'`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function warnDropped(id: string, among: 'choices' | 'nodes', dropped: string[], warn: (message: string) => void): void {
|
|
127
|
+
if (dropped.length === 0) return;
|
|
128
|
+
const listed = [...new Set(dropped)].map((v) => `'${v}'`).join(', ');
|
|
129
|
+
warn(`Field '${id}': dropping stored values that are not among its ${among}: ${listed}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A stored value (already parsed by the kind's value type) made fit for the definition as it is
|
|
134
|
+
* 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.
|
|
145
|
+
*/
|
|
146
|
+
export function sanitizeStored(
|
|
147
|
+
definition: FieldDefinition,
|
|
148
|
+
value: unknown,
|
|
149
|
+
ctx: FieldCtx,
|
|
150
|
+
warn: (message: string) => void = (message) => console.warn(message),
|
|
151
|
+
): unknown {
|
|
152
|
+
const { id } = definition;
|
|
153
|
+
switch (definition.kind) {
|
|
154
|
+
case 'boolean':
|
|
155
|
+
case 'text':
|
|
156
|
+
return value;
|
|
157
|
+
case 'mixed-boolean':
|
|
158
|
+
throw new Error(`Field '${id}': ${NOT_STORABLE}`);
|
|
159
|
+
case 'oneOf': {
|
|
160
|
+
const choices = choicesOf(definition, ctx);
|
|
161
|
+
if (choices.some((c) => c.value === value)) return value;
|
|
162
|
+
const fallback = oneOfInitialValue(definition, ctx, id, choices);
|
|
163
|
+
if (fallback !== value) warn(`Field '${id}': stored value '${value}' is not one of its choices, using '${fallback}'`);
|
|
164
|
+
return fallback;
|
|
165
|
+
}
|
|
166
|
+
case 'manyOf': {
|
|
167
|
+
const stored = value as string[];
|
|
168
|
+
const known = choicesOf(definition, ctx).map((c) => c.value);
|
|
169
|
+
warnDropped(id, 'choices', stored.filter((v) => !known.includes(v)), warn);
|
|
170
|
+
return known.filter((v) => stored.includes(v));
|
|
171
|
+
}
|
|
172
|
+
case 'tree': {
|
|
173
|
+
const stored = value as string[];
|
|
174
|
+
const nodes = nodesOf(definition, ctx);
|
|
175
|
+
const known = nodeValues(nodes);
|
|
176
|
+
warnDropped(id, 'nodes', stored.filter((v) => !known.has(v)), warn);
|
|
177
|
+
return normalizeTreeValue(nodes, stored.filter((v) => known.has(v)), definition.selection ?? 'topmost');
|
|
178
|
+
}
|
|
179
|
+
case 'numeric': {
|
|
180
|
+
const stored = value as number;
|
|
181
|
+
const { min, max, integer } = resolveNumeric(id, definition, ctx);
|
|
182
|
+
const clamped = clamp(stored, min, max);
|
|
183
|
+
const fitted = fit(stored, min, max, integer);
|
|
184
|
+
if (fitted !== stored) {
|
|
185
|
+
if (clamped !== stored) warn(`Field '${id}': stored value ${stored} is outside its bounds, using ${fitted}`);
|
|
186
|
+
else warn(`Field '${id}': stored value ${stored} is not a whole number, using ${fitted}`);
|
|
187
|
+
}
|
|
188
|
+
return fitted;
|
|
189
|
+
}
|
|
190
|
+
case 'range': {
|
|
191
|
+
const stored = value as NumberRange;
|
|
192
|
+
const { min, max, integer } = resolveNumeric(id, definition, ctx);
|
|
193
|
+
const clampedChanged = clamp(stored[0], min, max) !== stored[0] || clamp(stored[1], min, max) !== stored[1];
|
|
194
|
+
const from = fit(stored[0], min, max, integer);
|
|
195
|
+
const to = fit(stored[1], min, max, integer);
|
|
196
|
+
const sanitized: NumberRange = [Math.min(from, to), Math.max(from, to)];
|
|
197
|
+
if (from !== stored[0] || to !== stored[1]) {
|
|
198
|
+
if (clampedChanged) {
|
|
199
|
+
warn(`Field '${id}': stored range ${rangeType.format(stored)} is outside its bounds, using ${rangeType.format(sanitized)}`);
|
|
200
|
+
} else {
|
|
201
|
+
warn(`Field '${id}': stored range ${rangeType.format(stored)} has ends that are not whole numbers, using ${rangeType.format(sanitized)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return sanitized;
|
|
205
|
+
}
|
|
206
|
+
default: return unknownKind(definition);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ArgStore } from './argStore.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An ArgStore over `window.localStorage` (Praeceptor's print options), items kept under
|
|
5
|
+
* `${prefix}.${key}`. Guarded for SSR/Node (`typeof window === 'undefined'`), so creating one
|
|
6
|
+
* outside a browser does not crash: reads then give `undefined` and writes are no-ops. A browser
|
|
7
|
+
* may block storage (a sandboxed frame, blocked site data) or refuse a write (quota), and then
|
|
8
|
+
* reaching or using `localStorage` throws: a read that fails reads as absent and a write that
|
|
9
|
+
* fails is skipped, with one warning per store rather than one per call.
|
|
10
|
+
*
|
|
11
|
+
* A write notifies this store's own subscribers; a `storage` event (another tab changed a key
|
|
12
|
+
* under the prefix) notifies them too. The store listens for that event on `window` only while
|
|
13
|
+
* it has subscribers, so a store that is dropped leaves no listener behind; still, create one
|
|
14
|
+
* once (outside render, or in `useMemo`) rather than on every render.
|
|
15
|
+
*/
|
|
16
|
+
export function localStorageStore(prefix: string): ArgStore {
|
|
17
|
+
const hasWindow = typeof window !== 'undefined';
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
const storageKey = (key: string) => `${prefix}.${key}`;
|
|
20
|
+
// `null` means "another tab cleared the whole storage" (localStorage.clear()): every key under
|
|
21
|
+
// the prefix may have changed, so this counts as ours too.
|
|
22
|
+
const isOwnStorageKey = (key: string | null) => key === null || key.startsWith(`${prefix}.`);
|
|
23
|
+
const onStorage = (event: StorageEvent) => {
|
|
24
|
+
if (isOwnStorageKey(event.key)) listeners.forEach((listener) => listener());
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let warned = false;
|
|
28
|
+
/** Runs a use of localStorage; if it throws, warns (the first time for this store) and gives `undefined`. */
|
|
29
|
+
const guarded = <T>(what: string, use: (storage: Storage) => T): T | undefined => {
|
|
30
|
+
try {
|
|
31
|
+
return use(window.localStorage);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!warned) {
|
|
34
|
+
warned = true;
|
|
35
|
+
console.warn(
|
|
36
|
+
`localStorageStore('${prefix}'): cannot ${what} localStorage (${String(error)}); `
|
|
37
|
+
+ 'failing reads read as absent and failing writes are skipped, without further warnings',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
read(key) {
|
|
46
|
+
if (!hasWindow) return undefined;
|
|
47
|
+
return guarded('read', (storage) => storage.getItem(storageKey(key))) ?? undefined;
|
|
48
|
+
},
|
|
49
|
+
write(changes, _method) {
|
|
50
|
+
if (!hasWindow) return;
|
|
51
|
+
for (const { key, raw } of changes) {
|
|
52
|
+
guarded('write', (storage) => {
|
|
53
|
+
if (raw === undefined) storage.removeItem(storageKey(key));
|
|
54
|
+
else storage.setItem(storageKey(key), raw);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
listeners.forEach((listener) => listener());
|
|
58
|
+
},
|
|
59
|
+
subscribe(listener) {
|
|
60
|
+
if (hasWindow && listeners.size === 0) window.addEventListener('storage', onStorage);
|
|
61
|
+
listeners.add(listener);
|
|
62
|
+
return () => {
|
|
63
|
+
if (!listeners.delete(listener)) return;
|
|
64
|
+
if (hasWindow && listeners.size === 0) window.removeEventListener('storage', onStorage);
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key for accent- and case-insensitive comparison of labels (the tree hook's search,
|
|
3
|
+
* Taskmaster's): decompose, strip the combining marks, lower-case. No quaesitor dependency.
|
|
4
|
+
*/
|
|
5
|
+
export function normalizeText(text: string): string {
|
|
6
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
|
|
7
|
+
}
|
package/src/numeric.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
* `n` clamped into [min, max], then, when `integer` is true, rounded and kept to the whole numbers
|
|
41
|
+
* inside the bounds ([ceil(min), floor(max)]: with `max: 9.5`, 9.7 is 9, not 10): the rule a
|
|
42
|
+
* numeric or range field's shown value and requests are fit by (useNumericField, useRangeField),
|
|
43
|
+
* and the one `sanitizeStored` fits a stored value by.
|
|
44
|
+
*/
|
|
45
|
+
export function fit(n: number, min: number | undefined, max: number | undefined, integer: boolean): number {
|
|
46
|
+
const clamped = clamp(n, min, max);
|
|
47
|
+
if (!integer) return clamped;
|
|
48
|
+
return clamp(Math.round(clamped), min === undefined ? undefined : Math.ceil(min), max === undefined ? undefined : Math.floor(max));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolves a numeric field's bounds and formatter with the field context. `format` goes by its
|
|
53
|
+
* own rule, not resolveIn's, as it always receives the number first; otherwise it is treated
|
|
54
|
+
* the same way. With a provider above, it receives the context as its second argument, and a
|
|
55
|
+
* key it reads that no provider gave throws naming the field and the option `'format'`. With
|
|
56
|
+
* none, a `format` that declares the context parameter (`format.length >= 2`) throws the same
|
|
57
|
+
* message shape resolveIn uses, and one that does not is called with the number alone.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveNumeric(id: string, opts: NumericBounds, ctx: FieldCtx): ResolvedNumeric {
|
|
60
|
+
const where = (name: string) => `Field '${id}': option '${name}'`;
|
|
61
|
+
const min = resolveIn<number | undefined>(opts.min, undefined, ctx, where('min'));
|
|
62
|
+
const max = resolveIn<number | undefined>(opts.max, undefined, ctx, where('max'));
|
|
63
|
+
const step = resolveIn<number | undefined>(opts.step, undefined, ctx, where('step'));
|
|
64
|
+
const integer = resolveIn<boolean>(opts.integer, false, ctx, where('integer'));
|
|
65
|
+
const unit = resolveIn<string | undefined>(opts.unit, undefined, ctx, where('unit'));
|
|
66
|
+
|
|
67
|
+
const { format } = opts;
|
|
68
|
+
let resolvedFormat: (n: number) => string;
|
|
69
|
+
if (format === undefined) {
|
|
70
|
+
resolvedFormat = (n) => `${n}${unit ?? ''}`;
|
|
71
|
+
} else if (ctx !== NO_FIELD_CONTEXT) {
|
|
72
|
+
resolvedFormat = (n) => readingContext(where('format'), () => format(n, ctx as never));
|
|
73
|
+
} else if (format.length < 2) {
|
|
74
|
+
resolvedFormat = (n) => format(n, undefined as never);
|
|
75
|
+
} else {
|
|
76
|
+
throw new Error(`${where('format')} is a function of the field context, but no FieldContextProvider is above it`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { min, max, step, integer, unit, format: resolvedFormat };
|
|
80
|
+
}
|
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
|
-
|
|
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:
|
|
57
|
-
icon?:
|
|
58
|
-
variant?:
|
|
59
|
-
enabled?:
|
|
60
|
-
invisible?:
|
|
61
|
-
confirmation?:
|
|
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`. Not part of the package's public API: kept here as the one definition, imported directly by the files that use it (kinds.ts), and left out of index.ts's re-export of this module. */
|
|
77
|
+
export 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).
|