akanjs 3.0.0-alpha.36 → 3.0.0-alpha.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.0.0-alpha.36",
3
+ "version": "3.0.0-alpha.38",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/store/action.ts CHANGED
@@ -316,6 +316,11 @@ export const makeFormSetter = (refName: string, fetch: FetchProxy<any>) => {
316
316
  : (value as object);
317
317
  (state[names.modelForm] as { [key: string]: any })[namesOfField.field] = setValue;
318
318
  });
319
+
320
+ const postSet = (this as unknown as { [key: string]: ((value: unknown) => unknown) | undefined })[
321
+ namesOfField.postSetField
322
+ ];
323
+ if (postSet) void postSet.call(this, value);
319
324
  },
320
325
  ...(field.isArray
321
326
  ? {
@@ -248,6 +248,15 @@ export class ScreenReader {
248
248
  if (href && href !== "#" && !href.startsWith("javascript:") && text !== href) this.#buffer += ` (${href})`;
249
249
  }
250
250
 
251
+ /**
252
+ * A control the person cannot use publishes no tool, so saying so here is what turns a silent refusal into a
253
+ * fact the agent could have read. It reads `aria-disabled` too: a styled-off div carries no native property.
254
+ */
255
+ static #off(el: HTMLElement) {
256
+ const native = (el as HTMLInputElement | HTMLButtonElement).disabled;
257
+ return native || el.getAttribute("aria-disabled") === "true" ? " (disabled)" : "";
258
+ }
259
+
251
260
  #button(el: HTMLElement) {
252
261
  const before = this.#buffer;
253
262
  this.#walkChildren(el);
@@ -255,7 +264,7 @@ export class ScreenReader {
255
264
  this.#buffer = before;
256
265
  const label = inner || el.getAttribute("aria-label") || "";
257
266
  const action = el.getAttribute("data-akan-action");
258
- if (label || action) this.#buffer += ` [button: ${label}${action ? ` → ${action}` : ""}]`;
267
+ if (label || action) this.#buffer += ` [button${ScreenReader.#off(el)}: ${label}${action ? ` → ${action}` : ""}]`;
259
268
  }
260
269
 
261
270
  #control(el: HTMLElement, tag: string) {
@@ -268,12 +277,13 @@ export class ScreenReader {
268
277
  el.getAttribute("placeholder") ??
269
278
  el.getAttribute("name") ??
270
279
  type;
280
+ const off = ScreenReader.#off(el);
271
281
  if (type === "password") {
272
- this.#buffer += ` [input ${name}]`;
282
+ this.#buffer += ` [input ${name}${off}]`;
273
283
  return;
274
284
  }
275
285
  if (type === "checkbox" || type === "radio") {
276
- this.#buffer += ` [${type} ${name}: ${input.checked ? "on" : "off"}]`;
286
+ this.#buffer += ` [${type} ${name}${off}: ${input.checked ? "on" : "off"}]`;
277
287
  return;
278
288
  }
279
289
  const raw =
@@ -282,7 +292,7 @@ export class ScreenReader {
282
292
  (el as unknown as HTMLSelectElement).value)
283
293
  : input.value;
284
294
  const value = (raw ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
285
- this.#buffer += ` [${tag === "SELECT" ? "select" : "input"} ${name}: ${JSON.stringify(value)}]`;
295
+ this.#buffer += ` [${tag === "SELECT" ? "select" : "input"} ${name}${off}: ${JSON.stringify(value)}]`;
286
296
  }
287
297
 
288
298
  #pre(el: HTMLElement) {
@@ -89,24 +89,77 @@ const rowEntries = (ref: FormFieldRef, arraySchema: JsonSchema): ToolEntry[] =>
89
89
  ];
90
90
  };
91
91
 
