@lerianstudio/sindarian-ui 2.0.0-beta.1 → 2.0.0-beta.2

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 (42) hide show
  1. package/dist/components/form/file-upload-field/index.d.ts +11 -2
  2. package/dist/components/form/file-upload-field/index.d.ts.map +1 -1
  3. package/dist/components/form/file-upload-field/index.js +8 -3
  4. package/dist/components/form/input-field/index.d.ts +41 -2
  5. package/dist/components/form/input-field/index.d.ts.map +1 -1
  6. package/dist/components/form/input-field/index.js +73 -15
  7. package/dist/components/form/select-field/index.d.ts +9 -1
  8. package/dist/components/form/select-field/index.d.ts.map +1 -1
  9. package/dist/components/form/select-field/index.js +2 -2
  10. package/dist/components/ui/button/styles.css +31 -1
  11. package/dist/components/ui/calendar/index.d.ts.map +1 -1
  12. package/dist/components/ui/calendar/index.js +7 -1
  13. package/dist/components/ui/file-upload/index.d.ts +76 -9
  14. package/dist/components/ui/file-upload/index.d.ts.map +1 -1
  15. package/dist/components/ui/file-upload/index.js +47 -12
  16. package/dist/components/ui/form.d.ts +4 -0
  17. package/dist/components/ui/form.d.ts.map +1 -1
  18. package/dist/components/ui/form.js +48 -6
  19. package/dist/components/ui/toggle-group/index.d.ts +1 -1
  20. package/dist/components/ui/toggle-group/index.d.ts.map +1 -1
  21. package/dist/components/ui/toggle-group/index.js +73 -4
  22. package/dist/esm/components/form/file-upload-field/index.d.ts +11 -2
  23. package/dist/esm/components/form/file-upload-field/index.d.ts.map +1 -1
  24. package/dist/esm/components/form/file-upload-field/index.js +8 -3
  25. package/dist/esm/components/form/input-field/index.d.ts +41 -2
  26. package/dist/esm/components/form/input-field/index.d.ts.map +1 -1
  27. package/dist/esm/components/form/input-field/index.js +73 -15
  28. package/dist/esm/components/form/select-field/index.d.ts +9 -1
  29. package/dist/esm/components/form/select-field/index.d.ts.map +1 -1
  30. package/dist/esm/components/form/select-field/index.js +2 -2
  31. package/dist/esm/components/ui/calendar/index.d.ts.map +1 -1
  32. package/dist/esm/components/ui/calendar/index.js +8 -2
  33. package/dist/esm/components/ui/file-upload/index.d.ts +76 -9
  34. package/dist/esm/components/ui/file-upload/index.d.ts.map +1 -1
  35. package/dist/esm/components/ui/file-upload/index.js +47 -13
  36. package/dist/esm/components/ui/form.d.ts +4 -0
  37. package/dist/esm/components/ui/form.d.ts.map +1 -1
  38. package/dist/esm/components/ui/form.js +48 -6
  39. package/dist/esm/components/ui/toggle-group/index.d.ts +1 -1
  40. package/dist/esm/components/ui/toggle-group/index.d.ts.map +1 -1
  41. package/dist/esm/components/ui/toggle-group/index.js +73 -4
  42. package/package.json +1 -1
@@ -36,13 +36,21 @@ var __importStar = (this && this.__importStar) || (function () {
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.FileUpload = void 0;
38
38
  exports.validateFile = validateFile;
39
+ exports.humanizeSize = humanizeSize;
39
40
  const jsx_runtime_1 = require("react/jsx-runtime");
40
41
  /**
41
- * FileUpload — a controlled "pick a file, read it to text, hand back the text"
42
- * primitive. The defining job: select one file (by click, keyboard, or
43
- * drag-and-drop), validate it against an accept filter + a byte ceiling, read
44
- * it as UTF-8 via FileReader.readAsText, and emit `{ file, text }`. Binary/DER
45
- * (.pfx/.p12) is explicitly OUT of scope — readAsText only.
42
+ * FileUpload — a controlled "pick a file, hand it back" primitive. The defining
43
+ * job: select one file (by click, keyboard, or drag-and-drop), validate it
44
+ * against an accept filter + a byte ceiling, and emit `{ file, text }`.
45
+ *
46
+ * `readAs` decides whether the bytes are decoded on the way through. `'text'`
47
+ * (the default) reads UTF-8 via FileReader.readAsText, which is what a PEM/CSV
48
+ * host wants. `'none'` hands the `File` over untouched and is the only mode a
49
+ * BINARY file can use: a PDF, XLSX or PFX put through readAsText decodes into
50
+ * replacement-character garbage, and that garbage string is then retained for
51
+ * as long as the host holds the value — 20 MiB of PDF became a useless 20 MiB
52
+ * string, and the host still had to read the file a second time to get at the
53
+ * real bytes.
46
54
  *
47
55
  * The real `<input type="file">` IS the accessible control: it is `sr-only`
48
56
  * (visually hidden) but focusable and labelable — never `aria-hidden`, never
@@ -63,6 +71,11 @@ const jsx_runtime_1 = require("react/jsx-runtime");
63
71
  * a value, so it cannot masquerade as valid. `accept` is validated against
64
72
  * BOTH extension and MIME because the native `accept` attribute is only a
65
73
  * browser hint and is bypassable via drag-drop.
74
+ *
75
+ * A host that already announces the rejection itself — a toast in its own
76
+ * locale, driven off `onError` — silences this one by returning nothing from
77
+ * `labels.error`. Two announcements for one event, in two languages, is the
78
+ * accessibility defect; the wording is not.
66
79
  */
67
80
  const React = __importStar(require("react"));
68
81
  const lucide_react_1 = require("lucide-react");
@@ -105,7 +118,11 @@ function matchesAccept(file, accept) {
105
118
  return mime === token;
106
119
  });
107
120
  }
