vultus-core 0.1.1 → 0.2.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 (48) hide show
  1. package/README.md +35 -1
  2. package/dist/MessageSink.d.ts +8 -5
  3. package/dist/MessageSink.d.ts.map +1 -1
  4. package/dist/MessageSink.js.map +1 -1
  5. package/dist/action-core.d.ts +3 -3
  6. package/dist/action-core.d.ts.map +1 -1
  7. package/dist/action-core.js +24 -6
  8. package/dist/action-core.js.map +1 -1
  9. package/dist/decision.d.ts +2 -0
  10. package/dist/decision.d.ts.map +1 -1
  11. package/dist/decision.js +2 -0
  12. package/dist/decision.js.map +1 -1
  13. package/dist/field.d.ts +97 -0
  14. package/dist/field.d.ts.map +1 -0
  15. package/dist/field.js +95 -0
  16. package/dist/field.js.map +1 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/types.d.ts +25 -1
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/useAction.d.ts.map +1 -1
  24. package/dist/useAction.js +4 -4
  25. package/dist/useAction.js.map +1 -1
  26. package/dist/useActionList.d.ts +2 -2
  27. package/dist/useActionList.d.ts.map +1 -1
  28. package/dist/useActionList.js +6 -6
  29. package/dist/useActionList.js.map +1 -1
  30. package/dist/useBoolField.d.ts +17 -0
  31. package/dist/useBoolField.d.ts.map +1 -0
  32. package/dist/useBoolField.js +12 -0
  33. package/dist/useBoolField.js.map +1 -0
  34. package/dist/useMixedBoolField.d.ts +24 -0
  35. package/dist/useMixedBoolField.d.ts.map +1 -0
  36. package/dist/useMixedBoolField.js +25 -0
  37. package/dist/useMixedBoolField.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/MessageSink.tsx +8 -5
  40. package/src/action-core.ts +27 -7
  41. package/src/decision.ts +4 -0
  42. package/src/field.ts +197 -0
  43. package/src/index.ts +8 -0
  44. package/src/types.ts +28 -1
  45. package/src/useAction.ts +5 -5
  46. package/src/useActionList.ts +7 -7
  47. package/src/useBoolField.ts +30 -0
  48. package/src/useMixedBoolField.ts +43 -0