92
+ export interface FieldToolOptions {
93
+ /** The control's own normalizer. Applied to the agent's write exactly as it is to the person's typing. */
94
+ transform?: unknown;
95
+ /** True while the person cannot use the control. Publishes nothing, so the agent gets no lever the screen withholds. */
96
+ disabled?: boolean;
97
+ /** The person can drag entries into a new order, so `move<Field>On<Model>` is a lever the screen really has. */
98
+ sortable?: boolean;
99
+ }
100
+
101
+ /**
102
+ * Reorder-by-position for a list the person can drag, beside the whole-array setter.
103
+ *
104
+ * The drag is the lever the screen actually offers, and it changes no row's content — so an agent asked to move one
105
+ * row should not have to retype the nine it is leaving alone, which is the same argument that gives an embedded-row
106
+ * array its `add`/`sub`. There is no store action behind it: reordering *is* a whole-array write, so this splices
107
+ * the live rows and hands them to the setter the drag hands them to, `transform` deliberately not applied — the
108
+ * values are already stored, and normalizing them again is not something dragging does.
109
+ */
110
+ const moveEntry = (ref: FormFieldRef, onChange: () => (value: unknown) => unknown): ToolEntry => {
111
+ const name = formSetterNames(capitalize(ref.refName), ref.key).moveFieldOnModel;
112
+ return {
113
+ name,
114
+ description: `Move one entry of ${ref.key} on the ${ref.refName} form to another position, counting from 0. Reorders only — no entry's content changes.`,
115
+ parameters: {
116
+ type: "object",
117
+ properties: { from: { type: "integer" }, to: { type: "integer" } },
118
+ required: ["from", "to"],
119
+ additionalProperties: false,
120
+ },
121
+ effect: "state",
122
+ guard: (args) => {
123
+ const length = rowsOf(ref).length;
124
+ const outside = ["from", "to"].filter((key) => {
125
+ const idx = args[key];
126
+ return typeof idx !== "number" || !Number.isInteger(idx) || idx < 0 || idx >= length;
127
+ });
128
+ if (!outside.length) return true;
129
+ return `${ref.key} has ${length} ${length === 1 ? "entry" : "entries"}, so ${outside.join(" and ")} is out of range.`;
130
+ },
131
+ run: (args) => {
132
+ const rows = [...rowsOf(ref)];
133
+ const [moved] = rows.splice(args.from as number, 1);
134
+ rows.splice(args.to as number, 0, moved);
135
+ return onChange()(rows);
136
+ },
137
+ };
138
+ };
139
+
92
140
  /**
93
- * Publishes the setter a form control is already holding, for exactly as long as the control is on screen.
141
+ * Publishes the setter a form control is already holding, for exactly as long as the control is usable.
94
142
  *
95
143
  * The control is the declaration — the same rule the rest of the surface follows. A handler passed by reference
96
144
  * (`onChange={st.do.setTitleOnTask}`) names the field it writes, so the tool and the person press one function;
97
145
  * an inline arrow names nothing and publishes nothing, which is the existing `data-akan-action` rule with
98
146
  * consequences. Publishing from the form's subscription instead would offer every field of the model, including
99
147
  * the ones this template draws no control for.
148
+ *
149
+ * `disabled` withdraws the tool for the same reason the whole surface is declaration-only: a field the person
150
+ * cannot type into is not one an agent may write in their place. It also closes the field to `fill<Model>Form`,
151
+ * whose guard offers only what a control published — so one gate covers both writers.
100
152
  */
101
- export const useFieldTool = (onChange: unknown, transform?: unknown) => {
153
+ export const useFieldTool = (onChange: unknown, { transform, disabled, sortable }: FieldToolOptions = {}) => {
102
154
  const surface = useSurface();
103
155
  const scope = useScopePath();
104
156
  const action = actionTagOf(onChange)?.action ?? null;
157
+ const off = !!disabled;
105
158
  const live = useRef({ onChange, transform });
106
159
  live.current = { onChange, transform };
107
160
  const scopeKey = scope.join(".");
108
161
  useEffect(() => {
109
- if (!action) return;
162
+ if (!action || off) return;
110
163
  const ref = FormFields.ref(action);
111
164
  const schema = ref && FormFields.schema(ref.field);
112
165
  if (!ref || !schema) return;
@@ -122,10 +175,13 @@ export const useFieldTool = (onChange: unknown, transform?: unknown) => {
122
175
  },
123
176
  },
124
177
  ...rowEntries(ref, schema),
178
+ ...(sortable && ref.field.arrDepth > 0
179
+ ? [moveEntry(ref, () => live.current.onChange as (value: unknown) => unknown)]
180
+ : []),
125
181
  ];
126
182
  const registered = entries.map((entry) => surface.registerTool(scope, entry));
127
183
  return () => {
128
184
  for (const unregister of registered) unregister();
129
185
  };
130
- }, [surface, scopeKey, action]);
186
+ }, [surface, scopeKey, action, off, !!sortable]);
131
187
  };
