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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vultus-core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Framework-neutral action model + hooks for vultus (antd-free by design)",
6
6
  "repository": {
@@ -0,0 +1,16 @@
1
+ import { createContext, useContext, type ReactNode } from 'react';
2
+ import type { ArgStore } from './argStore.js';
3
+
4
+ const ArgStoreContext = createContext<ArgStore | undefined>(undefined);
5
+
6
+ /** Gives the subtree below an ArgStore (Praeceptor: the URL store around the module page, localStorage around the print dialog). */
7
+ export function ArgStoreProvider({ store, children }: { store: ArgStore; children?: ReactNode }) {
8
+ return <ArgStoreContext.Provider value={store}>{children}</ArgStoreContext.Provider>;
9
+ }
10
+
11
+ /** The nearest ArgStoreProvider's store; a hook with none above it fails loudly rather than reading nothing. */
12
+ export function useArgStore(): ArgStore {
13
+ const store = useContext(ArgStoreContext);
14
+ if (store === undefined) throw new Error('No ArgStoreProvider is above this component');
15
+ return store;
16
+ }
@@ -0,0 +1,47 @@
1
+ import { createContext, useContext, useMemo, type ComponentType, type ReactNode } from 'react';
2
+
3
+ /** A spinner an app lends to vultus/optio, rendered in place of their native loading indicators. */
4
+ export interface SpinnerProps {
5
+ /** The spinner's identity: stable across re-renders and remounts, unique on the page (after scoping). */
6
+ spinnerKey: string;
7
+ /** antd's names (the height of antd's Spin indicator) or a height in px. */
8
+ size?: 'small' | 'default' | 'large' | number;
9
+ /** Accessible label; the spinner's own default when omitted. */
10
+ label?: string;
11
+ }
12
+ export type SpinnerComponent = ComponentType<SpinnerProps>;
13
+
14
+ /** Components an app lends to the libraries. */
15
+ export interface VultusComponents {
16
+ Spinner?: SpinnerComponent;
17
+ }
18
+
19
+ /**
20
+ * The components an app lends to vultus/optio in place of their own UI. Default is `{}`
21
+ * (none lent) so vultus-core stays framework-neutral; vultus-antd's `VultusProvider` merges
22
+ * in whatever an app passes as `components`.
23
+ */
24
+ export const VultusComponentsContext = createContext<VultusComponents>({});
25
+
26
+ export function useVultusComponents(): VultusComponents {
27
+ return useContext(VultusComponentsContext);
28
+ }
29
+
30
+ /** The scope path (segment names, outermost first) that `useSpinnerKey` prefixes onto local keys. */
31
+ const SpinnerScopeContext = createContext<string[]>([]);
32
+
33
+ /** Prefixes every spinner key below with `name` (scopes nest: outer/inner). */
34
+ export function SpinnerScope({ name, children }: { name: string; children?: ReactNode }) {
35
+ const outer = useContext(SpinnerScopeContext);
36
+ // Memoized so consumers below (useSpinnerKey, via SpinnerScopeContext) don't re-render
37
+ // on every SpinnerScope re-render — only when the scope path actually changes. Matters
38
+ // once whole conversation views sit inside a SpinnerScope.
39
+ const scope = useMemo(() => [...outer, name], [outer, name]);
40
+ return <SpinnerScopeContext.Provider value={scope}>{children}</SpinnerScopeContext.Provider>;
41
+ }
42
+
43
+ /** The scoped key for a library-local key: 'outer/inner/local', or 'local' outside any scope. */
44
+ export function useSpinnerKey(localKey: string): string {
45
+ const scope = useContext(SpinnerScopeContext);
46
+ return [...scope, localKey].join('/');
47
+ }
@@ -0,0 +1,98 @@
1
+ import { createContext, useContext, useMemo, type JSX, type ReactNode } from 'react';
2
+
3
+ /**
4
+ * What useFieldContext gives when no FieldContextProvider is above: a field's function that
5
+ * declares a parameter then has nothing to receive, and resolving it throws.
6
+ */
7
+ export const NO_FIELD_CONTEXT: unique symbol = Symbol('vultus.noFieldContext');
8
+
9
+ /** The field context as the hooks hold it: the guarded value, or the mark that there is none. */
10
+ export type FieldCtx = object | typeof NO_FIELD_CONTEXT;
11
+
12
+ /** The merged value of the providers above, and the same value guarded against missing keys. */
13
+ interface Provided {
14
+ raw: object;
15
+ guarded: object;
16
+ }
17
+
18
+ const FieldContext = createContext<Provided | undefined>(undefined);
19
+
20
+ /**
21
+ * Keys that libraries read to find out what an object is (a thenable, JSON, a test matcher, a
22
+ * React element, a DOM node or element, a mock function), and the `@@`-prefixed sentinels
23
+ * (Immutable's) that printers such as pretty-format test for. Reading them on the context is a
24
+ * probe, not a mistake.
25
+ */
26
+ const PROBED_KEYS = new Set([
27
+ 'then', 'toJSON', 'asymmetricMatch', '$$typeof', 'nodeType', 'tagName', 'hasAttribute', '_isMockFunction',
28
+ ]);
29
+
30
+ function isProbe(key: string): boolean {
31
+ return PROBED_KEYS.has(key) || key.startsWith('@@');
32
+ }
33
+
34
+ /** What the guard throws for a key no provider gave, so readingContext can tell it from the function's own errors. */
35
+ class MissingKeyError extends Error {}
36
+
37
+ /**
38
+ * The context value as the definitions' functions receive it: reading a key that no provider
39
+ * gave throws, naming it and the keys present, so a forgotten value fails loudly instead of
40
+ * passing `undefined` along. A key provided with the value `undefined` is fine.
41
+ */
42
+ function guard(raw: object): object {
43
+ return new Proxy(raw, {
44
+ get(target, key, receiver) {
45
+ if (typeof key === 'string' && !isProbe(key) && !(key in target)) {
46
+ throw new MissingKeyError(`Field context: '${key}' was never provided (provided: ${Object.keys(target).join(', ')})`);
47
+ }
48
+ return Reflect.get(target, key, receiver);
49
+ },
50
+ });
51
+ }
52
+
53
+ /**
54
+ * Runs `read`, a call of a definition's function with the field context. A key it reads that
55
+ * no provider gave throws with `where` (`Field '<id>': option '<name>'`) in front of the
56
+ * guard's message, so the error names the field and the option as well as the key; any other
57
+ * error passes through unchanged.
58
+ */
59
+ export function readingContext<T>(where: string, read: () => T): T {
60
+ try {
61
+ return read();
62
+ } catch (error) {
63
+ if (error instanceof MissingKeyError) throw new Error(`${where}: ${error.message}`, { cause: error });
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Gives the fields below a context, the argument their definitions' functions receive. Nested
70
+ * providers merge, the inner value winning: an app can provide `t` near the root and the data
71
+ * a page shows further down.
72
+ */
73
+ export function FieldContextProvider({ value, children }: { value: object; children?: ReactNode }) {
74
+ const outer = useContext(FieldContext);
75
+ // Memoized on the outer context and `value`: a caller passing a fresh object literal gets a
76
+ // new context every render anyway; a stable `value` keeps the fields below from re-rendering.
77
+ const provided = useMemo(() => {
78
+ const raw = { ...outer?.raw, ...value };
79
+ return { raw, guarded: guard(raw) };
80
+ }, [outer, value]);
81
+ return <FieldContext.Provider value={provided}>{children}</FieldContext.Provider>;
82
+ }
83
+
84
+ /**
85
+ * A FieldContextProvider typed for the context `Ctx` an app's definitions are written against.
86
+ * It is the same provider (one React context), so typed providers nest and merge with each
87
+ * other and with the untyped one; each gives part of the context.
88
+ */
89
+ export function createFieldContext<Ctx extends object>(): {
90
+ Provider: (props: { value: Partial<Ctx>; children?: ReactNode }) => JSX.Element;
91
+ } {
92
+ return { Provider: FieldContextProvider };
93
+ }
94
+
95
+ /** The field context the hooks resolve functions with: the guarded value, or NO_FIELD_CONTEXT. */
96
+ export function useFieldContext(): FieldCtx {
97
+ return useContext(FieldContext)?.guarded ?? NO_FIELD_CONTEXT;
98
+ }
@@ -0,0 +1,49 @@
1
+ /** Whether a write pushes a new history entry or replaces the current one; a store without history ignores it. */
2
+ export type UpdateMethod = 'push' | 'replace';
3
+
4
+ /** One key's new raw value, or `undefined` to remove it (the key reads back as its default). */
5
+ export interface RawChange {
6
+ key: string;
7
+ raw: string | undefined;
8
+ }
9
+
10
+ /**
11
+ * A store for argument values kept as strings (moxb's url-arg backends, batched): a URL query,
12
+ * localStorage, or (here) memory. `read` and `write` move raw strings only — parsing, defaults
13
+ * and equality live in the value type and in useArg, not here. The hooks call its methods on
14
+ * the store, never detached, so a store may be a class whose methods use `this`.
15
+ */
16
+ export interface ArgStore {
17
+ read(key: string): string | undefined;
18
+ /** Applies every change, then notifies subscribers once — several keys changed together are one history entry. */
19
+ write(changes: RawChange[], method: UpdateMethod): void;
20
+ subscribe(listener: () => void): () => void;
21
+ /** The href a write of `changes` would produce, where the store has one (a URL store); omitted otherwise. */
22
+ hrefFor?(changes: RawChange[]): string;
23
+ }
24
+
25
+ /** An ArgStore over a plain object, for tests and demos. `snapshot()` is a copy, for assertions. */
26
+ export function memoryStore(initial: Record<string, string> = {}): ArgStore & { snapshot(): Record<string, string> } {
27
+ const data: Record<string, string> = { ...initial };
28
+ const listeners = new Set<() => void>();
29
+
30
+ return {
31
+ read(key) {
32
+ return data[key];
33
+ },
34
+ write(changes, _method) {
35
+ for (const { key, raw } of changes) {
36
+ if (raw === undefined) delete data[key];
37
+ else data[key] = raw;
38
+ }
39
+ listeners.forEach((listener) => listener());
40
+ },
41
+ subscribe(listener) {
42
+ listeners.add(listener);
43
+ return () => listeners.delete(listener);
44
+ },
45
+ snapshot() {
46
+ return { ...data };
47
+ },
48
+ };
49
+ }
package/src/choices.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { ReactNode } from 'react';
2
+ import { calculateEnabled } from './field.js';
3
+ import { getReason, getVerdict } from './decision.js';
4
+ import type { Decision } from './types.js';
5
+
6
+ /**
7
+ * One choice of a one-of or many-of field, or a tree node (moxb's
8
+ * BindOneOfChoice). Shared by useOneOfField today; useManyOfField and the
9
+ * tree hook (later tasks) build on the same shape.
10
+ */
11
+ export interface OneOfChoice<T extends string = string> {
12
+ value: T;
13
+ /** Shown for the choice; default: the value. */
14
+ label?: string;
15
+ /** What choosing it does (moxb's `help`), markdown; also the field's current state when it is chosen. */
16
+ description?: string;
17
+ /** Shown before the label (or instead of it, for an icon-only widget), like an action's icon. */
18
+ icon?: ReactNode;
19
+ /** Choosable? Default true, optionally with a reason. Use `enabled` or `disabled`, not contradicting each other. */
20
+ enabled?: Decision;
21
+ disabled?: Decision;
22
+ /** Shown as the main or a dangerous choice, like an action's variant. Default 'default'. */
23
+ variant?: 'default' | 'primary' | 'danger';
24
+ /** A heading the choice is listed under, after the choices without one (OneOfSelect); default none. */
25
+ group?: string;
26
+ /** Shown after the label in secondary colour, e.g. a count ("12", "12 · 8%"). Default none. */
27
+ note?: string;
28
+ /** Shown faded, e.g. a choice with nothing behind it right now (no matching tasks). Default false. */
29
+ dimmed?: boolean;
30
+ }
31
+
32
+ /** A choice as the widgets get it. */
33
+ export interface OneOfChoiceControls<T extends string = string> {
34
+ value: T;
35
+ label: string;
36
+ description: string | undefined;
37
+ icon: ReactNode | undefined;
38
+ enabled: boolean;
39
+ whyDisabled: string | undefined;
40
+ variant: 'default' | 'primary' | 'danger';
41
+ group: string | undefined;
42
+ note: string | undefined;
43
+ dimmed: boolean;
44
+ }
45
+
46
+ /** Resolves a choice's `enabled` / `disabled` and defaults, as useOneOfField does for each of its choices. */
47
+ export function resolveChoice<T extends string>(fieldId: string, choice: OneOfChoice<T>): OneOfChoiceControls<T> {
48
+ const decision = calculateEnabled(`${fieldId}.${choice.value}`, choice.enabled, choice.disabled);
49
+ const enabled = getVerdict(decision, true);
50
+ return {
51
+ value: choice.value,
52
+ label: choice.label ?? choice.value,
53
+ description: choice.description,
54
+ icon: choice.icon,
55
+ enabled,
56
+ whyDisabled: enabled ? undefined : getReason(decision),
57
+ variant: choice.variant ?? 'default',
58
+ group: choice.group,
59
+ note: choice.note,
60
+ dimmed: choice.dimmed ?? false,
61
+ };
62
+ }
package/src/field.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useState } from 'react';
2
2
  import { getReason, getVerdict, invertDecision } from './decision.js';
3
- import { resolve } from './resolve.js';
3
+ import { useFieldContext } from './FieldContext.js';
4
+ import { resolveIn } from './resolve.js';
4
5
  import { useAction } from './useAction.js';
5
6
  import type {
6
7
  ConfirmationSpec,
@@ -21,6 +22,12 @@ import type {
21
22
  * Design notes: unitas docs/2026-10-05-vultus-field-model-design.md.
22
23
  */
23
24
 
25
+ /** The field kinds (FieldControls.type and a FieldDefinition's `kind`). */
26
+ export type FieldKind = 'boolean' | 'mixed-boolean' | 'oneOf' | 'manyOf' | 'numeric' | 'range' | 'tree' | 'text';
27
+
28
+ /** The widget to render a field with: a name, or a name with display options overriding its defaults. */
29
+ export type WidgetHint = string | { name: string; [option: string]: unknown };
30
+
24
31
  /** How an immediately committing field stores a change: an action that receives the requested value. */
25
32
  export interface FieldCommit<TRequest, RouteId extends string = string> {
26
33
  fire: (next: TRequest, ctx: ExecutionContext) => Promise<void> | void;
@@ -32,16 +39,30 @@ export interface FieldCommit<TRequest, RouteId extends string = string> {
32
39
  /** The key of a value in `valueDescriptions`: 'true' / 'false' for booleans, the value itself for strings. */
33
40
  export type ValueKey<T> = T extends boolean ? `${T}` : T extends string | number ? `${T}` : never;
34
41
 
35
- interface FieldBaseOptions<TValue> {
42
+ /**
43
+ * What every field has, whatever its storage. `Ctx` is the field context the options' functions
44
+ * receive (see FieldContextProvider); a function without parameters needs none.
45
+ */
46
+ export interface FieldBaseOptions<TValue, Ctx = any> {
36
47
  id: string;
37
- label?: ValueOrFn<string>;
38
- description?: ValueOrFn<string | undefined>;
48
+ /**
49
+ * The field's kind, as a FieldDefinition carries it (optional here: a hook knows its own
50
+ * kind). Given and different from the hook's own kind, it throws — a defence against passing
51
+ * the wrong definition to a kind-specific hook.
52
+ */
53
+ kind?: FieldKind;
54
+ label?: ValueOrFn<string, Ctx>;
55
+ description?: ValueOrFn<string | undefined, Ctx>;
56
+ /** Shown while the field is empty (moxb's Value); a widget that has no notion of "empty" ignores it. */
57
+ placeholder?: ValueOrFn<string | undefined, Ctx>;
58
+ /** The widget to render this field with: a name, or a name with its display options. Each kind has a default; vultus-antd's `<Field>` applies render-site overrides on top of this. */
59
+ widget?: WidgetHint;
39
60
  /** Shown? Default true. Use `visible` or `hidden`, not contradicting each other. */
40
- visible?: ValueOrFn<boolean>;
41
- hidden?: ValueOrFn<boolean>;
61
+ visible?: ValueOrFn<boolean, Ctx>;
62
+ hidden?: ValueOrFn<boolean, Ctx>;
42
63
  /** Changeable? Default true, optionally with a reason. Use `enabled` or `disabled`, not contradicting each other. */
43
- enabled?: ValueOrFn<Decision>;
44
- disabled?: ValueOrFn<Decision>;
64
+ enabled?: ValueOrFn<Decision, Ctx>;
65
+ disabled?: ValueOrFn<Decision, Ctx>;
45
66
  /**
46
67
  * What each value means right now, keyed by value (for booleans `true` / `false`, and `mixed`
47
68
  * where the field can show it). Shown with the current value, next to `description`, which
@@ -55,23 +76,24 @@ interface FieldBaseOptions<TValue> {
55
76
  * `TValue` and can be asked for a `TRequest`; usually the same type, but an aggregate can show
56
77
  * a state that cannot be requested (a boolean field showing 'mixed').
57
78
  */
58
- export type FieldStorage<TValue, TRequest = TValue, RouteId extends string = string> =
59
- /** Inside the field: its own state, starting at `initialValue`. */
60
- | { initialValue?: TValue; value?: never; onChange?: never; commit?: never }
79
+ export type FieldStorage<TValue, TRequest = TValue, RouteId extends string = string, Ctx = any> =
80
+ /** Inside the field: its own state, starting at `initialValue` (resolved once, when the field mounts). */
81
+ | { initialValue?: ValueOrFn<TValue, Ctx>; value?: never; onChange?: never; commit?: never }
61
82
  /** Outside, changed locally: the caller owns the value and applies `onChange`. */
62
83
  | { value: TValue; onChange: (value: TRequest) => void; initialValue?: never; commit?: never }
63
84
  /** Outside, committed immediately: `commit.fire` stores the change (pending, messages, confirmation). */
64
85
  | { value: TValue; commit: FieldCommit<TRequest, RouteId>; initialValue?: never; onChange?: never };
65
86
 
66
- export type FieldOptions<TValue, TRequest = TValue, RouteId extends string = string> =
67
- FieldBaseOptions<TValue> & FieldStorage<TValue, TRequest, RouteId>;
87
+ export type FieldOptions<TValue, TRequest = TValue, RouteId extends string = string, Ctx = any> =
88
+ FieldBaseOptions<TValue, Ctx> & FieldStorage<TValue, TRequest, RouteId, Ctx>;
68
89
 
69
90
  /** What a field widget receives: everything it shows, and the one way to change the value. */
70
91
  export interface FieldControls<TValue, TRequest = TValue> {
71
- type: string;
92
+ type: FieldKind;
72
93
  id: string;
73
94
  label: string;
74
95
  description: string | undefined;
96
+ placeholder: string | undefined;
75
97
  /** What the current value means (from `valueDescriptions`). */
76
98
  valueDescription: string | undefined;
77
99
  visible: boolean;
@@ -82,10 +104,13 @@ export interface FieldControls<TValue, TRequest = TValue> {
82
104
  setValue(next: TRequest): void;
83
105
  /** A commit is running. */
84
106
  pending: boolean;
107
+ /** The field stores through `commit` — true exactly in commit storage mode, so a widget can hold back requests while typing. */
108
+ commits: boolean;
85
109
  /** Messages of the last commit. */
86
110
  messages: Message[];
87
111
  /** The confirmation a widget must obtain before requesting `next`, if any. */
88
112
  confirmationFor(next: TRequest): ConfirmationSpec | undefined;
113
+ widget: WidgetHint | undefined;
89
114
  }
90
115
 
91
116
  export type FieldStorageMode = 'inside' | 'local' | 'commit';
@@ -122,27 +147,38 @@ export function calculateEnabled(id: string, enabled: Decision | undefined, disa
122
147
  /**
123
148
  * The shared core of every field hook: label and description, visibility and
124
149
  * enabled state (with reason), the storage mode, and in commit mode the setter
125
- * as an action (its pending state and messages become the field's).
150
+ * as an action (its pending state and messages become the field's). Functions
151
+ * among the options are resolved with the field context, on every render;
152
+ * `initialValue` once, when the field mounts. A kind's hook passes its implicit
153
+ * initial value as a zero-argument function, so that too is computed only then.
126
154
  */
127
- export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId extends string = string>(
128
- type: string,
129
- opts: FieldOptions<TValue, TRequest, RouteId>,
155
+ export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId extends string = string, Ctx = any>(
156
+ type: FieldKind,
157
+ opts: FieldOptions<TValue, TRequest, RouteId, Ctx>,
130
158
  registry?: ErrorRoutesRegistry<RouteId>,
131
159
  ): FieldControls<TValue, TRequest> {
160
+ if (opts.kind !== undefined && opts.kind !== type) {
161
+ throw new Error(`Field '${opts.id}': a definition of kind '${opts.kind}' was passed to the '${type}' hook`);
162
+ }
132
163
  const mode = fieldStorageMode(opts.id, opts as FieldStorage<unknown, unknown, string>);
133
- const [inner, setInner] = useState<TValue>(opts.initialValue as TValue);
164
+ const ctx = useFieldContext();
165
+ const where = (name: string) => `Field '${opts.id}': option '${name}'`;
166
+ const [inner, setInner] = useState<TValue>(
167
+ () => resolveIn<TValue | undefined>(opts.initialValue, undefined, ctx, where('initialValue')) as TValue,
168
+ );
134
169
 
135
- const label = resolve(opts.label, '');
136
- const description = resolve<string | undefined>(opts.description, undefined);
170
+ const label = resolveIn(opts.label, '', ctx, where('label'));
171
+ const description = resolveIn<string | undefined>(opts.description, undefined, ctx, where('description'));
172
+ const placeholder = resolveIn<string | undefined>(opts.placeholder, undefined, ctx, where('placeholder'));
137
173
  const visible = calculateVisible(
138
174
  opts.id,
139
- resolve<boolean | undefined>(opts.visible, undefined),
140
- resolve<boolean | undefined>(opts.hidden, undefined),
175
+ resolveIn<boolean | undefined>(opts.visible, undefined, ctx, where('visible')),
176
+ resolveIn<boolean | undefined>(opts.hidden, undefined, ctx, where('hidden')),
141
177
  );
142
178
  const enabledDecision = calculateEnabled(
143
179
  opts.id,
144
- resolve<Decision | undefined>(opts.enabled, undefined),
145
- resolve<Decision | undefined>(opts.disabled, undefined),
180
+ resolveIn<Decision | undefined>(opts.enabled, undefined, ctx, where('enabled')),
181
+ resolveIn<Decision | undefined>(opts.disabled, undefined, ctx, where('disabled')),
146
182
  );
147
183
  const enabled = getVerdict(enabledDecision, true);
148
184
  const whyDisabled = enabled ? undefined : getReason(enabledDecision);
@@ -184,6 +220,7 @@ export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId e
184
220
  id: opts.id,
185
221
  label,
186
222
  description,
223
+ placeholder,
187
224
  valueDescription,
188
225
  visible,
189
226
  enabled,
@@ -191,7 +228,9 @@ export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId e
191
228
  value,
192
229
  setValue,
193
230
  pending,
231
+ commits: mode === 'commit',
194
232
  messages: mode === 'commit' ? (setter.messages ?? []) : [],
195
233
  confirmationFor,
234
+ widget: opts.widget,
196
235
  };
197
236
  }
package/src/index.ts CHANGED
@@ -6,16 +6,66 @@ export * from './routeApiError.js';
6
6
  export { ActionErrorContext, useActionErrorCtx } from './useActionErrorCtx.js';
7
7
  export { useAction } from './useAction.js';
8
8
  export { useActionList } from './useActionList.js';
9
+ export {
10
+ FieldContextProvider, createFieldContext, useFieldContext, NO_FIELD_CONTEXT, type FieldCtx,
11
+ } from './FieldContext.js';
9
12
  export {
10
13
  useFieldCore, fieldStorageMode, calculateVisible, calculateEnabled,
11
- type FieldCommit, type FieldControls, type FieldOptions, type FieldStorage, type FieldStorageMode, type ValueKey,
14
+ type FieldBaseOptions, type FieldCommit, type FieldControls, type FieldOptions, type FieldStorage, type FieldStorageMode,
15
+ type FieldKind, type WidgetHint, type ValueKey,
12
16
  } from './field.js';
13
- export { useBoolField, type BoolFieldControls, type BoolFieldOptions } from './useBoolField.js';
17
+ export { resolveChoice, type OneOfChoice, type OneOfChoiceControls } from './choices.js';
18
+ export { normalizeText } from './normalizeText.js';
19
+ export { useBoolField, boolInitialValue, type BoolFieldControls, type BoolFieldOptions } from './useBoolField.js';
14
20
  export {
15
- useMixedBoolField, aggregateBool, type MixedBool, type MixedBoolFieldControls, type MixedBoolFieldOptions,
21
+ useMixedBoolField, aggregateBool, mixedBoolInitialValue,
22
+ type MixedBool, type MixedBoolFieldControls, type MixedBoolFieldOptions,
16
23
  } from './useMixedBoolField.js';
17
24
  export {
18
- useOneOfField, type OneOfChoice, type OneOfChoiceControls, type OneOfFieldControls, type OneOfFieldOptions,
25
+ useOneOfField, oneOfInitialValue, type OneOfFieldControls, type OneOfFieldOptions, type OneOfOwnOptions,
19
26
  } from './useOneOfField.js';
27
+ export {
28
+ useManyOfField, manyOfInitialValue,
29
+ type ManyOfFieldControls, type ManyOfFieldOptions, type ManyOfOwnOptions, type ManyOfChoiceControls,
30
+ } from './useManyOfField.js';
31
+ export { clamp, resolveNumeric, type NumericBounds, type ResolvedNumeric } from './numeric.js';
32
+ export {
33
+ useNumericField, numericInitialValue,
34
+ type NumericFieldControls, type NumericFieldOptions, type NumericOwnOptions,
35
+ } from './useNumericField.js';
36
+ export {
37
+ useRangeField, rangeInitialValue,
38
+ type NumberRange, type RangeFieldControls, type RangeFieldOptions, type RangeOwnOptions,
39
+ } from './useRangeField.js';
40
+ export {
41
+ normalizeTreeValue, coveredNodes, toggleTreeValue, expandedTreeKeys, filterTree,
42
+ type TreeNode, type TreeNodeControls, type TreeSelection, type TreeExpand,
43
+ } from './tree.js';
44
+ export {
45
+ useTreeField, treeInitialValue,
46
+ type TreeFieldControls, type TreeFieldOptions, type TreeOwnOptions,
47
+ } from './useTreeField.js';
48
+ export {
49
+ useTextField, textInitialValue,
50
+ type TextFieldControls, type TextFieldOptions, type TextOwnOptions,
51
+ } from './useTextField.js';
52
+ export {
53
+ booleanType, stringType, stringSetType, numberType, rangeType, type ValueType,
54
+ } from './valueTypes.js';
55
+ export { initialValueOf, valueTypeOf, sanitizeStored, type FieldDefinition } from './kinds.js';
56
+ export { memoryStore, type ArgStore, type RawChange, type UpdateMethod } from './argStore.js';
57
+ export { localStorageStore } from './localStorageStore.js';
58
+ export { ArgStoreProvider, useArgStore } from './ArgStoreContext.js';
59
+ export {
60
+ useArg, useArgWriter, setArg, resetArg,
61
+ type ArgDefinition, type ArgControls, type ArgChange,
62
+ } from './useArg.js';
63
+ export {
64
+ argOf, useStoredField, useStoredFields, type StoredField, type StoredFields,
65
+ } from './useStoredField.js';
20
66
  export { InternalLinkContext, useInternalLink, type InternalLinkComponent } from './LinkContext.js';
21
67
  export { MessageSinkContext, useMessageSink, type MessageSink } from './MessageSink.js';
68
+ export {
69
+ VultusComponentsContext, useVultusComponents, SpinnerScope, useSpinnerKey,
70
+ type VultusComponents, type SpinnerProps, type SpinnerComponent,
71
+ } from './Components.js';