@@ -6,9 +6,9 @@ import { resolveActionFields, makeFirePromise, assembleStatus } from './action-c
6
6
  /**
7
7
  * Build a DYNAMIC list of actions from a spec array — the list counterpart to
8
8
  * useAction. The hook count is fixed regardless of list length (one shared
9
- * error context, one pending/errors map, one memo), so it is usable where
9
+ * error context, one pending/messages map, one memo), so it is usable where
10
10
  * useAction — a per-call hook — cannot be looped (rules of hooks). Pending and
11
- * errors are tracked per action id in the shared maps; each action gets real
11
+ * messages are tracked per action id in the shared maps; each action gets real
12
12
  * per-item pending. The firing machinery + ActionStatus shape are the exact
13
13
  * same shared helpers useAction uses (see action-core.ts) — nothing is
14
14
  * reimplemented.
@@ -20,7 +20,7 @@ export function useActionList(specs, registry) {
20
20
  const { t } = useTranslation();
21
21
  const messageSink = useMessageSink();
22
22
  const [pendingMap, setPendingMap] = useState({});
23
- const [errorsMap, setErrorsMap] = useState({});
23
+ const [messagesMap, setMessagesMap] = useState({});
24
24
  const pendingRef = useRef(pendingMap);
25
25
  pendingRef.current = pendingMap;
26
26
  return useMemo(() => specs.map((opts) => {
@@ -30,13 +30,13 @@ export function useActionList(specs, registry) {
30
30
  reason: fields.reason,
31
31
  getPending: () => !!pendingRef.current[opts.id],
32
32
  setPending: (value) => setPendingMap((m) => ({ ...m, [opts.id]: value })),
33
- setErrors: (errs) => setErrorsMap((m) => ({ ...m, [opts.id]: errs })),
33
+ setMessages: (msgs) => setMessagesMap((m) => ({ ...m, [opts.id]: msgs })),
34
34
  errCtxRef,
35
35
  t,
36
36
  registry,
37
37
  messageSink,
38
38
  });
39
- return assembleStatus(opts, fields, !!pendingMap[opts.id], errorsMap[opts.id] ?? [], firePromise);
40
- }), [specs, pendingMap, errorsMap, registry, t, messageSink]);
39
+ return assembleStatus(opts, fields, !!pendingMap[opts.id], messagesMap[opts.id] ?? [], firePromise);
40
+ }), [specs, pendingMap, messagesMap, registry, t, messageSink]);
41
41
  }
42
42
  //# sourceMappingURL=useActionList.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useActionList.js","sourceRoot":"","sources":["../src/useActionList.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGxF;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,KAA+C,EAC/C,QAAuC;IAEvC,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IACnC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;IACjC,SAAS,CAAC,OAAO,GAAG,MAAM,CAAC;IAC3B,MAAM,EAAE,CAAC,EAAE,GAAG,cAAc,EAAE,CAAC;IAC/B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IAErC,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,QAAQ,CAA0B,EAAE,CAAC,CAAC;IAC1E,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAA2B,EAAE,CAAC,CAAC;IACzE,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACtC,UAAU,CAAC,OAAO,GAAG,UAAU,CAAC;IAEhC,OAAO,OAAO,CACZ,GAAG,EAAE,CACH,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACjB,MAAM,MAAM,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;QACzC,MAAM,WAAW,GAAG,eAAe,CAAC,IAAI,EAAE;YACxC,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,UAAU,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;YACzE,SAAS,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;YACrE,SAAS;YACT,CAAC;YACD,QAAQ;YACR,WAAW;SACZ,CAAC,CAAC;QACH,OAAO,cAAc,CACnB,IAAI,EACJ,MAAM,EACN,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,EACrB,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,EACxB,WAAW,CACZ,CAAC;IACJ,CAAC,CAAC,EACJ,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,EAAE,WAAW,CAAC,CACzD,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"useActionList.js","sourceRoot":"","sources":["../src/useActionList.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAGxF;;;;;;;;;GASG;AACH,MAAM,UAAU,aAAa,CAC3B,KAA+C,EAC/C,QAAuC;IAEvC,MAAM,MAAM,GAAG,iBAAiB,EAAE,CAAC;IACnC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;IACjC,SAAS,CAAC,OAAO,GAAG,MAAM,CAAC;IAC3B,MAAM,EAAE,CAAC,EAAE,GAAG,cAAc,EAAE,CAAC;IAC/B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IAErC,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,QAAQ,CAA0B,EAAE,CAAC,CAAC;IAC1E,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAA4B,EAAE,CAAC,CAAC;IAC9E,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACtC,UAAU,CAAC,OAAO,GAAG,UAAU,CAAC;IAEhC,OAAO,OAAO,CACZ,GAAG,EAAE,CACH,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACjB,MAAM,MAAM,GAAG,mBAAmB,CAAC,IAAI,CAAC,CAAC;QACzC,MAAM,WAAW,GAAG,eAAe,CAAC,IAAI,EAAE;YACxC,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,UAAU,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/C,UAAU,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;YACzE,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC;YACzE,SAAS;YACT,CAAC;YACD,QAAQ;YACR,WAAW;SACZ,CAAC,CAAC;QACH,OAAO,cAAc,CACnB,IAAI,EACJ,MAAM,EACN,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,EACrB,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,EAC1B,WAAW,CACZ,CAAC;IACJ,CAAC,CAAC,EACJ,CAAC,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,QAAQ,EAAE,CAAC,EAAE,WAAW,CAAC,CAC3D,CAAC;AACJ,CAAC"}
@@ -0,0 +1,17 @@
1
+ import { type FieldControls, type FieldOptions } from './field.js';
2
+ import type { ErrorRoutesRegistry } from './types.js';
3
+ export type BoolFieldOptions<RouteId extends string = string> = FieldOptions<boolean, boolean, RouteId>;
4
+ export interface BoolFieldControls extends FieldControls<boolean, boolean> {
5
+ /**
6
+ * Request the opposite value. Programmatic use; widgets obtain the field's
7
+ * confirmation for `!value` first, then call `setValue`.
8
+ */
9
+ toggle(): void;
10
+ }
11
+ /**
12
+ * A boolean field (moxb's Bool, the sidedao boolean field). Rendered by
13
+ * vultus-antd's BoolCheckbox and BoolSwitch, which take the same controls.
14
+ * Inside the field the value starts at `initialValue` (default false).
15
+ */
16
+ export declare function useBoolField<RouteId extends string = string>(opts: BoolFieldOptions<RouteId>, registry?: ErrorRoutesRegistry<RouteId>): BoolFieldControls;
17
+ //# sourceMappingURL=useBoolField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useBoolField.d.ts","sourceRoot":"","sources":["../src/useBoolField.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD,MAAM,MAAM,gBAAgB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAExG,MAAM,WAAW,iBAAkB,SAAQ,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC;IACxE;;;OAGG;IACH,MAAM,IAAI,IAAI,CAAC;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,EAC1D,IAAI,EAAE,gBAAgB,CAAC,OAAO,CAAC,EAC/B,QAAQ,CAAC,EAAE,mBAAmB,CAAC,OAAO,CAAC,GACtC,iBAAiB,CAQnB"}
@@ -0,0 +1,12 @@
1
+ import { useFieldCore } from './field.js';
2
+ /**
3
+ * A boolean field (moxb's Bool, the sidedao boolean field). Rendered by
4
+ * vultus-antd's BoolCheckbox and BoolSwitch, which take the same controls.
5
+ * Inside the field the value starts at `initialValue` (default false).
6
+ */
7
+ export function useBoolField(opts, registry) {
8
+ const inside = !opts.onChange && !opts.commit;
9
+ const field = useFieldCore('boolean', inside ? { ...opts, initialValue: opts.initialValue ?? false } : opts, registry);
10
+ return { ...field, toggle: () => field.setValue(!field.value) };
11
+ }
12
+ //# sourceMappingURL=useBoolField.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useBoolField.js","sourceRoot":"","sources":["../src/useBoolField.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAyC,MAAM,YAAY,CAAC;AAajF;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAC1B,IAA+B,EAC/B,QAAuC;IAEvC,MAAM,MAAM,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;IAC9C,MAAM,KAAK,GAAG,YAAY,CACxB,SAAS,EACT,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EACrE,QAAQ,CACT,CAAC;IACF,OAAO,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC;AAClE,CAAC"}
@@ -0,0 +1,24 @@
1
+ import { type FieldControls, type FieldOptions } from './field.js';
2
+ import type { ErrorRoutesRegistry } from './types.js';
3
+ /** What a mixed boolean field shows: on, off, or some of its items on and some off. */
4
+ export type MixedBool = boolean | 'mixed';
5
+ export type MixedBoolFieldOptions<RouteId extends string = string> = FieldOptions<MixedBool, boolean, RouteId>;
6
+ export interface MixedBoolFieldControls extends FieldControls<MixedBool, boolean> {
7
+ /** Request the opposite: off when on, on when off or mixed. Programmatic use, as in BoolFieldControls. */
8
+ toggle(): void;
9
+ }
10
+ /**
11
+ * A boolean field over an aggregate (moxb's indeterminate Bool, ARIA's
12
+ * "mixed"): it shows true, false or 'mixed', and can be asked for true or
13
+ * false only. Rendered by the same widgets as useBoolField: BoolSwitch puts
14
+ * its knob in the middle, BoolCheckbox is indeterminate. Derive the value with
15
+ * `aggregateBool`; with no items at all, disable the field with a reason.
16
+ */
17
+ export declare function useMixedBoolField<RouteId extends string = string>(opts: MixedBoolFieldOptions<RouteId>, registry?: ErrorRoutesRegistry<RouteId>): MixedBoolFieldControls;
18
+ /**
19
+ * The aggregate of several booleans: true when all are on, false when none is,
20
+ * 'mixed' otherwise; `undefined` when there are none (a case for disabling the
21
+ * field, not a value).
22
+ */
23
+ export declare function aggregateBool(values: readonly boolean[]): MixedBool | undefined;
24
+ //# sourceMappingURL=useMixedBoolField.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMixedBoolField.d.ts","sourceRoot":"","sources":["../src/useMixedBoolField.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AACjF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtD,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;AAE1C,MAAM,MAAM,qBAAqB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,YAAY,CAAC,SAAS,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;AAE/G,MAAM,WAAW,sBAAuB,SAAQ,aAAa,CAAC,SAAS,EAAE,OAAO,CAAC;IAC/E,0GAA0G;IAC1G,MAAM,IAAI,IAAI,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/D,IAAI,EAAE,qBAAqB,CAAC,OAAO,CAAC,EACpC,QAAQ,CAAC,EAAE,mBAAmB,CAAC,OAAO,CAAC,GACtC,sBAAsB,CAQxB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,GAAG,SAAS,GAAG,SAAS,CAI/E"}
@@ -0,0 +1,25 @@
1
+ import { useFieldCore } from './field.js';
2
+ /**
3
+ * A boolean field over an aggregate (moxb's indeterminate Bool, ARIA's
4
+ * "mixed"): it shows true, false or 'mixed', and can be asked for true or
5
+ * false only. Rendered by the same widgets as useBoolField: BoolSwitch puts
6
+ * its knob in the middle, BoolCheckbox is indeterminate. Derive the value with
7
+ * `aggregateBool`; with no items at all, disable the field with a reason.
8
+ */
9
+ export function useMixedBoolField(opts, registry) {
10
+ const inside = !opts.onChange && !opts.commit;
11
+ const field = useFieldCore('mixed-boolean', inside ? { ...opts, initialValue: opts.initialValue ?? false } : opts, registry);
12
+ return { ...field, toggle: () => field.setValue(field.value !== true) };
13
+ }
14
+ /**
15
+ * The aggregate of several booleans: true when all are on, false when none is,
16
+ * 'mixed' otherwise; `undefined` when there are none (a case for disabling the
17
+ * field, not a value).
18
+ */
19
+ export function aggregateBool(values) {
20
+ if (values.length === 0)
21
+ return undefined;
22
+ const on = values.filter(Boolean).length;
23
+ return on === values.length ? true : on === 0 ? false : 'mixed';
24
+ }
25
+ //# sourceMappingURL=useMixedBoolField.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useMixedBoolField.js","sourceRoot":"","sources":["../src/useMixedBoolField.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAyC,MAAM,YAAY,CAAC;AAajF;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAC/B,IAAoC,EACpC,QAAuC;IAEvC,MAAM,MAAM,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC;IAC9C,MAAM,KAAK,GAAG,YAAY,CACxB,eAAe,EACf,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,YAAY,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EACrE,QAAQ,CACT,CAAC;IACF,OAAO,EAAE,GAAG,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,KAAK,IAAI,CAAC,EAAE,CAAC;AAC1E,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,MAA0B;IACtD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IAC1C,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;IACzC,OAAO,EAAE,KAAK,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC;AAClE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vultus-core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Framework-neutral action model + hooks for vultus (antd-free by design)",
6
6
  "repository": {
@@ -1,12 +1,15 @@
1
1
  import { createContext, useContext } from 'react';
2
+ import type { Severity } from './types.js';
2
3
 
3
4
  /**
4
- * How the action framework surfaces an otherwise-unhandled error (no form field,
5
- * no inline error target). Default is a no-op so vultus-core stays antd-free and
6
- * framework-neutral; vultus-antd's VultusProvider wires this to antd's
7
- * message.error. Hosts may inject any toast/notification mechanism.
5
+ * How the action framework surfaces messages: an otherwise-unhandled error (no
6
+ * form field, no inline error target), and the messages an action reports
7
+ * through its ExecutionContext. `severity` defaults to 'error'. Default is a
8
+ * no-op so vultus-core stays antd-free and framework-neutral; vultus-antd's
9
+ * VultusProvider wires this to antd's message API. Hosts may inject any
10
+ * toast/notification mechanism.
8
11
  */
9
- export type MessageSink = (text: string) => void;
12
+ export type MessageSink = (text: string, severity?: Severity) => void;
10
13
 
11
14
  export const MessageSinkContext = createContext<MessageSink>(() => {});
12
15
 
@@ -12,6 +12,9 @@ import type {
12
12
  ConfirmationSpec,
13
13
  Decision,
14
14
  ErrorRoutesRegistry,
15
+ ExecutionContext,
16
+ Message,
17
+ Severity,
15
18
  } from './types.js';
16
19
 
17
20
  /**
@@ -49,7 +52,7 @@ export interface FirePromiseDeps<RouteId extends string> {
49
52
  reason: string | undefined;
50
53
  getPending: () => boolean;
51
54
  setPending: (value: boolean) => void;
52
- setErrors: (errors: string[]) => void;
55
+ setMessages: (messages: Message[]) => void;
53
56
  errCtxRef: MutableRefObject<ActionErrorCtx | null>;
54
57
  t: (key: string, opts?: Record<string, unknown>) => string;
55
58
  registry?: ErrorRoutesRegistry<RouteId>;
@@ -70,13 +73,26 @@ export function makeFirePromise<TArgs, TResult, RouteId extends string>(
70
73
  console.warn(`Action '${opts.id}': already pending — ignoring fire`);
71
74
  return;
72
75
  }
73
- deps.setErrors([]);
76
+ // Messages of this run: reported through the context while it runs, then
77
+ // the thrown error if it fails. Each report updates the status at once.
78
+ const messages: Message[] = [];
79
+ const report = (severity: Severity) => (text: string) => {
80
+ messages.push({ text, severity });
81
+ deps.setMessages([...messages]);
82
+ deps.messageSink?.(text, severity);
83
+ };
84
+ const ctx: ExecutionContext = { info: report('info'), warn: report('warning'), error: report('error') };
85
+ deps.setMessages([]);
74
86
  deps.setPending(true);
75
87
  try {
76
- const result = await opts.fire(args);
88
+ const result = await opts.fire(args, ctx);
77
89
  opts.onSuccess?.(result as TResult);
78
90
  } catch (err) {
91
+ const parsed = parseApiError(err);
92
+ const text = parsed.message ?? 'Action failed';
79
93
  if (opts.errorRoute && deps.registry) {
94
+ // A routed action: routeApiError decides where the error shows (a form
95
+ // field, the inline error, or the sink).
80
96
  routeApiError(
81
97
  err,
82
98
  {
@@ -88,9 +104,12 @@ export function makeFirePromise<TArgs, TResult, RouteId extends string>(
88
104
  },
89
105
  deps.registry,
90
106
  );
107
+ } else {
108
+ // No route: the sink shows it, like the messages reported through ctx.
109
+ deps.messageSink?.(text, 'error');
91
110
  }
92
- const parsed = parseApiError(err);
93
- deps.setErrors([parsed.message ?? 'Action failed']);
111
+ messages.push({ text, severity: 'error' });
112
+ deps.setMessages([...messages]);
94
113
  } finally {
95
114
  deps.setPending(false);
96
115
  }
@@ -102,7 +121,7 @@ export function assembleStatus<TArgs>(
102
121
  opts: ActionOptions<TArgs, any, any>,
103
122
  fields: ResolvedFields,
104
123
  pending: boolean,
105
- errors: string[],
124
+ messages: Message[],
106
125
  firePromise: (args: TArgs) => Promise<void>,
107
126
  ): ActionStatus<TArgs> {
108
127
  const fire = (args: TArgs) => {
@@ -118,7 +137,8 @@ export function assembleStatus<TArgs>(
118
137
  reason: fields.reason,
119
138
  invisible: fields.invisible,
120
139
  confirmation: fields.confirmation,
121
- errors,
140
+ messages,
141
+ errors: messages.filter((m) => m.severity === 'error').map((m) => m.text),
122
142
  fire: fire as ActionStatus<TArgs>['fire'],
123
143
  firePromise: firePromise as ActionStatus<TArgs>['firePromise'],
124
144
  };
package/src/decision.ts CHANGED
@@ -19,3 +19,7 @@ export const andDecisions = (a: Decision, b: Decision): Decision => {
19
19
  }
20
20
  return av ? b : a;
21
21
  };
22
+
23
+ /** The opposite verdict, keeping the reason (a reason to disable is a reason not to enable). */
24
+ export const invertDecision = (d: Decision): Decision =>
25
+ typeof d === 'boolean' ? !d : { verdict: !d.verdict, reason: d.reason };
package/src/field.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { useState } from 'react';
2
+ import { getReason, getVerdict, invertDecision } from './decision.js';
3
+ import { resolve } from './resolve.js';
4
+ import { useAction } from './useAction.js';
5
+ import type {
6
+ ConfirmationSpec,
7
+ Decision,
8
+ ErrorRoutesRegistry,
9
+ ExecutionContext,
10
+ Message,
11
+ ValueOrFn,
12
+ } from './types.js';
13
+
14
+ /**
15
+ * Fields: the value half of vultus, on the pattern of the sidedao InputFields.
16
+ * A field hook (useBoolField, later others) is built on useFieldCore by
17
+ * composition, never by inheritance. A field's value lives either inside the
18
+ * field (its own state) or outside it (the caller passes it every render);
19
+ * from outside it is changed either locally (`onChange`, e.g. a form) or by an
20
+ * immediate commit (`commit`), whose setter runs as an action.
21
+ * Design notes: unitas docs/2026-10-05-vultus-field-model-design.md.
22
+ */
23
+
24
+ /** How an immediately committing field stores a change: an action that receives the requested value. */
25
+ export interface FieldCommit<TRequest, RouteId extends string = string> {
26
+ fire: (next: TRequest, ctx: ExecutionContext) => Promise<void> | void;
27
+ /** Confirmation before committing; may depend on the requested value (e.g. only when switching off). */
28
+ confirmation?: ConfirmationSpec | ((next: TRequest) => ConfirmationSpec | undefined);
29
+ errorRoute?: RouteId;
30
+ }
31
+
32
+ /** The key of a value in `valueDescriptions`: 'true' / 'false' for booleans, the value itself for strings. */
33
+ export type ValueKey<T> = T extends boolean ? `${T}` : T extends string | number ? `${T}` : never;
34
+
35
+ interface FieldBaseOptions<TValue> {
36
+ id: string;
37
+ label?: ValueOrFn<string>;
38
+ description?: ValueOrFn<string | undefined>;
39
+ /** Shown? Default true. Use `visible` or `hidden`, not contradicting each other. */
40
+ visible?: ValueOrFn<boolean>;
41
+ hidden?: ValueOrFn<boolean>;
42
+ /** Changeable? Default true, optionally with a reason. Use `enabled` or `disabled`, not contradicting each other. */
43
+ enabled?: ValueOrFn<Decision>;
44
+ disabled?: ValueOrFn<Decision>;
45
+ /**
46
+ * What each value means right now, keyed by value (for booleans `true` / `false`, and `mixed`
47
+ * where the field can show it). Shown with the current value, next to `description`, which
48
+ * says what the field is in general.
49
+ */
50
+ valueDescriptions?: Partial<Record<ValueKey<TValue>, string>>;
51
+ }
52
+
53
+ /**
54
+ * Where the value lives, and what setting it does. Exactly one of the three. The field shows a
55
+ * `TValue` and can be asked for a `TRequest`; usually the same type, but an aggregate can show
56
+ * a state that cannot be requested (a boolean field showing 'mixed').
57
+ */
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 }
61
+ /** Outside, changed locally: the caller owns the value and applies `onChange`. */
62
+ | { value: TValue; onChange: (value: TRequest) => void; initialValue?: never; commit?: never }
63
+ /** Outside, committed immediately: `commit.fire` stores the change (pending, messages, confirmation). */
64
+ | { value: TValue; commit: FieldCommit<TRequest, RouteId>; initialValue?: never; onChange?: never };
65
+
66
+ export type FieldOptions<TValue, TRequest = TValue, RouteId extends string = string> =
67
+ FieldBaseOptions<TValue> & FieldStorage<TValue, TRequest, RouteId>;
68
+
69
+ /** What a field widget receives: everything it shows, and the one way to change the value. */
70
+ export interface FieldControls<TValue, TRequest = TValue> {
71
+ type: string;
72
+ id: string;
73
+ label: string;
74
+ description: string | undefined;
75
+ /** What the current value means (from `valueDescriptions`). */
76
+ valueDescription: string | undefined;
77
+ visible: boolean;
78
+ enabled: boolean;
79
+ whyDisabled: string | undefined;
80
+ value: TValue;
81
+ /** Request a new value. Ignored (with a warning) when disabled or while a commit is pending. */
82
+ setValue(next: TRequest): void;
83
+ /** A commit is running. */
84
+ pending: boolean;
85
+ /** Messages of the last commit. */
86
+ messages: Message[];
87
+ /** The confirmation a widget must obtain before requesting `next`, if any. */
88
+ confirmationFor(next: TRequest): ConfirmationSpec | undefined;
89
+ }
90
+
91
+ export type FieldStorageMode = 'inside' | 'local' | 'commit';
92
+
93
+ export function fieldStorageMode(id: string, opts: FieldStorage<unknown, unknown, string>): FieldStorageMode {
94
+ const { initialValue, value, onChange, commit } = opts as {
95
+ initialValue?: unknown; value?: unknown; onChange?: unknown; commit?: unknown;
96
+ };
97
+ if (commit && onChange) throw new Error(`Field '${id}': "commit" and "onChange" are mutually exclusive`);
98
+ if ((commit || onChange) && initialValue !== undefined) {
99
+ throw new Error(`Field '${id}': "initialValue" is for a value inside the field; with "${commit ? 'commit' : 'onChange'}" the caller passes "value"`);
100
+ }
101
+ if (commit) return 'commit';
102
+ if (onChange) return 'local';
103
+ if (value !== undefined) throw new Error(`Field '${id}': "value" needs "onChange" or "commit"`);
104
+ return 'inside';
105
+ }
106
+
107
+ export function calculateVisible(id: string, visible: boolean | undefined, hidden: boolean | undefined): boolean {
108
+ if (visible === undefined) return hidden === undefined ? true : !hidden;
109
+ if (hidden === undefined || visible !== hidden) return visible;
110
+ throw new Error(`Field '${id}': "visible" and "hidden" contradict each other`);
111
+ }
112
+
113
+ export function calculateEnabled(id: string, enabled: Decision | undefined, disabled: Decision | undefined): Decision {
114
+ if (enabled === undefined) return disabled === undefined ? true : invertDecision(disabled);
115
+ if (disabled === undefined) return enabled;
116
+ if (getVerdict(enabled, false) !== getVerdict(disabled, false)) {
117
+ return { verdict: getVerdict(enabled, false), reason: getReason(disabled) ?? getReason(enabled) };
118
+ }
119
+ throw new Error(`Field '${id}': "enabled" and "disabled" contradict each other`);
120
+ }
121
+
122
+ /**
123
+ * The shared core of every field hook: label and description, visibility and
124
+ * 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).
126
+ */
127
+ export function useFieldCore<TValue, TRequest extends TValue = TValue, RouteId extends string = string>(
128
+ type: string,
129
+ opts: FieldOptions<TValue, TRequest, RouteId>,
130
+ registry?: ErrorRoutesRegistry<RouteId>,
131
+ ): FieldControls<TValue, TRequest> {
132
+ const mode = fieldStorageMode(opts.id, opts as FieldStorage<unknown, unknown, string>);
133
+ const [inner, setInner] = useState<TValue>(opts.initialValue as TValue);
134
+
135
+ const label = resolve(opts.label, '');
136
+ const description = resolve<string | undefined>(opts.description, undefined);
137
+ const visible = calculateVisible(
138
+ opts.id,
139
+ resolve<boolean | undefined>(opts.visible, undefined),
140
+ resolve<boolean | undefined>(opts.hidden, undefined),
141
+ );
142
+ const enabledDecision = calculateEnabled(
143
+ opts.id,
144
+ resolve<Decision | undefined>(opts.enabled, undefined),
145
+ resolve<Decision | undefined>(opts.disabled, undefined),
146
+ );
147
+ const enabled = getVerdict(enabledDecision, true);
148
+ const whyDisabled = enabled ? undefined : getReason(enabledDecision);
149
+
150
+ // Hooks run unconditionally; the setter action is only fired in commit mode.
151
+ const setter = useAction<TRequest, void, RouteId>(
152
+ {
153
+ id: `${opts.id}.set`,
154
+ label,
155
+ enabled: enabledDecision,
156
+ invisible: !visible,
157
+ errorRoute: opts.commit?.errorRoute,
158
+ fire: (next, ctx) => opts.commit?.fire(next, ctx),
159
+ },
160
+ registry,
161
+ );
162
+
163
+ const value = mode === 'inside' ? inner : (opts.value as TValue);
164
+ const pending = mode === 'commit' && setter.pending;
165
+ const valueDescription = (opts.valueDescriptions as Record<string, string | undefined> | undefined)?.[String(value)];
166
+
167
+ const setValue = (next: TRequest) => {
168
+ if (!enabled) {
169
+ console.warn(`Field '${opts.id}': disabled — ${whyDisabled ?? '(no reason)'}`);
170
+ return;
171
+ }
172
+ if (mode === 'inside') setInner(next);
173
+ else if (mode === 'local') opts.onChange!(next);
174
+ else (setter.fire as (args: TRequest) => void)(next);
175
+ };
176
+
177
+ const confirmationFor = (next: TRequest): ConfirmationSpec | undefined => {
178
+ const spec = opts.commit?.confirmation;
179
+ return typeof spec === 'function' ? spec(next) : spec;
180
+ };
181
+
182
+ return {
183
+ type,
184
+ id: opts.id,
185
+ label,
186
+ description,
187
+ valueDescription,
188
+ visible,
189
+ enabled,
190
+ whyDisabled,
191
+ value,
192
+ setValue,
193
+ pending,
194
+ messages: mode === 'commit' ? (setter.messages ?? []) : [],
195
+ confirmationFor,
196
+ };
197
+ }
package/src/index.ts CHANGED
@@ -6,5 +6,13 @@ 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
+ useFieldCore, fieldStorageMode, calculateVisible, calculateEnabled,
11
+ type FieldCommit, type FieldControls, type FieldOptions, type FieldStorage, type FieldStorageMode, type ValueKey,
12
+ } from './field.js';
13
+ export { useBoolField, type BoolFieldControls, type BoolFieldOptions } from './useBoolField.js';
14
+ export {
15
+ useMixedBoolField, aggregateBool, type MixedBool, type MixedBoolFieldControls, type MixedBoolFieldOptions,
16
+ } from './useMixedBoolField.js';
9
17
  export { InternalLinkContext, useInternalLink, type InternalLinkComponent } from './LinkContext.js';