@@ -17,6 +17,11 @@ import { FormFields } from "./formFields";
17
17
  * controls will render. The **guard** is where the screen gets its say: a plain field has to have published its
18
18
  * own setter, and a composite is waved through because its rows are written with `writeOn<Model>(path, value)`,
19
19
  * which no control can annotate — so this is the one place an agent can reach a field the screen may not show.
20
+ *
21
+ * Registered `shared`, because the entry is a pure function of `refName`: the schema comes from the model, the
22
+ * guard re-reads the live surface, and every `write` reaches the one store instance. So a form put on screen by a
23
+ * shell that subscribes it (`Model.EditModal`) and by the `Template` inside it registers one declaration twice,
24
+ * which is interchangeable in the exact sense `shared` means — not a clash an app could fix by suppressing one.
20
25
  */
21
26
  export const useFormTools = (refName: string | null, write: (action: string, value: unknown) => void) => {
22
27
  const surface = useSurface();
@@ -44,6 +49,7 @@ export const useFormTools = (refName: string | null, write: (action: string, val
44
49
  additionalProperties: false,
45
50
  },
46
51
  effect: "state",
52
+ shared: true,
47
53
  guard: (args) => {
48
54
  const keys = Object.keys(args);
49
55
  if (!keys.length) return "Name at least one field to fill.";
@@ -59,9 +65,14 @@ export const useFormTools = (refName: string | null, write: (action: string, val
59
65
  const patch = Object.entries(args).map(([key, value]) => {
60
66
  const entry = byKey.get(key);
61
67
  if (!entry) throw new Error(`The ${refName} form has no field "${key}".`);
62
- return [entry.action, FormFields.checked(name, key, entry.field, value)] as const;
68
+ return { entry, value: FormFields.checked(name, key, entry.field, value) };
63
69
  });
64
- for (const [action, value] of patch) live.current(action, value);
70
+
71
+ for (const { entry, value } of patch) {
72
+ const control = surface.tool(AgenticSurface.fullName(scope, entry.action), scope);
73
+ if (control) void control.run({ value });
74
+ else live.current(entry.action, value);
75
+ }
65
76
  },
66
77
  });
67
78
  }, [surface, scopeKey, refName]);
@@ -16,6 +16,20 @@ export const formSetterNames = (className: string, key: string) => {
16
16
  addFieldOnModel: `add${classKeyName}On${className}`,
17
17
  subFieldOnModel: `sub${classKeyName}On${className}`,
18
18
  addOrSubFieldOnModel: `addOrSub${classKeyName}On${className}`,
19
+ /**
20
+ * The agent tool a drag-sortable list publishes. No store action answers to it: reordering *is* the whole-array
21
+ * write the drag already performs, so the tool splices the live rows and hands them to the same setter.
22
+ */
23
+ moveFieldOnModel: `move${classKeyName}On${className}`,
19
24
  uploadFieldOnModel: `upload${classKeyName}On${className}`,
25
+ /**
26
+ * The optional hook a store declares to run after this field is written.
27
+ *
28
+ * It carries no model suffix and a leading `_`, so it can never collide with a generated action name — which is
29
+ * the whole point: every generated action lives in a mapped type, and a mapped type produces *properties*, so a
30
+ * subclass method of the same name is a TS2425 error and there is no legal way to override one. A hook under a
31
+ * name the base type does not declare is the only shape TypeScript permits.
32
+ */
33
+ postSetField: `_postSet${classKeyName}`,
20
34
  };
21
35
  };
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ACTION_META,
3
3
  ACTION_OWNER_META,
4
+ FIELD_META,
4
5
  type MergeAllKeyOfObjects,
5
6
  type MergeAllKeyOfTypes,
6
7
  type MergeAllTypes,
@@ -9,7 +10,9 @@ import {
9
10
  STATE_META,
10
11
  } from "akanjs/base";
11
12
  import { applyMixins } from "akanjs/common";
13
+ import { ConstantRegistry } from "akanjs/constant";
12
14
  import { attachAgentic } from "./agentic";
15
+ import { formSetterNames } from "./formSetterNames";
13
16
  import type { RootStoreCls } from "./rootStore";
14
17
  import type { StoreCls } from "./store";