108
- /** Humanize a byte count for the selected-file chip. Binary units, 1 decimal. */
121
+ /**
122
+ * Humanize a byte count for the selected-file chip. Binary units, 1 decimal.
123
+ * Exported so a `labels.error` override can format `maxSizeBytes` exactly the
124
+ * way the chip and the default message do, instead of re-deriving binary units.
125
+ */
109
126
  function humanizeSize(bytes) {
110
127
  if (bytes < 1024)
111
128
  return `${bytes} B`;
@@ -118,8 +135,8 @@ function humanizeSize(bytes) {
118
135
  }
119
136
  return `${size.toFixed(1)} ${units[unit]}`;
120
137
  }
121
- /** A human-readable announcement for each rejection kind. */
122
- function errorMessage(error) {
138
+ /** The default English announcement for each rejection kind. */
139
+ function defaultErrorMessage(error) {
123
140
  switch (error.kind) {
124
141
  case 'too-large':
125
142
  return `File is too large (max ${humanizeSize(error.maxSizeBytes)}).`;
@@ -129,7 +146,7 @@ function errorMessage(error) {
129
146
  return 'Could not read the file.';
130
147
  }
131
148
  }
132
- exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes, value, onSelect, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
149
+ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes, value, readAs = 'text', labels, onSelect, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
133
150
  const internalRef = React.useRef(null);
134
151
  React.useImperativeHandle(ref, () => internalRef.current);
135
152
  const reactId = React.useId();
@@ -141,12 +158,21 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
141
158
  // component's own validation/read error — a self-detected bad pick must read
142
159
  // as invalid even outside a form.
143
160
  const invalid = ariaInvalid || error !== null;
161
+ // Resolve the announcement BEFORE deciding whether the alert exists: a
162
+ // consumer that returns nothing is opting out of this surface entirely,
163
+ // and an association pointing at an unrendered node is worse than none.
164
+ const resolved = error
165
+ ? labels?.error
166
+ ? labels.error(error)
167
+ : defaultErrorMessage(error)
168
+ : null;
169
+ const errorText = resolved ? resolved : null;
144
170
  // Radix Slot OVERWRITES aria-describedby (it does not merge), so merge the
145
171
  // primitive's own role=alert error id with the FormControl-injected one so
146
172
  // both associations coexist on the input. A plain join, never `cn` —
147
173
  // tailwind-merge treats these as class names and would drop an id that
148
174
  // happens to look like a conflicting utility.
149
- const describedBy = [ariaDescribedby, error ? errorId : undefined]
175
+ const describedBy = [ariaDescribedby, errorText ? errorId : undefined]
150
176
  .filter(Boolean)
151
177
  .join(' ') || undefined;
152
178
  // Last-resolved-wins: a slow read for pick A must not overwrite a newer
@@ -162,6 +188,15 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
162
188
  onError?.(validationError);
163
189
  return;
164
190
  }
191
+ // Binary path: no decode, no reader, no retained garbage string. The
192
+ // stale-rejection clear still has to happen here — the alert is this
193
+ // component's own state and a good pick must retire it.
194
+ if (readAs === 'none') {
195
+ readerRef.current = null;
196
+ setError(null);
197
+ onSelect({ file, text: '' });
198
+ return;
199
+ }
165
200
  const reader = new FileReader();
166
201
  readerRef.current = reader;
167
202
  reader.onload = () => {
@@ -228,10 +263,10 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
228
263
  return ((0, jsx_runtime_1.jsxs)("div", { className: (0, utils_1.cn)('space-y-2', className), children: [(0, jsx_runtime_1.jsxs)("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: (0, utils_1.cn)('border-input bg-card focus-within:ring-ring focus-within:ring-offset-background aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-within:ring-destructive flex w-full items-center gap-3 rounded-md border px-3 py-4 text-sm shadow-xs transition-colors focus-within:ring-2 focus-within:ring-offset-1 focus-within:outline-none', dragActive &&
229
264
  'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', disabled
230
265
  ? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
231
- : 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", accept: accept, disabled: disabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": "Remove file", onClick: (event) => {
266
+ : 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", accept: accept, disabled: disabled, className: "sr-only", "aria-invalid": invalid || undefined, "aria-required": ariaRequired || undefined, "aria-describedby": describedBy, "aria-label": ariaLabel, onChange: onInputChange, ...rest }), (0, jsx_runtime_1.jsx)(lucide_react_1.Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("span", { className: "min-w-0 flex-1 truncate", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove?.trim() ? labels.remove : 'Remove file', onClick: (event) => {
232
267
  // Don't bubble to the zone's openPicker and re-open the dialog.
233
268
  event.stopPropagation();
234
269
  clear();
235
- }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: "Choose a file" }), ' ', "or drag and drop"] }))] }), error ? ((0, jsx_runtime_1.jsx)("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorMessage(error) })) : null] }));
270
+ }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] })) : ((0, jsx_runtime_1.jsxs)("span", { className: "text-muted-foreground", children: [(0, jsx_runtime_1.jsx)("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? ((0, jsx_runtime_1.jsx)("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
236
271
  });
237
272
  exports.FileUpload.displayName = 'FileUpload';
@@ -16,7 +16,11 @@ declare const useFormField: () => {
16
16
  formDescriptionId: string;
17
17
  formMessageId: string;
18
18
  required: boolean | undefined;
19
+ described: Record<DescribingSlot, boolean> | undefined;
20
+ onDescribedChange: ((slot: DescribingSlot, rendered: boolean) => void) | undefined;
19
21
  };
22
+ /** The two slots that can describe a control, in the order a reader hears them. */
23
+ type DescribingSlot = 'message' | 'description';
20
24
  export type FormItemProps = React.HTMLAttributes<HTMLDivElement> & {
21
25
  required?: boolean;
22
26
  };
@@ -1 +1 @@
1
- {"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../../../src/components/ui/form.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,cAAc,MAAM,uBAAuB,CAAA;AAEvD,OAAO,EAEL,eAAe,EACf,SAAS,EACT,WAAW,EAGZ,MAAM,iBAAiB,CAAA;AAKxB,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA;AAS9D,QAAA,MAAM,IAAI,4MAAe,CAAA;AAazB,QAAA,MAAM,SAAS,GACb,YAAY,SAAS,WAAW,GAAG,WAAW,EAC9C,KAAK,SAAS,SAAS,CAAC,YAAY,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,EAC/D,cAEC,eAAe,CAAC,YAAY,EAAE,KAAK,CAAC,sBAMtC,CAAA;AAED,QAAA,MAAM,YAAY;;;;;;;;;;;;CAyBjB,CAAA;AAWD,MAAM,MAAM,aAAa,GAAG,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC,GAAG;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,QAAA,MAAM,QAAQ;eAHD,OAAO;wCAanB,CAAA;AAGD,MAAM,MAAM,cAAc,GAAG,KAAK,CAAC,wBAAwB,CACzD,OAAO,cAAc,CAAC,IAAI,CAC3B,GAAG;IACF,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CACxB,CAAA;AAED,QAAA,MAAM,SAAS;YAHL,KAAK,CAAC,SAAS;0CA6BvB,CAAA;AAGF,eAAO,MAAM,WAAW,GAAI,yBAAyB,oBAAoB,sBASxE,CAAA;AAED,QAAA,MAAM,WAAW;;gFAmBf,CAAA;AAGF,QAAA,MAAM,eAAe,yHAcnB,CAAA;AAGF,QAAA,MAAM,WAAW,yHAuBf,CAAA;AAGF,OAAO,EACL,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,WAAW,EACX,eAAe,EACf,WAAW,EACX,SAAS,EACV,CAAA"}
1
+ {"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../../../src/components/ui/form.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,cAAc,MAAM,uBAAuB,CAAA;AAEvD,OAAO,EAEL,eAAe,EACf,SAAS,EACT,WAAW,EAGZ,MAAM,iBAAiB,CAAA;AAKxB,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAA;AAS9D,QAAA,MAAM,IAAI,4MAAe,CAAA;AAazB,QAAA,MAAM,SAAS,GACb,YAAY,SAAS,WAAW,GAAG,WAAW,EAC9C,KAAK,SAAS,SAAS,CAAC,YAAY,CAAC,GAAG,SAAS,CAAC,YAAY,CAAC,EAC/D,cAEC,eAAe,CAAC,YAAY,EAAE,KAAK,CAAC,sBAMtC,CAAA;AAED,QAAA,MAAM,YAAY;;;;;;;;;;;;;+BA4CW,cAAc,YAAY,OAAO,KAAK,IAAI;CAjBtE,CAAA;AAED,mFAAmF;AACnF,KAAK,cAAc,GAAG,SAAS,GAAG,aAAa,CAAA;AAwC/C,MAAM,MAAM,aAAa,GAAG,KAAK,CAAC,cAAc,CAAC,cAAc,CAAC,GAAG;IACjE,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,QAAA,MAAM,QAAQ;eAHD,OAAO;wCA8BnB,CAAA;AAGD,MAAM,MAAM,cAAc,GAAG,KAAK,CAAC,wBAAwB,CACzD,OAAO,cAAc,CAAC,IAAI,CAC3B,GAAG;IACF,KAAK,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CACxB,CAAA;AAED,QAAA,MAAM,SAAS;YAHL,KAAK,CAAC,SAAS;0CA6BvB,CAAA;AAGF,eAAO,MAAM,WAAW,GAAI,yBAAyB,oBAAoB,sBASxE,CAAA;AAED,QAAA,MAAM,WAAW;;gFAgCf,CAAA;AAGF,QAAA,MAAM,eAAe,yHAgBnB,CAAA;AAGF,QAAA,MAAM,WAAW,yHA6Bf,CAAA;AAGF,OAAO,EACL,YAAY,EACZ,IAAI,EACJ,QAAQ,EACR,SAAS,EACT,WAAW,EACX,eAAe,EACf,WAAW,EACX,SAAS,EACV,CAAA"}
@@ -61,7 +61,7 @@ const useFormField = () => {
61
61
  const fieldState = form && fieldContext.name
62
62
  ? form.getFieldState(fieldContext.name, form.formState)
63
63
  : undefined;
64
- const { id, required } = itemContext;
64
+ const { id, required, described, onDescribedChange } = itemContext;
65
65
  return {
66
66
  id,
67
67
  name: fieldContext.name,
@@ -69,14 +69,38 @@ const useFormField = () => {
69
69
  formDescriptionId: `${id}-form-item-description`,
70
70
  formMessageId: `${id}-form-item-message`,
71
71
  required,
72
+ described,
73
+ onDescribedChange,
72
74
  ...fieldState
73
75
  };
74
76
  };
75
77
  exports.useFormField = useFormField;
76
78
  const FormItemContext = React.createContext({});
79
+ /**
80
+ * Announce that this slot is on the page, for as long as it is.
81
+ *
82
+ * `rendered` is a parameter rather than a caller-side condition because
83
+ * `FormMessage` returns null when it has nothing to say, and a hook cannot sit
84
+ * behind that early return.
85
+ */
86
+ function useDescribingSlot(slot, rendered) {
87
+ const { onDescribedChange } = React.useContext(FormItemContext);
88
+ React.useEffect(() => {
89
+ if (!rendered)
90
+ return;
91
+ onDescribedChange?.(slot, true);
92
+ return () => onDescribedChange?.(slot, false);
93
+ }, [onDescribedChange, slot, rendered]);
94
+ }
77
95
  const FormItem = React.forwardRef(({ className, required, ...props }, ref) => {
78
96
  const id = React.useId();
79
- return ((0, jsx_runtime_1.jsx)(FormItemContext.Provider, { value: { id, required }, children: (0, jsx_runtime_1.jsx)("div", { ref: ref, className: (0, utils_1.cn)('space-y-2', className), ...props }) }));
97
+ const [described, setDescribed] = React.useState({ message: false, description: false });
98
+ // Keeps the object identity when nothing moved, so a slot re-registering
99
+ // does not re-render the item for no reason.
100
+ const onDescribedChange = React.useCallback((slot, rendered) => setDescribed((current) => current[slot] === rendered
101
+ ? current
102
+ : { ...current, [slot]: rendered }), []);
103
+ return ((0, jsx_runtime_1.jsx)(FormItemContext.Provider, { value: { id, required, described, onDescribedChange }, children: (0, jsx_runtime_1.jsx)("div", { ref: ref, className: (0, utils_1.cn)('space-y-2', className), ...props }) }));
80
104
  });
81
105
  exports.FormItem = FormItem;
82
106
  FormItem.displayName = 'FormItem';
@@ -92,15 +116,28 @@ FormLabel.displayName = 'FormLabel';
92
116
  const FormTooltip = ({ children, ...others }) => ((0, jsx_runtime_1.jsx)(tooltip_1.TooltipProvider, { ...others, children: (0, jsx_runtime_1.jsxs)(tooltip_1.Tooltip, { children: [(0, jsx_runtime_1.jsx)(tooltip_1.TooltipTrigger, { asChild: true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.HelpCircle, { className: "text-container-text ml-2 h-4 w-4" }) }), (0, jsx_runtime_1.jsx)(tooltip_1.TooltipContent, { children: children })] }) }));
93
117
  exports.FormTooltip = FormTooltip;
94
118
  const FormControl = React.forwardRef(({ ...props }, ref) => {
95
- const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
96
- return ((0, jsx_runtime_1.jsx)(react_slot_1.Slot, { ref: ref, id: formItemId, "aria-describedby": !error
97
- ? `${formDescriptionId}`
98
- : `${formDescriptionId} ${formMessageId}`, "aria-invalid": !!error, ...props }));
119
+ const { error, formItemId, formDescriptionId, formMessageId, described } = useFormField();
120
+ // Only ids that resolve. A screen reader drops a dangling IDREF silently, so
121
+ // the barrier this removes is diagnostic rather than operational: a form with
122
+ // thirteen broken associations is a form where a real one cannot be spotted.
123
+ //
124
+ // Message first: a reader hears the error before the hint. `undefined` when
125
+ // both slots are empty, which drops the attribute — and, because Radix Slot
126
+ // OVERWRITES rather than merges, still lets the wrapped element carry its own
127
+ // (`ui/file-upload` merges the injected id with its own error id that way).
128
+ const describedBy = [
129
+ described?.message ? formMessageId : undefined,
130
+ described?.description ? formDescriptionId : undefined
131
+ ]
132
+ .filter(Boolean)
133
+ .join(' ') || undefined;
134
+ return ((0, jsx_runtime_1.jsx)(react_slot_1.Slot, { ref: ref, id: formItemId, "aria-describedby": describedBy, "aria-invalid": !!error, ...props }));
99
135
  });
100
136
  exports.FormControl = FormControl;
101
137
  FormControl.displayName = 'FormControl';
102
138
  const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
103
139
  const { formDescriptionId } = useFormField();
140
+ useDescribingSlot('description', true);
104
141
  return ((0, jsx_runtime_1.jsx)("p", { ref: ref, id: formDescriptionId, className: (0, utils_1.cn)('text-muted-foreground text-xs font-medium', className), ...props }));
105
142
  });
106
143
  exports.FormDescription = FormDescription;
@@ -108,6 +145,11 @@ FormDescription.displayName = 'FormDescription';
108
145
  const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
109
146
  const { error, formMessageId } = useFormField();
110
147
  const body = error ? String(error?.message) : children;
148
+ // Registered on `body`, not on `error`: a message given plain children
149
+ // renders copy that the control has to point at too. The old expression only
150
+ // added the message id when react-hook-form reported an error, so a
151
+ // standalone message was rendered and never associated.
152
+ useDescribingSlot('message', Boolean(body));
111
153
  if (!body) {
112
154
  return null;
113
155
  }
@@ -13,6 +13,6 @@ declare const toggleVariants: (props?: ({
13
13
  declare function ToggleGroup({ className, variant, size, spacing, children, style, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
14
14
  spacing?: number;
15
15
  }): React.JSX.Element;
16
- declare function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>): React.JSX.Element;
16
+ declare function ToggleGroupItem({ className, children, variant, size, onFocus, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>): React.JSX.Element;
17
17
  export { ToggleGroup, ToggleGroupItem };
18
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/toggle-group/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,oBAAoB,MAAM,8BAA8B,CAAA;AACpE,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAIjE;;;;GAIG;AACH,QAAA,MAAM,cAAc;;;8EAoBnB,CAAA;AAYD,iBAAS,WAAW,CAAC,EACnB,SAAS,EACT,OAAO,EACP,IAAI,EACJ,OAAW,EACX,QAAQ,EACR,KAAK,EACL,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,qBAwBF;AAED,iBAAS,eAAe,CAAC,EACvB,SAAS,EACT,QAAQ,EACR,OAAO,EACP,IAAI,EACJ,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,qBAuBpC;AAED,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/components/ui/toggle-group/index.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAC9B,OAAO,KAAK,oBAAoB,MAAM,8BAA8B,CAAA;AACpE,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAIjE;;;;GAIG;AACH,QAAA,MAAM,cAAc;;;8EAoBnB,CAAA;AA4DD,iBAAS,WAAW,CAAC,EACnB,SAAS,EACT,OAAO,EACP,IAAI,EACJ,OAAW,EACX,QAAQ,EACR,KAAK,EACL,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,GAAG;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,qBA+BF;AAED,iBAAS,eAAe,CAAC,EACvB,SAAS,EACT,QAAQ,EACR,OAAO,EACP,IAAI,EACJ,OAAO,EACP,GAAG,KAAK,EACT,EAAE,KAAK,CAAC,cAAc,CAAC,OAAO,oBAAoB,CAAC,IAAI,CAAC,GACvD,YAAY,CAAC,OAAO,cAAc,CAAC,qBAiDpC;AAED,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,CAAA"}
@@ -63,12 +63,58 @@ const toggleVariants = (0, class_variance_authority_1.cva)("hover:bg-muted hover
63
63
  size: 'default'
64
64
  }
65
65
  });
66
+ const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
67
+ /**
68
+ * Is an arrow key down right now?
69
+ *
70
+ * This is how an item tells keyboard navigation apart from a pointer press or a
71
+ * programmatic focus, and it is the seam Radix uses for the same job in its own
72
+ * `RadioGroup`. It works because `RovingFocusGroup` defers the focus move to a
73
+ * `setTimeout`: the keydown has already bubbled to `document` and set the flag
74
+ * by the time the item receives `focus`.
75
+ *
76
+ * The distinction is not cosmetic. A pointer press focuses BEFORE the click
77
+ * lands, so selecting on every focus would turn one click into
78
+ * select-then-deselect and break mouse selection outright.
79
+ *
80
+ * Returns null when disabled, so `type="multiple"` carries no flag to read.
81
+ */
82
+ function useArrowKeyPressed(enabled) {
83
+ const arrowKeyPressed = React.useRef(false);
84
+ React.useEffect(() => {
85
+ if (!enabled)
86
+ return;
87
+ const onKeyDown = (event) => {
88
+ if (ARROW_KEYS.includes(event.key))
89
+ arrowKeyPressed.current = true;
90
+ };
91
+ const onKeyUp = () => {
92
+ arrowKeyPressed.current = false;
93
+ };
94
+ document.addEventListener('keydown', onKeyDown);
95
+ document.addEventListener('keyup', onKeyUp);
96
+ window.addEventListener('blur', onKeyUp);
97
+ return () => {
98
+ document.removeEventListener('keydown', onKeyDown);
99
+ document.removeEventListener('keyup', onKeyUp);
100
+ window.removeEventListener('blur', onKeyUp);
101
+ // A group unmounted mid-keystroke never sees the keyup.
102
+ arrowKeyPressed.current = false;
103
+ };
104
+ }, [enabled]);
105
+ return enabled ? arrowKeyPressed : null;
106
+ }
66
107
  const ToggleGroupContext = React.createContext({
67
108
  size: 'default',
68
109
  variant: 'default',
69
- spacing: 0
110
+ spacing: 0,
111
+ arrowKeyPressed: null
70
112
  });
71
113
  function ToggleGroup({ className, variant, size, spacing = 0, children, style, ...props }) {
114
+ // Read, never destructured: `type` is the discriminant of the Radix props
115
+ // union, and pulling it out then spreading it back widens the union so the
116
+ // single/multiple props stop resolving.
117
+ const arrowKeyPressed = useArrowKeyPressed(props.type === 'single');
72
118
  return ((0, jsx_runtime_1.jsx)(ToggleGroupPrimitive.Root, { "data-slot": "toggle-group", "data-variant": variant, "data-size": size, "data-spacing": spacing,
73
119
  // Caller style first: `--gap` is ours and must survive, but everything
74
120
  // else the caller passes has to reach the element.
@@ -76,12 +122,35 @@ function ToggleGroup({ className, variant, size, spacing = 0, children, style, .
76
122
  // Legacy carried a `data-[spacing=default]:...:shadow-xs` rule here that
77
123
  // never matched — `data-spacing` is always a number. Removed rather than
78
124
  // activated, so the rendered pixels stay identical to sindarian-x.
79
- 'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md', className), ...props, children: (0, jsx_runtime_1.jsx)(ToggleGroupContext.Provider, { value: { variant, size, spacing }, children: children }) }));
125
+ 'group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md', className), ...props, children: (0, jsx_runtime_1.jsx)(ToggleGroupContext.Provider, { value: { variant, size, spacing, arrowKeyPressed }, children: children }) }));
80
126
  }