10
18
  export { MessageSinkContext, useMessageSink, type MessageSink } from './MessageSink.js';
package/src/types.ts CHANGED
@@ -12,6 +12,26 @@ export interface FormLike {
12
12
  export type Decision = boolean | { verdict: boolean; reason?: string };
13
13
  export type ValueOrFn<T> = T | (() => T);
14
14
 
15
+ export type Severity = 'info' | 'warning' | 'error';
16
+
17
+ /** Something an action (or, later, a field's validation) has to say, with its severity. */
18
+ export interface Message {
19
+ text: string;
20
+ severity: Severity;
21
+ }
22
+
23
+ /**
24
+ * What an action's `fire` receives next to its arguments: a way to report
25
+ * messages while it runs (absorbed from the sidedao InputFields). Messages land
26
+ * on the action's status (`messages`) and go to the MessageSink. A thrown
27
+ * error still fails the action; `error()` reports an error without throwing.
28
+ */
29
+ export interface ExecutionContext {
30
+ info(text: string): void;
31
+ warn(text: string): void;
32
+ error(text: string): void;
33
+ }
34
+
15
35
  export type ConfirmationSpec =
16
36
  | { kind: 'popconfirm'; question: string }
17
37
  | { kind: 'cascade-modal'; title: string; content: React.ReactNode }
@@ -26,7 +46,7 @@ export interface ActionOptions<TArgs = void, TResult = void, RouteId extends str
26
46
  invisible?: ValueOrFn<boolean>;
27
47
  confirmation?: ValueOrFn<ConfirmationSpec | undefined>;
28
48
  errorRoute?: RouteId;
29
- fire: (args: TArgs) => Promise<TResult> | TResult;
49
+ fire: (args: TArgs, ctx: ExecutionContext) => Promise<TResult> | TResult;
30
50
  onSuccess?: (result: TResult) => void;
31
51
  }