15
18
  import { StoreInstance } from "./storeInstance";
@@ -57,8 +60,28 @@ export class StoreRegistry {
57
60
  store[ACTION_META] = actions;
58
61
  store[ACTION_OWNER_META] = owners;
59
62
  StoreRegistry.#state.store.set(store.refName, store);
63
+ StoreRegistry.#warnUnknownPostSetHooks(store);
60
64
  return store;
61
65
  }
66
+
67
+ /**
68
+ * A `_postSet<Field>` hook cannot be typed — the generated setters live in a mapped type, so every name the base
69
+ * declares is a property a subclass method may not redeclare (TS2425), and the hook only compiles because the base
70
+ * declares nothing under it. A misspelled field would therefore never fire and never complain, so say so here.
71
+ */
72
+ static #warnUnknownPostSetHooks(store: StoreCls) {
73
+ const hooks = Object.keys(Object.getOwnPropertyDescriptors(store.prototype)).filter((key) =>
74
+ key.startsWith("_postSet"),
75
+ );
76
+ if (!hooks.length) return;
77
+ const model = ConstantRegistry.getDatabase(store.refName, { allowEmpty: true });
78
+ if (!model) return;
79
+ const known = new Set(
80
+ Object.keys(model.full[FIELD_META] as object).map((key) => formSetterNames("", key).postSetField),
81
+ );
82
+ for (const hook of hooks.filter((key) => !known.has(key)))
83
+ console.warn(`[${store.refName}Store] ${hook} matches no field of ${store.refName}, so it will never run.`);
84
+ }
62
85
  static get(refName: string) {
63
86
  return StoreRegistry.#state.store.get(refName);
64
87
  }
@@ -1,10 +1,22 @@
1
+ export interface FieldToolOptions {
2
+ /** The control's own normalizer. Applied to the agent's write exactly as it is to the person's typing. */
3
+ transform?: unknown;
4
+ /** True while the person cannot use the control. Publishes nothing, so the agent gets no lever the screen withholds. */
5
+ disabled?: boolean;
6
+ /** The person can drag entries into a new order, so `move<Field>On<Model>` is a lever the screen really has. */
7
+ sortable?: boolean;
8
+ }
1
9
  /**
2
- * Publishes the setter a form control is already holding, for exactly as long as the control is on screen.
10
+ * Publishes the setter a form control is already holding, for exactly as long as the control is usable.
3
11
  *
4
12
  * The control is the declaration — the same rule the rest of the surface follows. A handler passed by reference
5
13
  * (`onChange={st.do.setTitleOnTask}`) names the field it writes, so the tool and the person press one function;
6
14
  * an inline arrow names nothing and publishes nothing, which is the existing `data-akan-action` rule with
7
15
  * consequences. Publishing from the form's subscription instead would offer every field of the model, including
8
16
  * the ones this template draws no control for.
17
+ *
18
+ * `disabled` withdraws the tool for the same reason the whole surface is declaration-only: a field the person
19
+ * cannot type into is not one an agent may write in their place. It also closes the field to `fill<Model>Form`,
20
+ * whose guard offers only what a control published — so one gate covers both writers.
9
21
  */
10
- export declare const useFieldTool: (onChange: unknown, transform?: unknown) => void;
22
+ export declare const useFieldTool: (onChange: unknown, { transform, disabled, sortable }?: FieldToolOptions) => void;
@@ -10,5 +10,10 @@
10
10
  * controls will render. The **guard** is where the screen gets its say: a plain field has to have published its
11
11
  * own setter, and a composite is waved through because its rows are written with `writeOn<Model>(path, value)`,
12
12
  * which no control can annotate — so this is the one place an agent can reach a field the screen may not show.
13
+ *
14
+ * Registered `shared`, because the entry is a pure function of `refName`: the schema comes from the model, the
15
+ * guard re-reads the live surface, and every `write` reaches the one store instance. So a form put on screen by a
16
+ * shell that subscribes it (`Model.EditModal`) and by the `Template` inside it registers one declaration twice,
17
+ * which is interchangeable in the exact sense `shared` means — not a clash an app could fix by suppressing one.
13
18
  */
14
19
  export declare const useFormTools: (refName: string | null, write: (action: string, value: unknown) => void) => void;
