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.
- package/README.md +304 -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 +21 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -3
- package/dist/index.js.map +1 -1
- package/dist/kinds.d.ts +51 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/kinds.js +143 -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 +38 -0
- package/dist/numeric.d.ts.map +1 -0
- package/dist/numeric.js +43 -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 +15 -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 +52 -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 +63 -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 +50 -4
- package/src/kinds.ts +197 -0
- package/src/localStorageStore.ts +68 -0
- package/src/normalizeText.ts +7 -0
- package/src/numeric.ts +68 -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 +69 -0
- package/src/useOneOfField.ts +38 -58
- package/src/useRangeField.ts +86 -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
|
@@ -0,0 +1,69 @@
|
|
|
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, 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) => {
|
|
49
|
+
const clamped = clamp(n, resolved.min, resolved.max);
|
|
50
|
+
return resolved.integer ? Math.round(clamped) : clamped;
|
|
51
|
+
};
|
|
52
|
+
const value = fit(field.value);
|
|
53
|
+
|
|
54
|
+
const setValue = (next: number) => {
|
|
55
|
+
if (!Number.isFinite(next)) {
|
|
56
|
+
console.warn(`Field '${opts.id}': '${next}' is not a finite number`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
field.setValue(fit(next));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...field,
|
|
64
|
+
...resolved,
|
|
65
|
+
value,
|
|
66
|
+
setValue,
|
|
67
|
+
valueText: resolved.format(value),
|
|
68
|
+
};
|
|
69
|
+
}
|
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,86 @@
|
|
|
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, 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) => {
|
|
63
|
+
const clamped = clamp(n, min, max);
|
|
64
|
+
return resolved.integer ? Math.round(clamped) : clamped;
|
|
65
|
+
};
|
|
66
|
+
const value = order([fit(field.value[0]), fit(field.value[1])]);
|
|
67
|
+
|
|
68
|
+
const setValue = (next: NumberRange) => {
|
|
69
|
+
if (!Number.isFinite(next[0]) || !Number.isFinite(next[1])) {
|
|
70
|
+
console.warn(`Field '${opts.id}': '${next}' is not a finite number range`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
field.setValue(order([fit(next[0]), fit(next[1])]));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
...field,
|
|
78
|
+
...resolved,
|
|
79
|
+
min,
|
|
80
|
+
max,
|
|
81
|
+
step,
|
|
82
|
+
value,
|
|
83
|
+
setValue,
|
|
84
|
+
valueText: `${resolved.format(value[0])}–${resolved.format(value[1])}`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
|
|
2
|
+
import type { UpdateMethod } from './argStore.js';
|
|
3
|
+
import { useArgStore } from './ArgStoreContext.js';
|
|
4
|
+
import { useFieldContext, type FieldCtx } from './FieldContext.js';
|
|
5
|
+
import { initialValueOf, sanitizeStored, valueTypeOf, type FieldDefinition } from './kinds.js';
|
|
6
|
+
import { useArg, type ArgDefinition } from './useArg.js';
|
|
7
|
+
import type { ValueType } from './valueTypes.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The argument a field's value is stored as: the definition's `id` as key, its kind's value
|
|
11
|
+
* type, and its initial value, resolved with the field context, as default — a value, so the
|
|
12
|
+
* argument layer compares against the same default the field starts at. A mixed-boolean field
|
|
13
|
+
* shows an aggregate of other data and cannot be stored: it throws, naming the field.
|
|
14
|
+
*/
|
|
15
|
+
export function argOf(definition: FieldDefinition, ctx: FieldCtx): ArgDefinition<any> {
|
|
16
|
+
const { id, kind } = definition;
|
|
17
|
+
let valueType: ValueType<any>;
|
|
18
|
+
try {
|
|
19
|
+
valueType = valueTypeOf(kind);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(`Field '${id}': ${(error as Error).message}`);
|
|
22
|
+
}
|
|
23
|
+
return { key: id, valueType, default: initialValueOf(definition, ctx) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function silently(_message: string): void {}
|
|
27
|
+
|
|
28
|
+
function warnOnConsole(message: string): void {
|
|
29
|
+
console.warn(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The same value element by element: a list in the same order, a range with the same ends, a
|
|
34
|
+
* scalar. Stricter than the value types' `isEqual` (lists as sets), because the order a field
|
|
35
|
+
* shows (choice order, tree order) matters to the identity kept below.
|
|
36
|
+
*/
|
|
37
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
38
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
39
|
+
return a === b;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether a field shows its default: its stored value (parsed, or the default where absent or
|
|
44
|
+
* unreadable) equals it, or fits the definition only as the default (a one-of value no longer
|
|
45
|
+
* among the choices, a range clamped to bounds that are the default). `shown` is the sanitized
|
|
46
|
+
* value, asked for only when the first test fails.
|
|
47
|
+
*/
|
|
48
|
+
function showsDefault(arg: ArgDefinition<any>, stored: unknown, shown: () => unknown): boolean {
|
|
49
|
+
return arg.valueType.isEqual(stored, arg.default) || arg.valueType.isEqual(shown(), arg.default);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A field kept in the nearest ArgStore: its options for the kind's hook, and whether it shows
|
|
54
|
+
* its default. `options` follows `D` member by member, so options of a definition typed as the
|
|
55
|
+
* FieldDefinition union still narrow by `kind`.
|
|
56
|
+
*/
|
|
57
|
+
export interface StoredField<D extends FieldDefinition = FieldDefinition> {
|
|
58
|
+
/** The definition in its outside form, for the kind's hook (`useManyOfField(stored.options)`). */
|
|
59
|
+
options: D extends any ? Omit<D, 'initialValue'> & { value: any; onChange(value: any): void } : never;
|
|
60
|
+
isDefault: boolean;
|
|
61
|
+
/** Removes the field's key from the store, so it shows its default again. */
|
|
62
|
+
reset(method?: UpdateMethod): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A compile-time check: options of a FieldDefinition-typed definition narrow by `kind` (the Omit distributes over the union). */
|
|
66
|
+
function optionsNarrowByKind(stored: StoredField<FieldDefinition>): unknown {
|
|
67
|
+
return stored.options.kind === 'manyOf' ? stored.options.choices : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A field whose value lives in the nearest ArgStore (the URL, localStorage) under its `id`,
|
|
72
|
+
* with its initial value as the default (see argOf). `options.value` is the stored value made
|
|
73
|
+
* fit for the definition as it is now (sanitizeStored: choices, nodes and bounds follow the
|
|
74
|
+
* data); `onChange` writes with `'replace'`.
|
|
75
|
+
*
|
|
76
|
+
* A stored value that does not fit warns once per field and raw value: not again when only the
|
|
77
|
+
* definition or the field context is a new object (a provider given `value={{...}}` makes one
|
|
78
|
+
* on every render). With the key absent the default is the definer's, not stored input, and is
|
|
79
|
+
* sanitized silently.
|
|
80
|
+
*
|
|
81
|
+
* `options.value` keeps its identity while it stays the same element by element, so a list
|
|
82
|
+
* does not become a new array on every render (effects keyed on it would re-run), even when
|
|
83
|
+
* the field context is a new object each time. The default keeps its identity while it is
|
|
84
|
+
* `isEqual` to the freshly resolved one, so `onChange` and `options` keep theirs too.
|
|
85
|
+
*/
|
|
86
|
+
export function useStoredField<D extends FieldDefinition>(definition: D): StoredField<D> {
|
|
87
|
+
const store = useArgStore();
|
|
88
|
+
const ctx = useFieldContext();
|
|
89
|
+
|
|
90
|
+
const fresh = argOf(definition, ctx);
|
|
91
|
+
const argRef = useRef<ArgDefinition<any> | undefined>(undefined);
|
|
92
|
+
const previousArg = argRef.current;
|
|
93
|
+
const arg = previousArg !== undefined && previousArg.key === fresh.key && previousArg.valueType === fresh.valueType
|
|
94
|
+
&& fresh.valueType.isEqual(previousArg.default, fresh.default)
|
|
95
|
+
? previousArg
|
|
96
|
+
: fresh;
|
|
97
|
+
argRef.current = arg;
|
|
98
|
+
|
|
99
|
+
const { value: stored, set, reset } = useArg(arg);
|
|
100
|
+
// Called on the store, not passed detached: a store written as a class uses `this`.
|
|
101
|
+
const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);
|
|
102
|
+
const read = useCallback(() => store.read(definition.id), [store, definition.id]);
|
|
103
|
+
const raw = useSyncExternalStore(subscribe, read);
|
|
104
|
+
|
|
105
|
+
const warnedRef = useRef<string | undefined>(undefined);
|
|
106
|
+
const sanitized = useMemo(() => {
|
|
107
|
+
const input = raw === undefined ? undefined : JSON.stringify([definition.id, raw]);
|
|
108
|
+
const warn = input !== undefined && input !== warnedRef.current ? warnOnConsole : silently;
|
|
109
|
+
warnedRef.current = input;
|
|
110
|
+
return sanitizeStored(definition, stored, ctx, warn);
|
|
111
|
+
}, [definition, stored, ctx, raw]);
|
|
112
|
+
|
|
113
|
+
const previousRef = useRef<{ value: unknown } | undefined>(undefined);
|
|
114
|
+
const value = previousRef.current !== undefined && sameValue(previousRef.current.value, sanitized)
|
|
115
|
+
? previousRef.current.value
|
|
116
|
+
: sanitized;
|
|
117
|
+
previousRef.current = { value };
|
|
118
|
+
|
|
119
|
+
const onChange = useCallback((next: unknown) => set(next, 'replace'), [set]);
|
|
120
|
+
const options = useMemo(() => {
|
|
121
|
+
const { initialValue: _inside, ...rest } = definition;
|
|
122
|
+
return { ...rest, value, onChange } as StoredField<D>['options'];
|
|
123
|
+
}, [definition, value, onChange]);
|
|
124
|
+
|
|
125
|
+
return { options, isDefault: showsDefault(arg, stored, () => value), reset };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Several stored fields at once: "more filters (3)" and "clear all". */
|
|
129
|
+
export interface StoredFields {
|
|
130
|
+
/** Whether the field with this id shows its default; an id not among the fields throws. */
|
|
131
|
+
isDefault(id: string): boolean;
|
|
132
|
+
/** How many of the fields do not show their defaults. */
|
|
133
|
+
changedCount: number;
|
|
134
|
+
/** Removes every field's key in one store write (one history entry). */
|
|
135
|
+
resetAll(method?: UpdateMethod): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A compile-time check: useStoredFields takes a `readonly` list of definitions (an `as const` module constant). */
|
|
139
|
+
function takesReadonlyDefinitions(definitions: readonly FieldDefinition[]): Parameters<typeof useStoredFields>[0] {
|
|
140
|
+
return definitions;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** One field's answer in useStoredFields, with what it was computed from. */
|
|
144
|
+
interface FieldReading {
|
|
145
|
+
definition: FieldDefinition;
|
|
146
|
+
raw: string | null;
|
|
147
|
+
ctx: FieldCtx;
|
|
148
|
+
isDefault: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether a field with this raw value shows its default, by useStoredField's rule; silent, as a summary does not warn. */
|
|
152
|
+
function readsAsDefault(definition: FieldDefinition, raw: string | null, ctx: FieldCtx): boolean {
|
|
153
|
+
const arg = argOf(definition, ctx);
|
|
154
|
+
const stored = (raw === null ? undefined : arg.valueType.parse(raw)) ?? arg.default;
|
|
155
|
+
return showsDefault(arg, stored, () => sanitizeStored(definition, stored, ctx, silently));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether stored fields show their defaults, by the same rule as useStoredField, with one store
|
|
160
|
+
* subscription for all of them: its snapshot is their raw values joined into one string, which
|
|
161
|
+
* changes exactly when one of them does. It is a summary, so it never warns (the fields do).
|
|
162
|
+
* Each field is re-read only when its raw value, its definition or the field context changes,
|
|
163
|
+
* so typing into one field does not re-read its siblings.
|
|
164
|
+
*/
|
|
165
|
+
export function useStoredFields(definitions: readonly FieldDefinition[]): StoredFields {
|
|
166
|
+
const store = useArgStore();
|
|
167
|
+
const ctx = useFieldContext();
|
|
168
|
+
// Called on the store, not passed detached: a store written as a class uses `this`.
|
|
169
|
+
const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]);
|
|
170
|
+
const readAll = useCallback(() => JSON.stringify(definitions.map((d) => store.read(d.id) ?? null)), [store, definitions]);
|
|
171
|
+
const snapshot = useSyncExternalStore(subscribe, readAll);
|
|
172
|
+
|
|
173
|
+
const readingsRef = useRef(new Map<string, FieldReading>());
|
|
174
|
+
const changed = useMemo(() => {
|
|
175
|
+
const raws = JSON.parse(snapshot) as (string | null)[];
|
|
176
|
+
const previous = readingsRef.current;
|
|
177
|
+
const readings = new Map<string, FieldReading>();
|
|
178
|
+
definitions.forEach((definition, i) => {
|
|
179
|
+
const raw = raws[i];
|
|
180
|
+
const before = previous.get(definition.id);
|
|
181
|
+
const reading: FieldReading = before !== undefined && before.definition === definition && before.raw === raw && before.ctx === ctx
|
|
182
|
+
? before
|
|
183
|
+
: { definition, raw, ctx, isDefault: readsAsDefault(definition, raw, ctx) };
|
|
184
|
+
readings.set(definition.id, reading);
|
|
185
|
+
});
|
|
186
|
+
readingsRef.current = readings;
|
|
187
|
+
return new Set([...readings.values()].filter((r) => !r.isDefault).map((r) => r.definition.id));
|
|
188
|
+
}, [snapshot, definitions, ctx]);
|
|
189
|
+
|
|
190
|
+
const isDefault = useCallback(
|
|
191
|
+
(id: string) => {
|
|
192
|
+
if (!definitions.some((d) => d.id === id)) throw new Error(`Field '${id}': not among the fields given to useStoredFields`);
|
|
193
|
+
return !changed.has(id);
|
|
194
|
+
},
|
|
195
|
+
[definitions, changed],
|
|
196
|
+
);
|
|
197
|
+
const resetAll = useCallback(
|
|
198
|
+
(method: UpdateMethod = 'replace') => {
|
|
199
|
+
store.write(definitions.map((d) => ({ key: d.id, raw: undefined })), method);
|
|
200
|
+
},
|
|
201
|
+
[store, definitions],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return { isDefault, changedCount: changed.size, resetAll };
|
|
205
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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 type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
|
|
5
|
+
|
|
6
|
+
export type TextOwnOptions = { control?: 'input' | 'textarea' };
|
|
7
|
+
|
|
8
|
+
export type TextFieldOptions<RouteId extends string = string, Ctx = any> =
|
|
9
|
+
FieldOptions<string, string, RouteId, Ctx> & TextOwnOptions;
|
|
10
|
+
|
|
11
|
+
export interface TextFieldControls extends FieldControls<string, string> {
|
|
12
|
+
control: 'input' | 'textarea';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The implicit initial value for a text field: `initialValue` resolved, else the empty string. */
|
|
16
|
+
export function textInitialValue(
|
|
17
|
+
opts: { initialValue?: ValueOrFn<string> },
|
|
18
|
+
ctx: FieldCtx,
|
|
19
|
+
id: string,
|
|
20
|
+
): string {
|
|
21
|
+
return resolveIn<string>(opts.initialValue, '', ctx, `Field '${id}': option 'initialValue'`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A field whose value is a string (moxb's Text, TextAnt, TextSearchAnt). `control` says whether
|
|
26
|
+
* vultus-antd renders an input or a textarea; defaults to `'input'`. A field inside the field or
|
|
27
|
+
* changed locally requests on every change; one that commits holds requests back until the
|
|
28
|
+
* widget decides (blur or Enter), as any commit-mode field does.
|
|
29
|
+
*/
|
|
30
|
+
export function useTextField<RouteId extends string = string, Ctx = any>(
|
|
31
|
+
opts: TextFieldOptions<RouteId, Ctx>,
|
|
32
|
+
registry?: ErrorRoutesRegistry<RouteId>,
|
|
33
|
+
): TextFieldControls {
|
|
34
|
+
const ctx = useFieldContext();
|
|
35
|
+
const inside = !opts.onChange && !opts.commit;
|
|
36
|
+
const initialValue = () => textInitialValue(opts, ctx, opts.id);
|
|
37
|
+
const field = useFieldCore<string, string, RouteId, Ctx>('text', inside ? { ...opts, initialValue } : opts, registry);
|
|
38
|
+
return { ...field, control: opts.control ?? 'input' };
|
|
39
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
|
|
2
|
+
import { resolveChoice } from './choices.js';
|
|
3
|
+
import { useFieldContext, type FieldCtx } from './FieldContext.js';
|
|
4
|
+
import { resolveIn } from './resolve.js';
|
|
5
|
+
import {
|
|
6
|
+
coveredNodes, expandedTreeKeys, normalizeTreeValue, toggleTreeValue,
|
|
7
|
+
type TreeExpand, type TreeNode, type TreeNodeControls, type TreeSelection,
|
|
8
|
+
} from './tree.js';
|
|
9
|
+
import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
|
|
10
|
+
|
|
11
|
+
export type TreeOwnOptions<T extends string = string, Ctx = any> = {
|
|
12
|
+
/** The roots, each with its children; static or derived (a function, of the field context if it declares a parameter). */
|
|
13
|
+
nodes: ValueOrFn<TreeNode<T>[], Ctx>;
|
|
14
|
+
/** What the value lists; default `'topmost'` (see TreeSelection). */
|
|
15
|
+
selection?: TreeSelection;
|
|
16
|
+
/** Which nodes a widget opens at first; default `'top'` (see TreeExpand). */
|
|
17
|
+
expand?: TreeExpand;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type TreeFieldOptions<T extends string = string, RouteId extends string = string, Ctx = any> =
|
|
21
|
+
FieldOptions<T[], T[], RouteId, Ctx> & TreeOwnOptions<T, Ctx>;
|
|
22
|
+
|
|
23
|
+
export interface TreeFieldControls<T extends string = string> extends FieldControls<T[], T[]> {
|
|
24
|
+
/** The roots as the widgets get them, each with its children. */
|
|
25
|
+
nodes: TreeNodeControls<T>[];
|
|
26
|
+
selection: TreeSelection;
|
|
27
|
+
/** The nodes a widget opens at first (from `expand`). */
|
|
28
|
+
expandedKeys: T[];
|
|
29
|
+
/** Every node, by its value. */
|
|
30
|
+
byValue: Map<T, TreeNodeControls<T>>;
|
|
31
|
+
/** Checks or unchecks a node (with its subtree, in the cascading selections); ignored (with a warning) for an unknown or disabled node. */
|
|
32
|
+
toggle(value: T): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The implicit initial value for a tree field: `initialValue` resolved, else the empty list. */
|
|
36
|
+
export function treeInitialValue<T extends string>(
|
|
37
|
+
opts: { initialValue?: ValueOrFn<T[]> },
|
|
38
|
+
ctx: FieldCtx,
|
|
39
|
+
id: string,
|
|
40
|
+
): T[] {
|
|
41
|
+
return resolveIn<T[]>(opts.initialValue, [], ctx, `Field '${id}': option 'initialValue'`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sameList<T>(a: T[], b: T[]): boolean {
|
|
45
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A field whose value is a set of tree nodes (Praeceptor's category filter), with antd
|
|
50
|
+
* TreeSelect's semantics: in the cascading selections a checked node stands for its subtree,
|
|
51
|
+
* and `selection` says which nodes the value lists (see TreeSelection). The value is shown
|
|
52
|
+
* and requested normalized (normalizeTreeValue): in tree pre-order, values that name no
|
|
53
|
+
* node (a stored value naming a removed node) kept at the end. `setValue` does not police
|
|
54
|
+
* disabled nodes (the widgets disable them); `toggle` refuses a disabled or unknown node with
|
|
55
|
+
* a warning, as useManyOfField does.
|
|
56
|
+
*/
|
|
57
|
+
export function useTreeField<T extends string = string, RouteId extends string = string, Ctx = any>(
|
|
58
|
+
opts: TreeFieldOptions<T, RouteId, Ctx>,
|
|
59
|
+
registry?: ErrorRoutesRegistry<RouteId>,
|
|
60
|
+
): TreeFieldControls<T> {
|
|
61
|
+
const ctx = useFieldContext();
|
|
62
|
+
const nodes = resolveIn<TreeNode<T>[]>(opts.nodes, [], ctx, `Field '${opts.id}': option 'nodes'`);
|
|
63
|
+
const selection = opts.selection ?? 'topmost';
|
|
64
|
+
|
|
65
|
+
const inside = !opts.onChange && !opts.commit;
|
|
66
|
+
const initialValue = () => treeInitialValue<T>(opts, ctx, opts.id);
|
|
67
|
+
const field = useFieldCore<T[], T[], RouteId, Ctx>('tree', inside ? { ...opts, initialValue } : opts, registry);
|
|
68
|
+
|
|
69
|
+
// The same list object while it is already normalized, so the value is stable across renders.
|
|
70
|
+
const normalized = normalizeTreeValue(nodes, field.value, selection);
|
|
71
|
+
const value = sameList(normalized, field.value) ? field.value : normalized;
|
|
72
|
+
|
|
73
|
+
const checked = new Set(coveredNodes(nodes, value, selection));
|
|
74
|
+
const byValue = new Map<T, TreeNodeControls<T>>();
|
|
75
|
+
const control = (node: TreeNode<T>): TreeNodeControls<T> => {
|
|
76
|
+
const controls: TreeNodeControls<T> = { ...resolveChoice(opts.id, node), children: undefined, checked: checked.has(node.value) };
|
|
77
|
+
byValue.set(node.value, controls);
|
|
78
|
+
if (node.children) controls.children = node.children.map(control);
|
|
79
|
+
return controls;
|
|
80
|
+
};
|
|
81
|
+
const nodeControls = nodes.map(control);
|
|
82
|
+
|
|
83
|
+
const setValue = (next: T[]) => field.setValue(normalizeTreeValue(nodes, next, selection));
|
|
84
|
+
|
|
85
|
+
const toggle = (v: T) => {
|
|
86
|
+
const node = byValue.get(v);
|
|
87
|
+
if (!node) {
|
|
88
|
+
console.warn(`Field '${opts.id}': '${v}' is not one of its nodes`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!node.enabled) {
|
|
92
|
+
console.warn(`Field '${opts.id}': node '${v}' is disabled — ${node.whyDisabled ?? '(no reason)'}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
field.setValue(toggleTreeValue(nodes, value, v, selection));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
...field,
|
|
100
|
+
value,
|
|
101
|
+
setValue,
|
|
102
|
+
nodes: nodeControls,
|
|
103
|
+
selection,
|
|
104
|
+
expandedKeys: expandedTreeKeys(nodes, value, opts.expand ?? 'top'),
|
|
105
|
+
byValue,
|
|
106
|
+
toggle,
|
|
107
|
+
};
|
|
108
|
+
}
|