81
- function ToggleGroupItem({ className, children, variant, size, ...props }) {
127
+ function ToggleGroupItem({ className, children, variant, size, onFocus, ...props }) {
82
128
  const context = React.useContext(ToggleGroupContext);
129
+ /**
130
+ * Selection follows focus, which is what `role="radio"` promises.
131
+ *
132
+ * With `type="single"` Radix puts the group on the ARIA radio pattern: the
133
+ * root is a `radiogroup`, each item a `radio` with `aria-checked`. Under that
134
+ * pattern an arrow key both moves focus and checks the item it lands on.
135
+ * Radix only moves focus, so a screen-reader operator used to arrow across
136
+ * "radio, not checked, 2 of 3" with nothing ever selected.
137
+ *
138
+ * `type="multiple"` is a `toolbar` of `aria-pressed` buttons and keeps its
139
+ * current behavior, where moving focus without pressing is correct: the flag
140
+ * is null there.
141
+ *
142
+ * Clicking the element rather than writing the value keeps a single
143
+ * activation path, so Radix's own toggle and any caller `onClick` both fire
144
+ * exactly as they do for a real click.
145
+ */
146
+ const handleFocus = (event) => {
147
+ onFocus?.(event);
148
+ if (context.arrowKeyPressed?.current) {
149
+ event.currentTarget.click();
150
+ }
151
+ };
83
152
  return ((0, jsx_runtime_1.jsx)(ToggleGroupPrimitive.Item, { "data-slot": "toggle-group-item", "data-variant": context.variant || variant, "data-size": context.size || size, "data-spacing": context.spacing, className: (0, utils_1.cn)(toggleVariants({
84
153
  variant: context.variant || variant,
85
154
  size: context.size || size
86
- }), 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10', 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l', className), ...props, children: children }));
155
+ }), 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10', 'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l', className), ...props, onFocus: handleFocus, children: children }));
87
156
  }
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
  import { Control, FieldPathByValue, FieldValues } from 'react-hook-form';