@@ -12,5 +12,19 @@ export declare const formSetterNames: (className: string, key: string) => {
12
12
  addFieldOnModel: string;
13
13
  subFieldOnModel: string;
14
14
  addOrSubFieldOnModel: string;
15
+ /**
16
+ * The agent tool a drag-sortable list publishes. No store action answers to it: reordering *is* the whole-array
17
+ * write the drag already performs, so the tool splices the live rows and hands them to the same setter.
18
+ */
19
+ moveFieldOnModel: string;
15
20
  uploadFieldOnModel: string;
21
+ /**
22
+ * The optional hook a store declares to run after this field is written.
23
+ *
24
+ * It carries no model suffix and a leading `_`, so it can never collide with a generated action name — which is
25
+ * the whole point: every generated action lives in a mapped type, and a mapped type produces *properties*, so a
26
+ * subclass method of the same name is a TS2425 error and there is no legal way to override one. A hook under a
27
+ * name the base type does not declare is the only shape TypeScript permits.
28
+ */
29
+ postSetField: string;
16
30
  };
@@ -242,6 +242,7 @@ interface DateRangeProps<Nullable extends boolean> {
242
242
  showTime?: boolean;
243
243
  onChangeFrom: (value: Dayjs) => void;
244
244
  onChangeTo: (value: Dayjs) => void;
245
+ /** The whole range after either end moves. Fires only once both ends are set — nobody can query a half-open one. */
245
246
  onChange?: (from: Dayjs, to: Dayjs) => void;
246
247
  }
