vultus-core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +319 -4
  2. package/dist/ArgStoreContext.d.ts +10 -0
  3. package/dist/ArgStoreContext.d.ts.map +1 -0
  4. package/dist/ArgStoreContext.js +15 -0
  5. package/dist/ArgStoreContext.js.map +1 -0
  6. package/dist/Components.d.ts +30 -0
  7. package/dist/Components.d.ts.map +1 -0
  8. package/dist/Components.js +28 -0
  9. package/dist/Components.js.map +1 -0
  10. package/dist/FieldContext.d.ts +38 -0
  11. package/dist/FieldContext.d.ts.map +1 -0
  12. package/dist/FieldContext.js +82 -0
  13. package/dist/FieldContext.js.map +1 -0
  14. package/dist/argStore.d.ts +26 -0
  15. package/dist/argStore.d.ts.map +1 -0
  16. package/dist/argStore.js +27 -0
  17. package/dist/argStore.js.map +1 -0
  18. package/dist/choices.d.ts +43 -0
  19. package/dist/choices.d.ts.map +1 -0
  20. package/dist/choices.js +20 -0
  21. package/dist/choices.js.map +1 -0
  22. package/dist/field.d.ts +42 -15
  23. package/dist/field.d.ts.map +1 -1
  24. package/dist/field.js +20 -7
  25. package/dist/field.js.map +1 -1
  26. package/dist/index.d.ts +22 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +21 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/kinds.d.ts +51 -0
  31. package/dist/kinds.d.ts.map +1 -0
  32. package/dist/kinds.js +143 -0
  33. package/dist/kinds.js.map +1 -0
  34. package/dist/localStorageStore.d.ts +16 -0
  35. package/dist/localStorageStore.d.ts.map +1 -0
  36. package/dist/localStorageStore.js +72 -0
  37. package/dist/localStorageStore.js.map +1 -0
  38. package/dist/normalizeText.d.ts +6 -0
  39. package/dist/normalizeText.d.ts.map +1 -0
  40. package/dist/normalizeText.js +8 -0
  41. package/dist/normalizeText.js.map +1 -0
  42. package/dist/numeric.d.ts +38 -0
  43. package/dist/numeric.d.ts.map +1 -0
  44. package/dist/numeric.js +43 -0
  45. package/dist/numeric.js.map +1 -0
  46. package/dist/resolve.d.ts +13 -0
  47. package/dist/resolve.d.ts.map +1 -1
  48. package/dist/resolve.js +25 -0
  49. package/dist/resolve.js.map +1 -1
  50. package/dist/tree.d.ts +69 -0
  51. package/dist/tree.d.ts.map +1 -0
  52. package/dist/tree.js +151 -0
  53. package/dist/tree.js.map +1 -0
  54. package/dist/types.d.ts +15 -7
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/useArg.d.ts +45 -0
  57. package/dist/useArg.d.ts.map +1 -0
  58. package/dist/useArg.js +83 -0
  59. package/dist/useArg.js.map +1 -0
  60. package/dist/useBoolField.d.ts +8 -3
  61. package/dist/useBoolField.d.ts.map +1 -1
  62. package/dist/useBoolField.js +9 -1
  63. package/dist/useBoolField.js.map +1 -1
  64. package/dist/useManyOfField.d.ts +35 -0
  65. package/dist/useManyOfField.d.ts.map +1 -0
  66. package/dist/useManyOfField.js +74 -0
  67. package/dist/useManyOfField.js.map +1 -0
  68. package/dist/useMixedBoolField.d.ts +8 -3
  69. package/dist/useMixedBoolField.d.ts.map +1 -1
  70. package/dist/useMixedBoolField.js +9 -1
  71. package/dist/useMixedBoolField.js.map +1 -1
  72. package/dist/useNumericField.d.ts +25 -0
  73. package/dist/useNumericField.d.ts.map +1 -0
  74. package/dist/useNumericField.js +52 -0
  75. package/dist/useNumericField.js.map +1 -0
  76. package/dist/useOneOfField.d.ts +18 -34
  77. package/dist/useOneOfField.d.ts.map +1 -1
  78. package/dist/useOneOfField.js +18 -18
  79. package/dist/useOneOfField.js.map +1 -1
  80. package/dist/useRangeField.d.ts +34 -0
  81. package/dist/useRangeField.d.ts.map +1 -0
  82. package/dist/useRangeField.js +63 -0
  83. package/dist/useRangeField.js.map +1 -0
  84. package/dist/useStoredField.d.ts +61 -0
  85. package/dist/useStoredField.d.ts.map +1 -0
  86. package/dist/useStoredField.js +151 -0
  87. package/dist/useStoredField.js.map +1 -0
  88. package/dist/useTextField.d.ts +22 -0
  89. package/dist/useTextField.d.ts.map +1 -0
  90. package/dist/useTextField.js +21 -0
  91. package/dist/useTextField.js.map +1 -0
  92. package/dist/useTreeField.d.ts +39 -0
  93. package/dist/useTreeField.d.ts.map +1 -0
  94. package/dist/useTreeField.js +66 -0
  95. package/dist/useTreeField.js.map +1 -0
  96. package/dist/valueTypes.d.ts +28 -0
  97. package/dist/valueTypes.d.ts.map +1 -0
  98. package/dist/valueTypes.js +65 -0
  99. package/dist/valueTypes.js.map +1 -0
  100. package/package.json +1 -1
  101. package/src/ArgStoreContext.tsx +16 -0
  102. package/src/Components.tsx +47 -0
  103. package/src/FieldContext.tsx +98 -0
  104. package/src/argStore.ts +49 -0
  105. package/src/choices.ts +62 -0
  106. package/src/field.ts +64 -25
  107. package/src/index.ts +54 -4
  108. package/src/kinds.ts +197 -0
  109. package/src/localStorageStore.ts +68 -0
  110. package/src/normalizeText.ts +7 -0
  111. package/src/numeric.ts +68 -0
  112. package/src/resolve.ts +23 -0
  113. package/src/tree.ts +175 -0
  114. package/src/types.ts +28 -7
  115. package/src/useArg.ts +121 -0
  116. package/src/useBoolField.ts +19 -6
  117. package/src/useManyOfField.ts +107 -0
  118. package/src/useMixedBoolField.ts +19 -6
  119. package/src/useNumericField.ts +69 -0
  120. package/src/useOneOfField.ts +38 -58
  121. package/src/useRangeField.ts +86 -0
  122. package/src/useStoredField.ts +205 -0
  123. package/src/useTextField.ts +39 -0
  124. package/src/useTreeField.ts +108 -0
  125. 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