3
- import { type FileUploadResult } from '../../../components/ui/file-upload/index.js';
3
+ import { type FileUploadError, type FileUploadLabels, type FileUploadResult } from '../../../components/ui/file-upload/index.js';
4
4
  import { type RenderableLabel } from '../../../components/form/renderable-label.js';
5
5
  type FileUploadFieldOwnProps<T extends FieldValues = FieldValues> = {
6
6
  control: Control<T>;
@@ -17,6 +17,15 @@ type FileUploadFieldOwnProps<T extends FieldValues = FieldValues> = {
17
17
  maxSizeBytes?: number;
18
18
  disabled?: boolean;
19
19
  className?: string;
20
+ /**
21
+ * Override the picker's user-visible copy. Threaded straight to FileUpload;
22
+ * omitted fields keep the English defaults. Note `labels.error` governs the
23
+ * PICKER's own size/type/read refusal only — resolver messages still come
24
+ * from the schema through FormMessage.
25
+ */
26
+ labels?: FileUploadLabels;
27
+ /** Observe size, type, and read failures after the form value is cleared. */
28
+ onError?: (error: FileUploadError) => void;
20
29
  /** Optional escape hatch to also observe the picked File/clear alongside the form's text value. */
21
30
  onSelect?: (result: FileUploadResult | null) => void;
22
31
  };
@@ -47,6 +56,6 @@ export type FileUploadFieldProps<T extends FieldValues = FieldValues> = FileUplo
47
56
  * rejection; on resolver failure the FormMessage announces. Both surfaces are
48
57
  * wired.
49
58
  */
50
- export declare const FileUploadField: <T extends FieldValues = FieldValues>({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, onSelect, "aria-label": ariaLabelProp }: FileUploadFieldProps<T>) => import("react").JSX.Element;
59
+ export declare const FileUploadField: <T extends FieldValues = FieldValues>({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, labels, onError, onSelect, "aria-label": ariaLabelProp }: FileUploadFieldProps<T>) => import("react").JSX.Element;
51
60
  export {};
52
61
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/file-upload-field/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAY,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAWxE,OAAO,EAAc,KAAK,gBAAgB,EAAE,MAAM,6BAA6B,CAAA;AAC/E,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,oCAAoC,CAAA;AAE3C,KAAK,uBAAuB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAAI;IAClE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACnB;;;;OAIG;IACH,IAAI,EAAE,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC7C,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mGAAmG;IACnG,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAA;CACrD,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAClE,uBAAuB,CAAC,CAAC,CAAC,GACxB,CACI;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAC1C,CAAA;AAEL;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,4IAalE,oBAAoB,CAAC,CAAC,CAAC,gCA8EzB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/file-upload-field/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAY,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAWxE,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,gBAAgB,EACtB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,oCAAoC,CAAA;AAE3C,KAAK,uBAAuB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAAI;IAClE,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACnB;;;;OAIG;IACH,IAAI,EAAE,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IAC7C,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,gBAAgB,CAAA;IACzB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IAC1C,mGAAmG;IACnG,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAA;CACrD,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAClE,uBAAuB,CAAC,CAAC,CAAC,GACxB,CACI;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GACjD;IAAE,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAC1C,CAAA;AAEL;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,6JAelE,oBAAoB,CAAC,CAAC,CAAC,gCAoFzB,CAAA"}
@@ -19,7 +19,7 @@ import { hasRenderableLabel } from '../../../components/form/renderable-label.js
19
19
  * rejection; on resolver failure the FormMessage announces. Both surfaces are
20
20
  * wired.
21
21
  */
22
- export const FileUploadField = ({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, onSelect, 'aria-label': ariaLabelProp }) => {
22
+ export const FileUploadField = ({ control, name, label, description, tooltip, required, accept, maxSizeBytes, disabled, className, labels, onError, onSelect, 'aria-label': ariaLabelProp }) => {
23
23
  const [localValue, setLocalValue] = useState(null);
24
24
  const showLabel = hasRenderableLabel(label);
25
25
  // An empty or whitespace-only aria-label is worse than none: it names the
@@ -51,10 +51,15 @@ export const FileUploadField = ({ control, name, label, description, tooltip, re
51
51
  // value carries nothing else to tell them apart. Fix needs a reset epoch
52
52
  // from react-hook-form, or a form value richer than the file text.
53
53
  const shown = localValue && field.value === localValue.text ? localValue : null;
54
- return (_jsxs(FormItem, { required: required, children: [showLabel && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : undefined, children: label })), _jsx(FormControl, { children: _jsx(FileUpload, { ref: field.ref, name: field.name, onBlur: field.onBlur, "aria-label": ariaLabel, accept: accept, maxSizeBytes: maxSizeBytes, disabled: disabled, className: className, value: shown, onSelect: (result) => {
54
+ return (_jsxs(FormItem, { required: required, children: [showLabel && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : undefined, children: label })), _jsx(FormControl, { children: _jsx(FileUpload, { ref: field.ref, name: field.name, onBlur: field.onBlur, "aria-label": ariaLabel, labels: labels, accept: accept, maxSizeBytes: maxSizeBytes, disabled: disabled, className: className, value: shown, onSelect: (result) => {
55
55
  setLocalValue(result);
56
56
  field.onChange(result?.text ?? '');
57
57
  onSelect?.(result);
58
- }, onError: () => field.onChange('') }) }), _jsx(FormMessage, {}), description && _jsx(FormDescription, { children: description })] }));
58
+ }, onError: (error) => {
59
+ setLocalValue(null);
60
+ field.onChange('');
61
+ onSelect?.(null);
62
+ onError?.(error);
63
+ } }) }), _jsx(FormMessage, {}), description && _jsx(FormDescription, { children: description })] }));
59
64
  } }));
