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/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
|
+
}
|
package/src/useBoolField.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
|
|
2
|
-
import type
|
|
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
|
|
36
|
+
const initialValue = () => boolInitialValue(opts, ctx, opts.id);
|
|
37
|
+
const field = useFieldCore<boolean, boolean, RouteId, Ctx>(
|
|
25
38
|
'boolean',
|
|
26
|
-
inside ? { ...opts, initialValue
|
|
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
|
+
}
|
package/src/useMixedBoolField.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
|
|
2
|
-
import type
|
|
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
|
|
38
|
+
const initialValue = () => mixedBoolInitialValue(opts, ctx, opts.id);
|
|
39
|
+
const field = useFieldCore<MixedBool, boolean, RouteId, Ctx>(
|
|
27
40
|
'mixed-boolean',
|
|
28
|
-
inside ? { ...opts, initialValue
|
|
41
|
+
inside ? { ...opts, initialValue } : opts,
|
|
29
42
|
registry,
|
|
30
43
|
);
|
|
31
44
|
return { ...field, toggle: () => field.setValue(field.value !== true) };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
|
|
2
|
+
import { useFieldContext, type FieldCtx } from './FieldContext.js';
|
|
3
|
+
import { resolveIn } from './resolve.js';
|
|
4
|
+
import { clamp, fit as fitNumber, resolveNumeric, type NumericBounds, type ResolvedNumeric } from './numeric.js';
|
|
5
|
+
import type { ErrorRoutesRegistry } from './types.js';
|
|
6
|
+
|
|
7
|
+
export type NumericOwnOptions<Ctx = any> = NumericBounds<Ctx>;
|
|
8
|
+
|
|
9
|
+
export type NumericFieldOptions<RouteId extends string = string, Ctx = any> =
|
|
10
|
+
FieldOptions<number, number, RouteId, Ctx> & NumericOwnOptions<Ctx>;
|
|
11
|
+
|
|
12
|
+
export interface NumericFieldControls extends FieldControls<number, number>, ResolvedNumeric {
|
|
13
|
+
/** The value, formatted (default: the number and the unit; `format` overrides it). */
|
|
14
|
+
valueText: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The implicit initial value for a numeric field: `initialValue` resolved, else `min` (or 0),
|
|
19
|
+
* clamped into the bounds. Only those options are resolved, not `format`, so a `format` of the
|
|
20
|
+
* field context needs no provider here.
|
|
21
|
+
*/
|
|
22
|
+
export function numericInitialValue(opts: NumericFieldOptions, ctx: FieldCtx, id: string): number {
|
|
23
|
+
const resolved = resolveIn<number | undefined>(opts.initialValue, undefined, ctx, `Field '${id}': option 'initialValue'`);
|
|
24
|
+
if (resolved !== undefined) return resolved;
|
|
25
|
+
const min = resolveIn<number | undefined>(opts.min, undefined, ctx, `Field '${id}': option 'min'`);
|
|
26
|
+
const max = resolveIn<number | undefined>(opts.max, undefined, ctx, `Field '${id}': option 'max'`);
|
|
27
|
+
return clamp(min ?? 0, min, max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A field whose value is a single number (moxb's Numeric). The shown value is always clamped
|
|
32
|
+
* into the bounds, then rounded when `integer`, since a value from outside (a stored or
|
|
33
|
+
* passed-in one) may fall outside them or be fractional even when every request this hook makes
|
|
34
|
+
* fits. A request is clamped and rounded the same way; a non-finite request (e.g. a widget
|
|
35
|
+
* parsing `''`) is refused with a warning, as a disabled field refuses any request.
|
|
36
|
+
*/
|
|
37
|
+
export function useNumericField<RouteId extends string = string, Ctx = any>(
|
|
38
|
+
opts: NumericFieldOptions<RouteId, Ctx>,
|
|
39
|
+
registry?: ErrorRoutesRegistry<RouteId>,
|
|
40
|
+
): NumericFieldControls {
|
|
41
|
+
const ctx = useFieldContext();
|
|
42
|
+
const resolved = resolveNumeric(opts.id, opts, ctx);
|
|
43
|
+
const inside = !opts.onChange && !opts.commit;
|
|
44
|
+
const initialValue = () => numericInitialValue(opts, ctx, opts.id);
|
|
45
|
+
const field = useFieldCore<number, number, RouteId, Ctx>('numeric', inside ? { ...opts, initialValue } : opts, registry);
|
|
46
|
+
|
|
47
|
+
/** A number clamped into the bounds, then rounded when `integer`. */
|
|
48
|
+
const fit = (n: number) => fitNumber(n, resolved.min, resolved.max, resolved.integer);
|
|
49
|
+
const value = fit(field.value);
|
|
50
|
+
|
|
51
|
+
const setValue = (next: number) => {
|
|
52
|
+
if (!Number.isFinite(next)) {
|
|
53
|
+
console.warn(`Field '${opts.id}': '${next}' is not a finite number`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
field.setValue(fit(next));
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
...field,
|
|
61
|
+
...resolved,
|
|
62
|
+
value,
|
|
63
|
+
setValue,
|
|
64
|
+
valueText: resolved.format(value),
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/useOneOfField.ts
CHANGED
|
@@ -1,44 +1,36 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
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
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/** Shown for the choice; default: the value. */
|
|
11
|
-
label?: string;
|
|
12
|
-
/** What choosing it does (moxb's `help`), markdown; also the field's current state when it is chosen. */
|
|
13
|
-
description?: string;
|
|
14
|
-
/** Shown before the label (or instead of it, for an icon-only widget), like an action's icon. */
|
|
15
|
-
icon?: ReactNode;
|
|
16
|
-
/** Choosable? Default true, optionally with a reason. Use `enabled` or `disabled`, not contradicting each other. */
|
|
17
|
-
enabled?: Decision;
|
|
18
|
-
disabled?: Decision;
|
|
19
|
-
/** Shown as the main or a dangerous choice, like an action's variant. Default 'default'. */
|
|
20
|
-
variant?: 'default' | 'primary' | 'danger';
|
|
21
|
-
/** A heading the choice is listed under, after the choices without one (OneOfSelect); default none. */
|
|
22
|
-
group?: string;
|
|
23
|
-
}
|
|
7
|
+
// Re-exported so today's callers (`import { type OneOfChoice } from 'vultus-core'`, and tests
|
|
8
|
+
// importing from this file) keep working now that the choice types live in choices.ts.
|
|
9
|
+
export type { OneOfChoice, OneOfChoiceControls };
|
|
24
10
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
description: string | undefined;
|
|
30
|
-
icon: ReactNode | undefined;
|
|
31
|
-
enabled: boolean;
|
|
32
|
-
whyDisabled: string | undefined;
|
|
33
|
-
variant: 'default' | 'primary' | 'danger';
|
|
34
|
-
group: string | undefined;
|
|
35
|
-
}
|
|
11
|
+
export type OneOfOwnOptions<T extends string = string, Ctx = any> = {
|
|
12
|
+
/** The choices, in order; static or derived (a function, of the field context if it declares a parameter). */
|
|
13
|
+
choices: ValueOrFn<OneOfChoice<T>[], Ctx>;
|
|
14
|
+
};
|
|
36
15
|
|
|
37
|
-
export type OneOfFieldOptions<T extends string = string, RouteId extends string = string> =
|
|
38
|
-
FieldOptions<T, T, RouteId> &
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
16
|
+
export type OneOfFieldOptions<T extends string = string, RouteId extends string = string, Ctx = any> =
|
|
17
|
+
FieldOptions<T, T, RouteId, Ctx> & OneOfOwnOptions<T, Ctx>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The implicit initial value for a one-of field: `initialValue` resolved through the field
|
|
21
|
+
* context, else the first enabled choice. Resolving first (rather than `opts.initialValue ??
|
|
22
|
+
* ...`) matters when `initialValue` is a context function that returns undefined: `??` would
|
|
23
|
+
* see the function, not its result, and never fall back.
|
|
24
|
+
*/
|
|
25
|
+
export function oneOfInitialValue<T extends string>(
|
|
26
|
+
opts: { initialValue?: ValueOrFn<T> },
|
|
27
|
+
ctx: FieldCtx,
|
|
28
|
+
id: string,
|
|
29
|
+
choices: OneOfChoiceControls<T>[],
|
|
30
|
+
): T | undefined {
|
|
31
|
+
const resolved = resolveIn<T | undefined>(opts.initialValue, undefined, ctx, `Field '${id}': option 'initialValue'`);
|
|
32
|
+
return resolved ?? choices.find((c) => c.enabled)?.value;
|
|
33
|
+
}
|
|
42
34
|
|
|
43
35
|
export interface OneOfFieldControls<T extends string = string> extends FieldControls<T, T> {
|
|
44
36
|
choices: OneOfChoiceControls<T>[];
|
|
@@ -46,21 +38,6 @@ export interface OneOfFieldControls<T extends string = string> extends FieldCont
|
|
|
46
38
|
current: OneOfChoiceControls<T> | undefined;
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
function resolveChoice<T extends string>(fieldId: string, choice: OneOfChoice<T>): OneOfChoiceControls<T> {
|
|
50
|
-
const decision = calculateEnabled(`${fieldId}.${choice.value}`, choice.enabled, choice.disabled);
|
|
51
|
-
const enabled = getVerdict(decision, true);
|
|
52
|
-
return {
|
|
53
|
-
value: choice.value,
|
|
54
|
-
label: choice.label ?? choice.value,
|
|
55
|
-
description: choice.description,
|
|
56
|
-
icon: choice.icon,
|
|
57
|
-
enabled,
|
|
58
|
-
whyDisabled: enabled ? undefined : getReason(decision),
|
|
59
|
-
variant: choice.variant ?? 'default',
|
|
60
|
-
group: choice.group,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
41
|
/**
|
|
65
42
|
* A field whose value is one of a list of choices (moxb's OneOf). Rendered by
|
|
66
43
|
* vultus-antd's OneOfSelect and OneOfSegmented, which take the same controls.
|
|
@@ -70,14 +47,17 @@ function resolveChoice<T extends string>(fieldId: string, choice: OneOfChoice<T>
|
|
|
70
47
|
* warning, as a disabled field ignores any request. Inside the field the value
|
|
71
48
|
* starts at `initialValue`, else at the first enabled choice.
|
|
72
49
|
*/
|
|
73
|
-
export function useOneOfField<T extends string = string, RouteId extends string = string>(
|
|
74
|
-
opts: OneOfFieldOptions<T, RouteId>,
|
|
50
|
+
export function useOneOfField<T extends string = string, RouteId extends string = string, Ctx = any>(
|
|
51
|
+
opts: OneOfFieldOptions<T, RouteId, Ctx>,
|
|
75
52
|
registry?: ErrorRoutesRegistry<RouteId>,
|
|
76
53
|
): OneOfFieldControls<T> {
|
|
77
|
-
const
|
|
54
|
+
const ctx = useFieldContext();
|
|
55
|
+
const choices = resolveIn<OneOfChoice<T>[]>(opts.choices, [], ctx, `Field '${opts.id}': option 'choices'`)
|
|
56
|
+
.map((c) => resolveChoice(opts.id, c));
|
|
78
57
|
const inside = !opts.onChange && !opts.commit;
|
|
79
|
-
|
|
80
|
-
const
|
|
58
|
+
// Undefined when no choice is enabled, and then so is the value.
|
|
59
|
+
const initialValue = () => oneOfInitialValue(opts, ctx, opts.id, choices) as T;
|
|
60
|
+
const field = useFieldCore<T, T, RouteId, Ctx>('oneOf', inside ? { ...opts, initialValue } : opts, registry);
|
|
81
61
|
const current = choices.find((c) => c.value === field.value);
|
|
82
62
|
|
|
83
63
|
const setValue = (next: T) => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
|
|
2
|
+
import { useFieldContext, type FieldCtx } from './FieldContext.js';
|
|
3
|
+
import { resolveIn } from './resolve.js';
|
|
4
|
+
import { fit as fitNumber, resolveNumeric, type NumericBounds, type ResolvedNumeric } from './numeric.js';
|
|
5
|
+
import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
|
|
6
|
+
|
|
7
|
+
/** A `[from, to]` pair, `from <= to`. Not named `Range`, which would shadow the DOM's global type. */
|
|
8
|
+
export type NumberRange = [number, number];
|
|
9
|
+
|
|
10
|
+
export type RangeOwnOptions<Ctx = any> = NumericBounds<Ctx> & { min: ValueOrFn<number, Ctx>; max: ValueOrFn<number, Ctx> };
|
|
11
|
+
|
|
12
|
+
export type RangeFieldOptions<RouteId extends string = string, Ctx = any> =
|
|
13
|
+
FieldOptions<NumberRange, NumberRange, RouteId, Ctx> & RangeOwnOptions<Ctx>;
|
|
14
|
+
|
|
15
|
+
export interface RangeFieldControls extends FieldControls<NumberRange, NumberRange>, ResolvedNumeric {
|
|
16
|
+
min: number;
|
|
17
|
+
max: number;
|
|
18
|
+
/** The value, formatted (default: each end's number, joined by an en dash). */
|
|
19
|
+
valueText: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function order([from, to]: NumberRange): NumberRange {
|
|
23
|
+
return from <= to ? [from, to] : [to, from];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The implicit initial value for a range field: `initialValue` resolved, else `[min, max]`. Only
|
|
28
|
+
* those options are resolved, not `format`, so a `format` of the field context needs no
|
|
29
|
+
* provider here.
|
|
30
|
+
*/
|
|
31
|
+
export function rangeInitialValue(opts: RangeFieldOptions, ctx: FieldCtx, id: string): NumberRange {
|
|
32
|
+
const resolved = resolveIn<NumberRange | undefined>(opts.initialValue, undefined, ctx, `Field '${id}': option 'initialValue'`);
|
|
33
|
+
if (resolved !== undefined) return resolved;
|
|
34
|
+
const min = resolveIn<number | undefined>(opts.min, undefined, ctx, `Field '${id}': option 'min'`);
|
|
35
|
+
const max = resolveIn<number | undefined>(opts.max, undefined, ctx, `Field '${id}': option 'max'`);
|
|
36
|
+
return [min as number, max as number];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A field whose value is a `[from, to]` number range (Praeceptor's school-years filter),
|
|
41
|
+
* reusing numeric's bounds and formatting; `min` and `max` are required here, and `step`
|
|
42
|
+
* defaults to 1. The shown value is each end clamped into the bounds and rounded when
|
|
43
|
+
* `integer`, then ordered, since a value from outside (a stored or passed-in one) may fall
|
|
44
|
+
* outside them, be fractional or be out of order even when every request this hook makes fits.
|
|
45
|
+
* A request is clamped, rounded and ordered the same way; a non-finite end refuses the request
|
|
46
|
+
* with a warning, as a disabled field refuses any request.
|
|
47
|
+
*/
|
|
48
|
+
export function useRangeField<RouteId extends string = string, Ctx = any>(
|
|
49
|
+
opts: RangeFieldOptions<RouteId, Ctx>,
|
|
50
|
+
registry?: ErrorRoutesRegistry<RouteId>,
|
|
51
|
+
): RangeFieldControls {
|
|
52
|
+
const ctx = useFieldContext();
|
|
53
|
+
const resolved = resolveNumeric(opts.id, opts, ctx);
|
|
54
|
+
const min = resolved.min as number;
|
|
55
|
+
const max = resolved.max as number;
|
|
56
|
+
const step = resolved.step ?? 1;
|
|
57
|
+
const inside = !opts.onChange && !opts.commit;
|
|
58
|
+
const initialValue = () => rangeInitialValue(opts, ctx, opts.id);
|
|
59
|
+
const field = useFieldCore<NumberRange, NumberRange, RouteId, Ctx>('range', inside ? { ...opts, initialValue } : opts, registry);
|
|
60
|
+
|
|
61
|
+
/** An end clamped into the bounds, then rounded when `integer`. */
|
|
62
|
+
const fit = (n: number) => fitNumber(n, min, max, resolved.integer);
|
|
63
|
+
const value = order([fit(field.value[0]), fit(field.value[1])]);
|
|
64
|
+
|
|
65
|
+
const setValue = (next: NumberRange) => {
|
|
66
|
+
if (!Number.isFinite(next[0]) || !Number.isFinite(next[1])) {
|
|
67
|
+
console.warn(`Field '${opts.id}': '${next}' is not a finite number range`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
field.setValue(order([fit(next[0]), fit(next[1])]));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...field,
|
|
75
|
+
...resolved,
|
|
76
|
+
min,
|
|
77
|
+
max,
|
|
78
|
+
step,
|
|
79
|
+
value,
|
|
80
|
+
setValue,
|
|
81
|
+
valueText: `${resolved.format(value[0])}–${resolved.format(value[1])}`,
|
|
82
|
+
};
|
|
83
|
+
}
|