+ }
@@ -1,7 +1,9 @@
1
1
  import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
2
- import type { ErrorRoutesRegistry } from './types.js';
2
+ import { useFieldContext, type FieldCtx } from './FieldContext.js';
3
+ import { resolveIn } from './resolve.js';
4
+ import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
3
5
 
4
- export type BoolFieldOptions<RouteId extends string = string> = FieldOptions<boolean, boolean, RouteId>;
6
+ export type BoolFieldOptions<RouteId extends string = string, Ctx = any> = FieldOptions<boolean, boolean, RouteId, Ctx>;
5
7
 
6
8
  export interface BoolFieldControls extends FieldControls<boolean, boolean> {
7
9
  /**
@@ -11,19 +13,30 @@ export interface BoolFieldControls extends FieldControls<boolean, boolean> {
11
13
  toggle(): void;
12
14
  }
13
15
 
16
+ /** The implicit initial value for a boolean field: `initialValue` resolved, else false. */
17
+ export function boolInitialValue(
18
+ opts: { initialValue?: ValueOrFn<boolean> },
19
+ ctx: FieldCtx,
20
+ id: string,
21
+ ): boolean {
22
+ return resolveIn<boolean>(opts.initialValue, false, ctx, `Field '${id}': option 'initialValue'`);
23
+ }
24
+
14
25
  /**
15
26
  * A boolean field (moxb's Bool, the sidedao boolean field). Rendered by
16
27
  * vultus-antd's BoolCheckbox and BoolSwitch, which take the same controls.
17
28
  * Inside the field the value starts at `initialValue` (default false).
18
29
  */
19
- export function useBoolField<RouteId extends string = string>(
20
- opts: BoolFieldOptions<RouteId>,
30
+ export function useBoolField<RouteId extends string = string, Ctx = any>(
31
+ opts: BoolFieldOptions<RouteId, Ctx>,
21
32
  registry?: ErrorRoutesRegistry<RouteId>,
22
33
  ): BoolFieldControls {
34
+ const ctx = useFieldContext();
23
35
  const inside = !opts.onChange && !opts.commit;
24
- const field = useFieldCore<boolean, boolean, RouteId>(
36
+ const initialValue = () => boolInitialValue(opts, ctx, opts.id);
37
+ const field = useFieldCore<boolean, boolean, RouteId, Ctx>(
25
38
  'boolean',
26
- inside ? { ...opts, initialValue: opts.initialValue ?? false } : opts,
39
+ inside ? { ...opts, initialValue } : opts,
27
40
  registry,
28
41
  );
29
42
  return { ...field, toggle: () => field.setValue(!field.value) };
@@ -0,0 +1,107 @@
1
+ import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
2
+ import { resolveChoice, type OneOfChoice, type OneOfChoiceControls } from './choices.js';
3
+ import { useFieldContext, type FieldCtx } from './FieldContext.js';
4
+ import { resolveIn } from './resolve.js';
5
+ import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
6
+
7
+ export type ManyOfOwnOptions<T extends string = string, Ctx = any> = {
8
+ /** The choices, in order; static or derived (a function, of the field context if it declares a parameter). */
9
+ choices: ValueOrFn<OneOfChoice<T>[], Ctx>;
10
+ };
11
+
12
+ export type ManyOfFieldOptions<T extends string = string, RouteId extends string = string, Ctx = any> =
13
+ FieldOptions<T[], T[], RouteId, Ctx> & ManyOfOwnOptions<T, Ctx>;
14
+
15
+ export interface ManyOfChoiceControls<T extends string = string> extends OneOfChoiceControls<T> {
16
+ selected: boolean;
17
+ }
18
+
19
+ export interface ManyOfFieldControls<T extends string = string> extends FieldControls<T[], T[]> {
20
+ choices: ManyOfChoiceControls<T>[];
21
+ isSelected(value: T): boolean;
22
+ /** Flips a choice's membership in the value; ignored (with a warning) for an unknown or disabled choice. */
23
+ toggle(value: T): void;
24
+ }
25
+
26
+ /** The implicit initial value for a many-of field: `initialValue` resolved, else the empty list. */
27
+ export function manyOfInitialValue<T extends string>(
28
+ opts: { initialValue?: ValueOrFn<T[]> },
29
+ ctx: FieldCtx,
30
+ id: string,
31
+ ): T[] {
32
+ return resolveIn<T[]>(opts.initialValue, [], ctx, `Field '${id}': option 'initialValue'`);
33
+ }
34
+
35
+ /**
36
+ * A field whose value is a set of choices (moxb's ManyOf). A request (to
37
+ * `setValue` or `toggle`) is normalized to the known choices in their order,
38
+ * without duplicates, followed by the values the field already holds that are
39
+ * no longer among the choices and that the request keeps (in their current
40
+ * order: `toggle` keeps them, so a stored value naming a choice that was
41
+ * removed is not silently dropped, while `setValue([])` clears it). A request
42
+ * naming a value that is neither a choice nor already held, or that changes a
43
+ * disabled choice's membership, is ignored with a warning — as useOneOfField
44
+ * refuses a disabled or unknown choice.
45
+ */
46
+ export function useManyOfField<T extends string = string, RouteId extends string = string, Ctx = any>(
47
+ opts: ManyOfFieldOptions<T, RouteId, Ctx>,
48
+ registry?: ErrorRoutesRegistry<RouteId>,
49
+ ): ManyOfFieldControls<T> {
50
+ const ctx = useFieldContext();
51
+ const resolvedChoices = resolveIn<OneOfChoice<T>[]>(opts.choices, [], ctx, `Field '${opts.id}': option 'choices'`)
52
+ .map((c) => resolveChoice(opts.id, c));
53
+ const byValue = new Map(resolvedChoices.map((c) => [c.value, c]));
54
+ const choiceOrder = resolvedChoices.map((c) => c.value);
55
+
56
+ const inside = !opts.onChange && !opts.commit;
57
+ const initialValue = () => manyOfInitialValue<T>(opts, ctx, opts.id);
58
+ const field = useFieldCore<T[], T[], RouteId, Ctx>('manyOf', inside ? { ...opts, initialValue } : opts, registry);
59
+
60
+ /** The normalized list a request becomes, or undefined when the request is refused (with a warning already issued). */
61
+ const normalize = (request: T[], current: T[]): T[] | undefined => {
62
+ const requestSet = new Set(request);
63
+ const currentSet = new Set(current);
64
+ const heldUnknown = current.filter((v) => !byValue.has(v));
65
+ const heldUnknownSet = new Set(heldUnknown);
66
+ for (const v of request) {
67
+ if (!byValue.has(v) && !heldUnknownSet.has(v)) {
68
+ console.warn(`Field '${opts.id}': '${v}' is not one of its choices`);
69
+ return undefined;
70
+ }
71
+ }
72
+ for (const choice of resolvedChoices) {
73
+ if (choice.enabled) continue;
74
+ if (currentSet.has(choice.value) !== requestSet.has(choice.value)) {
75
+ console.warn(`Field '${opts.id}': choice '${choice.value}' is disabled — ${choice.whyDisabled ?? '(no reason)'}`);
76
+ return undefined;
77
+ }
78
+ }
79
+ return [...choiceOrder.filter((v) => requestSet.has(v)), ...heldUnknown.filter((v) => requestSet.has(v))];
80
+ };
81
+
82
+ const setValue = (next: T[]) => {
83
+ const normalized = normalize(next, field.value);
84
+ if (normalized === undefined) return;
85
+ field.setValue(normalized);
86
+ };
87
+
88
+ const isSelected = (value: T): boolean => field.value.includes(value);
89
+
90
+ const toggle = (value: T) => {
91
+ const choice = byValue.get(value);
92
+ if (!choice) {
93
+ console.warn(`Field '${opts.id}': '${value}' is not one of its choices`);
94
+ return;
95
+ }
96
+ if (!choice.enabled) {
97
+ console.warn(`Field '${opts.id}': choice '${value}' is disabled — ${choice.whyDisabled ?? '(no reason)'}`);
98
+ return;
99
+ }
100
+ const current = field.value;
101
+ setValue(current.includes(value) ? current.filter((v) => v !== value) : [...current, value]);
102
+ };
103
+
104
+ const choices: ManyOfChoiceControls<T>[] = resolvedChoices.map((c) => ({ ...c, selected: field.value.includes(c.value) }));
105
+
106
+ return { ...field, setValue, choices, isSelected, toggle };
107
+ }
@@ -1,16 +1,27 @@
1
1
  import { useFieldCore, type FieldControls, type FieldOptions } from './field.js';
2
- import type { ErrorRoutesRegistry } from './types.js';
2
+ import { useFieldContext, type FieldCtx } from './FieldContext.js';
3
+ import { resolveIn } from './resolve.js';
4
+ import type { ErrorRoutesRegistry, ValueOrFn } from './types.js';
3
5
 
4
6
  /** What a mixed boolean field shows: on, off, or some of its items on and some off. */
5
7
  export type MixedBool = boolean | 'mixed';
6
8
 
7
- export type MixedBoolFieldOptions<RouteId extends string = string> = FieldOptions<MixedBool, boolean, RouteId>;
9
+ export type MixedBoolFieldOptions<RouteId extends string = string, Ctx = any> = FieldOptions<MixedBool, boolean, RouteId, Ctx>;
8
10
 
9
11
  export interface MixedBoolFieldControls extends FieldControls<MixedBool, boolean> {
10
12
  /** Request the opposite: off when on, on when off or mixed. Programmatic use, as in BoolFieldControls. */
11
13
  toggle(): void;
12
14
  }
13
15
 
16
+ /** The implicit initial value for a mixed-boolean field: `initialValue` resolved, else false. */
17
+ export function mixedBoolInitialValue(
18
+ opts: { initialValue?: ValueOrFn<MixedBool> },
19
+ ctx: FieldCtx,
20
+ id: string,
21
+ ): MixedBool {
22
+ return resolveIn<MixedBool>(opts.initialValue, false, ctx, `Field '${id}': option 'initialValue'`);
23
+ }
24
+
14
25
  /**
15
26
  * A boolean field over an aggregate (moxb's indeterminate Bool, ARIA's
16
27
  * "mixed"): it shows true, false or 'mixed', and can be asked for true or
@@ -18,14 +29,16 @@ export interface MixedBoolFieldControls extends FieldControls<MixedBool, boolean
18
29
  * its knob in the middle, BoolCheckbox is indeterminate. Derive the value with
19
30
  * `aggregateBool`; with no items at all, disable the field with a reason.
20
31
  */
21
- export function useMixedBoolField<RouteId extends string = string>(
22
- opts: MixedBoolFieldOptions<RouteId>,
32
+ export function useMixedBoolField<RouteId extends string = string, Ctx = any>(
33
+ opts: MixedBoolFieldOptions<RouteId, Ctx>,
23
34
  registry?: ErrorRoutesRegistry<RouteId>,
24
35
  ): MixedBoolFieldControls {
36
+ const ctx = useFieldContext();
25
37
  const inside = !opts.onChange && !opts.commit;
26
- const field = useFieldCore<MixedBool, boolean, RouteId>(
38
+ const initialValue = () => mixedBoolInitialValue(opts, ctx, opts.id);
39
+ const field = useFieldCore<MixedBool, boolean, RouteId, Ctx>(
27
40
  'mixed-boolean',
28
- inside ? { ...opts, initialValue: opts.initialValue ?? false } : opts,
41
+ inside ? { ...opts, initialValue } : opts,
29
42
  registry,
30
43
  );
31
44
  return { ...field, toggle: () => field.setValue(field.value !== true) };
@@ -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
+ }
@@ -1,44 +1,36 @@
1
- import type { ReactNode } from 'react';
2
- import { calculateEnabled, useFieldCore, type FieldControls, type FieldOptions } from './field.js';
3
- import { getReason, getVerdict } from './decision.js';
4
- import { resolve } from './resolve.js';
5
- import type { Decision, ErrorRoutesRegistry, ValueOrFn } from './types.js';
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
- /** One choice of a one-of field (moxb's BindOneOfChoice). */
8
- export interface OneOfChoice<T extends string = string> {
9
- value: T;
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
- /** A choice as the widgets get it. */
26
- export interface OneOfChoiceControls<T extends string = string> {
27
- value: T;
28
- label: string;
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
- /** The choices, in order; static or derived. */
40
- choices: ValueOrFn<OneOfChoice<T>[]>;
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 choices = resolve<OneOfChoice<T>[]>(opts.choices, []).map((c) => resolveChoice(opts.id, c));
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
- const initialValue = opts.initialValue ?? choices.find((c) => c.enabled)?.value;
80
- const field = useFieldCore<T, T, RouteId>('oneOf', inside ? { ...opts, initialValue } : opts, registry);
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
+ }