60
65
  };
@@ -1,9 +1,48 @@
1
- import { HTMLInputTypeAttribute, ReactNode } from 'react';
1
+ import { type InputRef } from '../../../components/ui/input/index.js';
2
+ import { HTMLInputTypeAttribute, ReactNode, Ref } from 'react';
2
3
  import { Control, FieldValues } from 'react-hook-form';
3
4
  export type InputFieldProps<T extends FieldValues = FieldValues> = {
4
5
  className?: string;
5
6
  name: string;
6
7
  type?: HTMLInputTypeAttribute;
8
+ /**
9
+ * Announce the field's own invalidity. Reaches the rendered control on BOTH
10
+ * branches, so a field with no `control` — and therefore no react-hook-form
11
+ * error state — can still tell a screen reader it is invalid.
12
+ *
13
+ * ORed with the form's error, never overriding it: `false` here cannot
14
+ * suppress a live resolver error. Nobody asked to mute a real validation
15
+ * failure, and on a financial console a muted one is a wrong number reaching
16
+ * a money path.
17
+ *
18
+ * TypeScript exempts hyphenated JSX attributes from excess-property
19
+ * checking, so passing this used to compile and then vanish.
20
+ */
21
+ 'aria-invalid'?: boolean;
22
+ /** Lower bound, forwarded to the single-line control on both branches. */
23
+ min?: number | string;
24
+ /** Upper bound, forwarded to the single-line control on both branches. */
25
+ max?: number | string;
26
+ /** Increment, forwarded to the single-line control on both branches. */
27
+ step?: number | string;
28
+ /** Character ceiling, forwarded on both branches (single-line and textarea). */
29
+ maxLength?: number;
30
+ /** Browser autofill hint. `"off"` is the one a money field usually wants. */
31
+ autoComplete?: React.HTMLInputAutoCompleteAttribute;
32
+ /** Virtual-keyboard hint, e.g. `"numeric"` or `"decimal"`. Both branches. */
33
+ inputMode?: React.HTMLAttributes<HTMLElement>['inputMode'];
34
+ /** Native validation regex. Single-line only — a textarea has no `pattern`. */
35
+ pattern?: string;
36
+ /** Focus the control on mount. React focuses the node rather than emitting an attribute. */
37
+ autoFocus?: boolean;
38
+ /**
39
+ * Imperative handle on the rendered control: `focus()`/`blur()` on the
40
+ * single-line branch, the textarea node on the `textArea` branch (which
41
+ * satisfies the same shape). COMPOSED with react-hook-form's own ref when
42
+ * `control` is present, never replacing it — the form keeps the node it
43
+ * needs for `shouldFocusError`.
44
+ */
45
+ ref?: Ref<InputRef>;
7
46
  label?: ReactNode;
8
47
  tooltip?: string;
9
48
  labelExtra?: ReactNode;
@@ -32,5 +71,5 @@ export type InputFieldProps<T extends FieldValues = FieldValues> = {
32
71
  'data-testid'?: string;
33
72
  onChange?: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
34
73
  };
35
- export declare const InputField: <T extends FieldValues = FieldValues>({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, ...others }: InputFieldProps<T>) => import("react").JSX.Element;
74
+ export declare const InputField: <T extends FieldValues = FieldValues>({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, min, max, step, maxLength, autoComplete, inputMode, pattern, autoFocus, ref, "aria-invalid": ariaInvalid, ...others }: InputFieldProps<T>) => import("react").JSX.Element;
36
75
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/input-field/index.tsx"],"names":[],"mappings":"AAWA,OAAO,EAAE,sBAAsB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AACzD,OAAO,EAAE,OAAO,EAAkB,WAAW,EAAQ,MAAM,iBAAiB,CAAA;AAE5E,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAAI;IACjE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,sBAAsB,CAAA;IAC7B,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,SAAS,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,cAAc,CAAC,EAAE,SAAS,CAAA;IAC1B,YAAY,CAAC,EAAE,SAAS,CAAA;IACxB;oDACgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACpB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,CACT,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,gBAAgB,GAAG,mBAAmB,CAAC,KACzD,IAAI,CAAA;CACV,CAAA;AAeD,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,uMAmB7D,eAAe,CAAC,CAAC,CAAC,gCA2EpB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/form/input-field/index.tsx"],"names":[],"mappings":"AAUA,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,uBAAuB,CAAA;AAC5D,OAAO,EAAE,sBAAsB,EAAE,SAAS,EAAE,GAAG,EAAmB,MAAM,OAAO,CAAA;AAC/E,OAAO,EAAE,OAAO,EAAkB,WAAW,EAAQ,MAAM,iBAAiB,CAAA;AAE5E,MAAM,MAAM,eAAe,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,IAAI;IACjE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,sBAAsB,CAAA;IAC7B;;;;;;;;;;;;OAYG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACrB,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,gFAAgF;IAChF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,KAAK,CAAC,8BAA8B,CAAA;IACnD,6EAA6E;IAC7E,SAAS,CAAC,EAAE,KAAK,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC,WAAW,CAAC,CAAA;IAC1D,+EAA+E;IAC/E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,4FAA4F;IAC5F,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAA;IACnB,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,UAAU,CAAC,EAAE,SAAS,CAAA;IACtB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB,cAAc,CAAC,EAAE,SAAS,CAAA;IAC1B,YAAY,CAAC,EAAE,SAAS,CAAA;IACxB;oDACgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAA;IACpB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,EAAE,CACT,CAAC,EAAE,KAAK,CAAC,WAAW,CAAC,gBAAgB,GAAG,mBAAmB,CAAC,KACzD,IAAI,CAAA;CACV,CAAA;AA4CD,eAAO,MAAM,UAAU,GAAI,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,iTA6B7D,eAAe,CAAC,CAAC,CAAC,gCAyHpB,CAAA"}
@@ -2,8 +2,59 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AutosizeTextarea } from '../../../components/ui/autosize-textarea/index.js';
3
3
  import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, FormTooltip } from '../../../components/ui/form.js';