247
248
  interface NumberProps {
@@ -2,10 +2,12 @@
2
2
  import { config, useSprings } from "@react-spring/web";
3
3
  import { useGesture } from "@use-gesture/react";
4
4
  import { cn } from "akanjs/client";
5
+ import { useFieldTool } from "akanjs/store";
5
6
  import { animated } from "akanjs/ui";
6
7
  import { createContext, type ReactElement, type ReactNode, useContext, useRef } from "react";
7
8
  import { BiTrash } from "react-icons/bi";
8
9
  import { MdDragIndicator } from "react-icons/md";
10
+ import { agentAttrs } from "./agentAttrs";
9
11
  import { buttonRecipe } from "./Button";
10
12
  import { useUiRecipe } from "./UiOverride";
11
13
 
@@ -33,6 +35,8 @@ interface DragListProps<V> {
33
35
  onRemove: (value: V, idx: number) => void;
34
36
  }
35
37
  const DragList = <V,>({ className, mode = "vertical", children, onChange, onRemove }: DragListProps<V>) => {
38
+
39
+ useFieldTool(onChange, { sortable: true });
36
40
  const refs = useRef<(HTMLDivElement | null)[]>([]);
37
41
  const order = useRef(children.map((_, index) => index));
38
42
  const clientLengths = useRef(children.map((_, index) => 0));
@@ -103,7 +107,7 @@ const DragList = <V,>({ className, mode = "vertical", children, onChange, onRemo
103
107
  });
104
108
 
105
109
  return (
106
- <div className={cn("isolate flex gap-0", mode === "vertical" && "flex-col", className)}>
110
+ <div {...agentAttrs(onChange)} className={cn("isolate flex gap-0", mode === "vertical" && "flex-col", className)}>
107
111
  {springs.map(({ zIndex, shadow, movement, scale }, i) => (
108
112
  <animated.div
109
113
  ref={(el: HTMLDivElement | null) => {
package/ui/Field.tsx CHANGED
@@ -3,7 +3,7 @@ import { type DataList, type Dayjs, dayjs, type EnumInstance, isEnum } from "aka
3
3
  import { cn, usePage } from "akanjs/client";
4
4
  import { capitalize, formatPhone, isPhoneNumber, lowerlize } from "akanjs/common";
5
5
  import type { SliceMeta } from "akanjs/fetch";
6
- import { st, useFieldTool, useRelationFieldTool } from "akanjs/store";
6
+ import { actionTagOf, st, tagAction, useFieldTool, useRelationFieldTool } from "akanjs/store";
7
7
  import { memo, type ReactNode, useState } from "react";
8
8
  import { AiOutlinePlus } from "react-icons/ai";
9
9
  import { BiHelpCircle, BiTrash, BiX } from "react-icons/bi";
@@ -182,7 +182,7 @@ const Text = ({
182
182
  inputClassName,
183
183
  inputStyleType = "bordered",
184
184
  }: TextProps) => {
185
- useFieldTool(onChange, transform);
185
+ useFieldTool(onChange, { transform, disabled });
186
186
  const { l } = usePage();
187
187
  return (
188
188
  <div className={cn("flex flex-col", className)}>
@@ -248,7 +248,7 @@ const Price = ({
248
248
  inputClassName,
249
249
  inputStyleType = "bordered",
250
250
  }: PriceProps) => {
251
- useFieldTool(onChange, transform);
251
+ useFieldTool(onChange, { transform, disabled });
252
252
  const { l } = usePage();
253
253
  return (
254
254
  <div className={cn("flex flex-col", className)}>
@@ -316,7 +316,7 @@ const TextArea = ({
316
316
  cache,
317
317
  inputClassName,
318
318
  }: TextAreaProps) => {
319
- useFieldTool(onChange, transform);
319
+ useFieldTool(onChange, { transform, disabled });
320
320
  const { l } = usePage();
321
321
  return (
322
322
  <div className={cn("flex flex-col", className)}>
@@ -370,7 +370,7 @@ const Switch = ({
370
370
  onDesc,
371
371
  offDesc,
372
372
  }: SwitchProps) => {
373
- useFieldTool(onChange);
373
+ useFieldTool(onChange, { disabled });
374
374
  return (
375
375
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
376
376
  {label ? <Label className={labelClassName} nullable label={label} desc={desc} /> : null}
@@ -419,7 +419,7 @@ const ToggleSelect = <I extends string | number | boolean | null>({
419
419
  disabled,
420
420
  btnClassName,
421
421
  }: ToggleSelectProps<I>) => {
422
- useFieldTool(onChange);
422
+ useFieldTool(onChange, { disabled });
423
423
  const { l } = usePage();
424
424
  const isEnumValue = isEnum(items as EnumInstance<string, I>);
425
425
  return (
@@ -477,7 +477,7 @@ const MultiToggleSelect = <I extends string | number | boolean>({
477
477
  onChange,
478
478
  disabled,
479
479
  }: MultiToggleSelectProps<I>) => {
480
- useFieldTool(onChange);
480
+ useFieldTool(onChange, { disabled });
481
481
  const { l } = usePage();
482
482
  const isEnumValue = isEnum(items as EnumInstance<string, I>);
483
483
  return (
@@ -545,7 +545,7 @@ const TextList = ({
545
545
  validate,
546
546
  inputClassName,
547
547
  }: TextListProps) => {
548
- useFieldTool(onChange, transform);
548
+ useFieldTool(onChange, { transform, disabled, sortable: true });
549
549
  const { l } = usePage();
550
550
  const recipe = useUiRecipe("button") ?? buttonRecipe;
551
551
  return (
@@ -554,7 +554,10 @@ const TextList = ({
554
554
  <div className="mb-5 h-full gap-2 rounded-box border border-border p-2">
555
555
  <DraggableList
556
556
  className="h-full gap-2"
557
- onChange={onChange}
557
+
558
+ onChange={(sorted: string[]) => {
559
+ onChange(sorted);
560
+ }}
558
561
  onRemove={(_, idx) => {
559
562
  onChange(value.filter((_, i) => i !== idx));
560
563
  }}
@@ -652,7 +655,7 @@ const Tags = ({
652
655
  validate,
653
656
  inputClassName,
654
657
  }: TagsProps) => {
655
- useFieldTool(onChange, transform);
658
+ useFieldTool(onChange, { transform, disabled });
656
659
  const { l } = usePage();
657
660
  const badge = useUiRecipe("badge") ?? badgeRecipe;
658
661
  const [inputVisible, setInputVisible] = useState(false);
@@ -784,6 +787,7 @@ interface DateRangeProps<Nullable extends boolean> {
784
787
  showTime?: boolean;
785
788
  onChangeFrom: (value: Dayjs) => void;
786
789
  onChangeTo: (value: Dayjs) => void;
790
+ /** The whole range after either end moves. Fires only once both ends are set — nobody can query a half-open one. */
787
791
  onChange?: (from: Dayjs, to: Dayjs) => void;
788
792
  }
789
793
  const DateRange = <Nullable extends boolean>({
@@ -801,6 +805,24 @@ const DateRange = <Nullable extends boolean>({
801
805
  onChange,
802
806
  showTime,
803
807
  }: DateRangeProps<Nullable>) => {
808
+ /**
809
+ * Adds the pair callback to one endpoint setter, carrying that setter's own tag onto the wrapper.
810
+ *
811
+ * The wrapper really does run the setter, so the tag stays a true statement — and wiring `onChange` then costs
812
+ * the endpoint neither its agent tool nor its `data-akan-action`, which a plain closure would both drop.
813
+ */
814
+ const withPair = (setter: (value: Dayjs) => void, pair: (value: Dayjs) => [Dayjs | null, Dayjs | null]) => {
815
+ if (!onChange) return setter;
816
+ const wrapped = (value: Dayjs) => {
817
+ setter(value);
818
+ const [nextFrom, nextTo] = pair(value);
819
+ if (nextFrom && nextTo) onChange(nextFrom, nextTo);
820
+ };
821
+ const tag = actionTagOf(setter);
822
+ return tag ? tagAction(wrapped, tag) : wrapped;
823
+ };
824
+ const changeFrom = withPair(onChangeFrom, (value) => [value, to]);
825
+ const changeTo = withPair(onChangeTo, (value) => [from, value]);
804
826
  return (
805
827
  <div className={cn("flex flex-col", className)}>
806
828
  {label ? <Label className={labelClassName} nullable={nullable} label={label} desc={desc} /> : null}
@@ -815,7 +837,7 @@ const DateRange = <Nullable extends boolean>({
815
837
  value={from}
816
838
  max={max}
817
839
  min={min}
818
- onChange={onChangeFrom}
840
+ onChange={changeFrom}
819
841
  />
820
842
  </div>
821
843
  <div className="relative flex w-full flex-col items-start gap-2 text-center md:flex-row md:items-center">
@@ -827,7 +849,7 @@ const DateRange = <Nullable extends boolean>({
827
849
  value={to}
828
850
  max={max}
829
851
  min={min}
830
- onChange={onChangeTo}
852
+ onChange={changeTo}
831
853
  />
832
854
  </div>
833
855
  </div>
@@ -878,7 +900,7 @@ const Number = ({
878
900
  formatter,
879
901
  parser,
880
902
  }: NumberProps) => {
881
- useFieldTool(onChange, transform);
903
+ useFieldTool(onChange, { transform, disabled });
882
904
  const { l } = usePage();
883
905
  return (
884
906
  <div className={cn("flex flex-col", className)}>
@@ -950,7 +972,7 @@ const DoubleNumber = ({
950
972
  validate,
951
973
  onPressEnter,
952
974
  }: DoubleNumberProps) => {
953
- useFieldTool(onChange, transform);
975
+ useFieldTool(onChange, { transform, disabled });
954
976
  const { l } = usePage();
955
977
  return (
956
978
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
@@ -1038,7 +1060,7 @@ const Email = ({
1038
1060
  inputClassName,
1039
1061
  inputStyleType,
1040
1062
  }: EmailProps) => {
1041
- useFieldTool(onChange, transform);
1063
+ useFieldTool(onChange, { transform, disabled });
1042
1064
  const { l } = usePage();
1043
1065
  return (
1044
1066
  <div className={cn("flex flex-col", className)}>
@@ -1103,7 +1125,7 @@ const Phone = ({
1103
1125
  onPressEnter,
1104
1126
  inputClassName,
1105
1127
  }: PhoneProps) => {
1106
- useFieldTool(onChange, transform);
1128
+ useFieldTool(onChange, { transform, disabled });
1107
1129
  const { l } = usePage();
1108
1130
 
1109
1131
  return (
@@ -1175,7 +1197,7 @@ const Password = ({
1175
1197
  inputClassName,
1176
1198
  showConfirm,
1177
1199
  }: PasswordProps) => {
1178
- useFieldTool(onChange, transform);
1200
+ useFieldTool(onChange, { transform, disabled });
1179
1201
  const { l } = usePage();
1180
1202
  return (
1181
1203
  <div className={cn("flex flex-col", className)}>
@@ -1384,7 +1406,7 @@ const ParentId = <T extends string, State, Input, Full extends { id: string }, L
1384
1406
  const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
1385
1407
  const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
1386
1408
 
1387
- useFieldTool(onChange);
1409
+ useFieldTool(onChange, { disabled });
1388
1410
 
1389
1411
  return (
1390
1412
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
@@ -1573,7 +1595,7 @@ const ChildrenId = <T extends string, State, Input, Full extends { id: string },
1573
1595
  const modelList = storeUse[namesOfSlice.modelList]() as DataList<Light>;
1574
1596
  const modelListLoading = storeUse[namesOfSlice.modelListLoading]() as string | boolean;
1575
1597
 
1576
- useFieldTool(onChange);
1598
+ useFieldTool(onChange, { disabled });
1577
1599
 
1578
1600
  return (
1579
1601
  <div {...agentAttrs(onChange)} className={cn("flex flex-col", className)}>
package/ui/Input.tsx CHANGED
@@ -59,7 +59,7 @@ const DefaultInput = ({
59
59
  validate,
60
60
  ...rest
61
61
  }: InputProps) => {
62
- useFieldTool(onChange);
62
+ useFieldTool(onChange, { disabled: rest.disabled });
63
63
  const { l } = usePage();
64
64
  const [firstFocus, setFirstFocus] = useState(true);
65
65
  const validateResult = validate ? validate(value) : undefined;
@@ -168,7 +168,7 @@ const DefaultTextArea = ({
168
168
  validate,
169
169
  ...rest
170
170
  }: TextAreaProps) => {
171
- useFieldTool(onChange);
171
+ useFieldTool(onChange, { disabled: rest.disabled });
172
172
  const { l } = usePage();
173
173
  const inputRef = useRef<HTMLTextAreaElement>(null);
174
174
  const validateResult = validate(value);
@@ -263,7 +263,7 @@ const DefaultPassword = ({
263
263
  validate,
264
264
  ...rest
265
265
  }: PasswordProps) => {
266
- useFieldTool(onChange);
266
+ useFieldTool(onChange, { disabled: rest.disabled });
267
267
  const { l } = usePage();
268
268
  const inputRef = useRef<HTMLInputElement>(null);
269
269
  const validateResult = validate(value);
@@ -380,7 +380,7 @@ const DefaultEmail = ({
380
380
  inputWrapperClassName,
381
381
  ...rest
382
382
  }: EmailProps) => {
383
- useFieldTool(onChange);
383
+ useFieldTool(onChange, { disabled: rest.disabled });
384
384
  const { l } = usePage();
385
385
  const inputRef = useRef<HTMLInputElement>(null);
386
386
  const isValidEmail = isEmail(value);
@@ -495,7 +495,7 @@ const DefaultNumber = ({
495
495
  parser,
496
496
  ...rest
497
497
  }: NumberProps) => {
498
- useFieldTool(onChange);
498
+ useFieldTool(onChange, { disabled: rest.disabled });
499
499
  const { l } = usePage();
500
500
  const inputRef = useRef<HTMLInputElement>(null);
501
501
  const validateResult = validate ? validate(value) : undefined;
@@ -639,7 +639,7 @@ export type CheckboxProps = Omit<InputHTMLAttributes<HTMLInputElement>, "onChang
639
639
  };
640
640
 
641
641
  const DefaultCheckbox = ({ checked, onChange, className, ...rest }: CheckboxProps) => {
642
- useFieldTool(onChange);
642
+ useFieldTool(onChange, { disabled: rest.disabled });
643
643
  return (
644
644
  <input
645
645
  {...rest}
package/ui/Select.tsx CHANGED
@@ -85,7 +85,7 @@ const DefaultSelect = <
85
85
  renderOption,
86
86
  renderSelected,
87
87
  }: SelectProps<T, Multiple, Searchable, Option>) => {
88
- useFieldTool(onChange);
88
+ useFieldTool(onChange, { disabled });
89
89
  const { l } = usePage();
90
90
  const [isOpen, setIsOpen] = useState(false);
91
91
  const labeledOptions: { label: string | boolean | number; value: T }[] = useMemo(
package/ui/Switch.tsx CHANGED
@@ -32,7 +32,7 @@ export const Switch = ({
32
32
  className,
33
33
  variant = "primary",
34
34
  }: SwitchProps) => {
35
- useFieldTool(onChange);
35
+ useFieldTool(onChange, { disabled });
36
36
  const [internal, setInternal] = useState(defaultChecked ?? false);
37
37
  const isControlled = checked !== undefined;
38
38
  const isChecked = isControlled ? checked : internal;