32
52
 
@@ -45,6 +65,13 @@ export interface ActionStatus<TArgs = void> {
45
65
  reason?: string;
46
66
  invisible: boolean;
47
67
  confirmation?: ConfirmationSpec;
68
+ /**
69
+ * Messages of the last run: reported through the ExecutionContext, or the thrown error.
70
+ * Optional so hand-built statuses (adapters, test fixtures) stay valid; read it as `[]` when
71
+ * absent. Statuses from useAction and useActionList always carry it.
72
+ */
73
+ messages?: Message[];
74
+ /** The error texts among `messages` (kept for callers that only show errors). */
48
75
  errors: string[];
49
76
  fire(...args: FireArgs<TArgs>): void;
50
77
  firePromise(...args: FireArgs<TArgs>): Promise<void>;
package/src/useAction.ts CHANGED
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
3
3
  import { useActionErrorCtx } from './useActionErrorCtx.js';
4
4
  import { useMessageSink } from './MessageSink.js';
5
5
  import { resolveActionFields, makeFirePromise, assembleStatus } from './action-core.js';
6
- import type { ActionOptions, ActionStatus, ErrorRoutesRegistry } from './types.js';
6
+ import type { ActionOptions, ActionStatus, ErrorRoutesRegistry, Message } from './types.js';
7
7
 
8
8
  export function useAction<TArgs = void, TResult = void, RouteId extends string = string>(
9
9
  opts: ActionOptions<TArgs, TResult, RouteId>,
@@ -16,7 +16,7 @@ export function useAction<TArgs = void, TResult = void, RouteId extends string =
16
16
  const messageSink = useMessageSink();
17
17
 
18
18
  const [pending, setPending] = useState(false);
19
- const [errors, setErrors] = useState<string[]>([]);
19
+ const [messages, setMessages] = useState<Message[]>([]);
20
20
  const pendingRef = useRef(pending);
21
21
  pendingRef.current = pending;
22
22
 
@@ -29,7 +29,7 @@ export function useAction<TArgs = void, TResult = void, RouteId extends string =
29
29
  reason: fields.reason,
30
30
  getPending: () => pendingRef.current,
31
31
  setPending,
32
- setErrors,
32
+ setMessages,
33
33
  errCtxRef,
34
34
  t,
35
35
  registry,
@@ -42,7 +42,7 @@ export function useAction<TArgs = void, TResult = void, RouteId extends string =
42
42
  // primitives below, not on the whole `opts`/`fields` objects (which are
43
43
  // fresh each render and would defeat the memo).
44
44
  return useMemo<ActionStatus<TArgs>>(
45
- () => assembleStatus(opts, fields, pending, errors, firePromise),
45
+ () => assembleStatus(opts, fields, pending, messages, firePromise),
46
46
  // eslint-disable-next-line react-hooks/exhaustive-deps
47
47
  [
48
48
  opts.id,
@@ -54,7 +54,7 @@ export function useAction<TArgs = void, TResult = void, RouteId extends string =
54
54
  fields.reason,
55
55
  fields.invisible,
56
56
  fields.confirmation,
57
- errors,
57
+ messages,
58
58
  firePromise,
59
59
  ],
60
60
  );
@@ -3,14 +3,14 @@ import { useTranslation } from 'react-i18next';
3
3
  import { useActionErrorCtx } from './useActionErrorCtx.js';
4
4
  import { useMessageSink } from './MessageSink.js';
5
5
  import { resolveActionFields, makeFirePromise, assembleStatus } from './action-core.js';
6
- import type { ActionOptions, ActionStatus, ErrorRoutesRegistry } from './types.js';
6
+ import type { ActionOptions, ActionStatus, ErrorRoutesRegistry, Message } from './types.js';
7
7
 
8
8
  /**
9
9
  * Build a DYNAMIC list of actions from a spec array — the list counterpart to
10
10
  * useAction. The hook count is fixed regardless of list length (one shared
11
- * error context, one pending/errors map, one memo), so it is usable where
11
+ * error context, one pending/messages map, one memo), so it is usable where
12
12
  * useAction — a per-call hook — cannot be looped (rules of hooks). Pending and
13
- * errors are tracked per action id in the shared maps; each action gets real
13
+ * messages are tracked per action id in the shared maps; each action gets real
14
14
  * per-item pending. The firing machinery + ActionStatus shape are the exact
15
15
  * same shared helpers useAction uses (see action-core.ts) — nothing is
16
16
  * reimplemented.
@@ -26,7 +26,7 @@ export function useActionList<TArgs = void, TResult = void, RouteId extends stri
26
26
  const messageSink = useMessageSink();
27
27
 
28
28
  const [pendingMap, setPendingMap] = useState<Record<string, boolean>>({});
29
- const [errorsMap, setErrorsMap] = useState<Record<string, string[]>>({});
29
+ const [messagesMap, setMessagesMap] = useState<Record<string, Message[]>>({});
30
30
  const pendingRef = useRef(pendingMap);
31
31
  pendingRef.current = pendingMap;
32
32
 
@@ -39,7 +39,7 @@ export function useActionList<TArgs = void, TResult = void, RouteId extends stri
39
39
  reason: fields.reason,
40
40
  getPending: () => !!pendingRef.current[opts.id],
41
41
  setPending: (value) => setPendingMap((m) => ({ ...m, [opts.id]: value })),
42
- setErrors: (errs) => setErrorsMap((m) => ({ ...m, [opts.id]: errs })),
42
+ setMessages: (msgs) => setMessagesMap((m) => ({ ...m, [opts.id]: msgs })),
43
43
  errCtxRef,
44
44
  t,
45
45
  registry,
@@ -49,10 +49,10 @@ export function useActionList<TArgs = void, TResult = void, RouteId extends stri
49
49
  opts,
50
50
  fields,
51
51
  !!pendingMap[opts.id],
52
- errorsMap[opts.id] ?? [],
52
+ messagesMap[opts.id] ?? [],
53
53
  firePromise,
54
54
  );
55
55
  }),
56
- [specs, pendingMap, errorsMap, registry, t, messageSink],
56
+ [specs, pendingMap, messagesMap, registry, t, messageSink],
57
57
  );
58
58
  }