4
4
  import { Input } from '../../../components/ui/input/index.js';
5
- export const InputField = ({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, ...others }) => {
6
- const renderItem = (binding) => (_jsxs(FormItem, { required: required, children: [label && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : labelExtra, children: label })), _jsx(FormControl, { children: textArea ? (_jsx(AutosizeTextarea, { className: className, placeholder: placeholder, readOnly: readOnly, minHeight: minHeight, maxHeight: maxHeight, "data-testid": others['data-testid'], ...binding })) : (_jsx(Input, { className: className, type: type, placeholder: placeholder, readOnly: readOnly, startAdornment: startAdornment, endAdornment: endAdornment, "data-testid": others['data-testid'], ...binding })) }), _jsx(FormMessage, {}), description && _jsx(FormDescription, { children: description })] }));
5
+ import { useMemo, useRef } from 'react';
6
+ /**
7
+ * Fan one node out to several refs. React hands a node to ONE ref, so a
8
+ * consumer ref and react-hook-form's `field.ref` cannot both be passed
9
+ * directly — one silently wins. Only used when the consumer actually supplied
10
+ * a ref, so a field without one keeps `field.ref`'s own identity and React
11
+ * never re-attaches it.
12
+ */
13
+ function fanOutRef(...refs) {
14
+ return (node) => {
15
+ const cleanups = [];
16
+ for (const ref of refs) {
17
+ if (typeof ref === 'function') {
18
+ const cleanup = ref(node);
19
+ cleanups.push(typeof cleanup === 'function' ? cleanup : () => ref(null));
20
+ }
21
+ else if (ref) {
22
+ ;
23
+ ref.current = node;
24
+ cleanups.push(() => {
25
+ ;
26
+ ref.current = null;
27
+ });
28
+ }
29
+ }
30
+ return () => cleanups.forEach((cleanup) => cleanup());
31
+ };
32
+ }
33
+ export const InputField = ({ className, type, label, tooltip, labelExtra, placeholder, description, startAdornment, endAdornment, required, readOnly, minHeight, maxHeight, textArea, defaultValue, value, onChange, min, max, step, maxLength, autoComplete, inputMode, pattern, autoFocus, ref, 'aria-invalid': ariaInvalid, ...others }) => {
34
+ // OR with the form's error, expressed through the Slot merge rather than by
35
+ // reading the form: FormControl already injects `aria-invalid={!!error}`, and
36
+ // a child prop only wins that merge when the KEY is present. So emit the key
37
+ // only for an explicit `true`, and let FormControl answer for every other
38
+ // case. `true || error` is true; `false || error` is whatever the form says,
39
+ // which is the whole point — an explicit `false` cannot mute a live error.
40
+ // With no `control` there is no error to OR against, so FormControl injects
41
+ // `false` and today's behaviour is unchanged.
42
+ const ariaInvalidProp = ariaInvalid ? { 'aria-invalid': true } : undefined;
43
+ const formRef = useRef(undefined);
44
+ const composedRef = useMemo(() => ref ? fanOutRef((node) => formRef.current?.(node), ref) : ref,
45
+ // `others.name` belongs here, and leaving it out is not a lint nit.
46
+ // react-hook-form hands a fresh `field.ref` for every name, and that ref
47
+ // only receives the element when React reattaches, which it does only when
48
+ // this callback's identity changes. Memoised on `ref` alone, one identity
49
+ // survived a name change, the new field never got its element, and
50
+ // `form.setFocus(newName)` then found nothing to focus and said nothing.
51
+ [ref, others.name]);
52
+ const renderItem = (binding) => (_jsxs(FormItem, { required: required, children: [label && (_jsx(FormLabel, { extra: tooltip ? _jsx(FormTooltip, { children: tooltip }) : labelExtra, children: label })), _jsx(FormControl, { children: textArea ? (_jsx(AutosizeTextarea, { className: className, placeholder: placeholder, readOnly: readOnly, minHeight: minHeight, maxHeight: maxHeight, maxLength: maxLength, autoComplete: autoComplete, inputMode: inputMode, autoFocus: autoFocus, "data-testid": others['data-testid'], ...ariaInvalidProp, ...binding })) : (_jsx(Input, { className: className, type: type, placeholder: placeholder, readOnly: readOnly, startAdornment: startAdornment, endAdornment: endAdornment,
53
+ // Bounds belong to the single-line control only: `min`/`max`/`step`
54
+ // mean nothing on a textarea and would render as invalid markup.
55
+ min: min, max: max, step: step, maxLength: maxLength, autoComplete: autoComplete, inputMode: inputMode,
56
+ // Single-line only: `<textarea pattern>` is not a thing.
57
+ pattern: pattern, autoFocus: autoFocus, "data-testid": others['data-testid'], ...ariaInvalidProp, ...binding })) }), _jsx(FormMessage, {}), description && _jsx(FormDescription, { children: description })] }));
7
58
  if (!others.control) {
8
59
  return renderItem({
9
60
  name: others.name,
@@ -11,7 +62,10 @@ export const InputField = ({ className, type, label, tooltip, labelExtra, placeh
11
62
  // Controlled when the caller holds the value, uncontrolled when it only
12
63
  // seeds one — supplying both is what React warns about.
13
64
  ...(value !== undefined ? { value } : { defaultValue }),
14
- onChange: (e) => onChange?.(e)
65
+ onChange: (e) => onChange?.(e),
66
+ // Nothing else claims the node on this branch, so the consumer ref goes
67
+ // straight through. Without it there was no node to focus at all.
68
+ ref: ref
15
69
  });
16
70
  }
17
71
  return (_jsx(FormField, { ...others, name: others.name,
@@ -19,16 +73,20 @@ export const InputField = ({ className, type, label, tooltip, labelExtra, placeh
19
73
  // value painted on the box only makes the field LOOK filled while the
20
74
  // form still submits nothing for that name. Controller seeds its own
21
75
  // state from this, so `field.value` carries the seed on both branches.
22
- defaultValue: defaultValue, render: ({ field }) => renderItem({
23
- ...field,
24
- // Controlled from mount. react-hook-form hands `undefined` for any
25
- // name the form declares no default for; React then mounts the box
26
- // uncontrolled and flips it on the first keystroke, losing that
27
- // character.
28
- value: field.value ?? '',
29
- onChange: (e) => {
30
- field.onChange(e);
31
- onChange?.(e);
32
- }
33
- }) }));
76
+ defaultValue: defaultValue, render: ({ field }) => {
77
+ formRef.current = field.ref;
78
+ return renderItem({
79
+ ...field,
80
+ // Controlled from mount. react-hook-form hands `undefined` for any
81
+ // name the form declares no default for; React then mounts the box
82
+ // uncontrolled and flips it on the first keystroke, losing that
83
+ // character.
84
+ value: field.value ?? '',
85
+ onChange: (e) => {
86
+ field.onChange(e);
87
+ onChange?.(e);
88
+ },
89
+ ref: (ref ? composedRef : field.ref)
90
+ });
91
+ } }));
34
92
  };