@lerianstudio/sindarian-ui 1.4.0-beta.8 → 2.0.0-beta.10

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 (196) hide show
  1. package/dist/components/breadcrumb/index.d.ts.map +1 -1
  2. package/dist/components/breadcrumb/index.js +25 -1
  3. package/dist/components/card/account-card/index.d.ts.map +1 -1
  4. package/dist/components/card/account-card/index.js +5 -5
  5. package/dist/components/card/basic-card/index.d.ts.map +1 -1
  6. package/dist/components/card/basic-card/index.js +2 -2
  7. package/dist/components/card/entity-card/index.js +4 -4
  8. package/dist/components/dialog/confirmation-dialog/index.d.ts +36 -5
  9. package/dist/components/dialog/confirmation-dialog/index.d.ts.map +1 -1
  10. package/dist/components/dialog/confirmation-dialog/index.js +5 -2
  11. package/dist/components/entity-box/index.d.ts +18 -2
  12. package/dist/components/entity-box/index.d.ts.map +1 -1
  13. package/dist/components/entity-box/index.js +33 -5
  14. package/dist/components/entity-data-table/index.d.ts.map +1 -1
  15. package/dist/components/entity-data-table/index.js +1 -1
  16. package/dist/components/form/file-upload-field/index.d.ts +11 -2
  17. package/dist/components/form/file-upload-field/index.d.ts.map +1 -1
  18. package/dist/components/form/file-upload-field/index.js +8 -3
  19. package/dist/components/form/input-field/index.d.ts +41 -2
  20. package/dist/components/form/input-field/index.d.ts.map +1 -1
  21. package/dist/components/form/input-field/index.js +73 -15
  22. package/dist/components/form/password-field/index.d.ts +5 -1
  23. package/dist/components/form/password-field/index.d.ts.map +1 -1
  24. package/dist/components/form/password-field/index.js +2 -2
  25. package/dist/components/form/select-field/index.d.ts +9 -1
  26. package/dist/components/form/select-field/index.d.ts.map +1 -1
  27. package/dist/components/form/select-field/index.js +2 -2
  28. package/dist/components/page-header/index.d.ts.map +1 -1
  29. package/dist/components/page-header/index.js +2 -2
  30. package/dist/components/table/id-table-cell.d.ts +5 -1
  31. package/dist/components/table/id-table-cell.d.ts.map +1 -1
  32. package/dist/components/table/id-table-cell.js +48 -6
  33. package/dist/components/ui/autocomplete/index.d.ts +58 -6
  34. package/dist/components/ui/autocomplete/index.d.ts.map +1 -1
  35. package/dist/components/ui/autocomplete/index.js +96 -16
  36. package/dist/components/ui/badge/index.d.ts +11 -7
  37. package/dist/components/ui/badge/index.d.ts.map +1 -1
  38. package/dist/components/ui/badge/index.js +31 -9
  39. package/dist/components/ui/button/styles.css +31 -1
  40. package/dist/components/ui/calendar/index.d.ts.map +1 -1
  41. package/dist/components/ui/calendar/index.js +7 -1
  42. package/dist/components/ui/card/index.d.ts.map +1 -1
  43. package/dist/components/ui/card/index.js +1 -1
  44. package/dist/components/ui/copy-field/index.d.ts.map +1 -1
  45. package/dist/components/ui/copy-field/index.js +1 -1
  46. package/dist/components/ui/dropdown-menu/index.d.ts.map +1 -1
  47. package/dist/components/ui/dropdown-menu/index.js +4 -1
  48. package/dist/components/ui/field/index.d.ts.map +1 -1
  49. package/dist/components/ui/field/index.js +5 -2
  50. package/dist/components/ui/file-upload/index.d.ts +235 -9
  51. package/dist/components/ui/file-upload/index.d.ts.map +1 -1
  52. package/dist/components/ui/file-upload/index.js +354 -17
  53. package/dist/components/ui/form.d.ts +4 -0
  54. package/dist/components/ui/form.d.ts.map +1 -1
  55. package/dist/components/ui/form.js +53 -8
  56. package/dist/components/ui/input/styles.css +30 -2
  57. package/dist/components/ui/multiple-select/index.js +2 -2
  58. package/dist/components/ui/progress/index.d.ts +15 -1
  59. package/dist/components/ui/progress/index.d.ts.map +1 -1
  60. package/dist/components/ui/progress/index.js +25 -2
  61. package/dist/components/ui/sheet/index.d.ts.map +1 -1
  62. package/dist/components/ui/sheet/index.js +2 -2
  63. package/dist/components/ui/sidebar/index.d.ts +1 -0
  64. package/dist/components/ui/sidebar/index.d.ts.map +1 -1
  65. package/dist/components/ui/sidebar/index.js +1 -0
  66. package/dist/components/ui/sidebar/sidebar-expand-button.d.ts +1 -1
  67. package/dist/components/ui/sidebar/sidebar-expand-button.d.ts.map +1 -1
  68. package/dist/components/ui/sidebar/sidebar-expand-button.js +32 -2
  69. package/dist/components/ui/sidebar/sidebar-item.d.ts +1 -1
  70. package/dist/components/ui/sidebar/sidebar-item.d.ts.map +1 -1
  71. package/dist/components/ui/sidebar/sidebar-item.js +28 -4
  72. package/dist/components/ui/sidebar/sidebar-provider.d.ts +37 -0
  73. package/dist/components/ui/sidebar/sidebar-provider.d.ts.map +1 -1
  74. package/dist/components/ui/sidebar/sidebar-provider.js +125 -4
  75. package/dist/components/ui/sidebar/sidebar-root.d.ts +38 -1
  76. package/dist/components/ui/sidebar/sidebar-root.d.ts.map +1 -1
  77. package/dist/components/ui/sidebar/sidebar-root.js +100 -5
  78. package/dist/components/ui/sidebar/sidebar-trigger.d.ts +18 -0
  79. package/dist/components/ui/sidebar/sidebar-trigger.d.ts.map +1 -0
  80. package/dist/components/ui/sidebar/sidebar-trigger.js +30 -0
  81. package/dist/components/ui/toggle-group/index.d.ts +1 -1
  82. package/dist/components/ui/toggle-group/index.d.ts.map +1 -1
  83. package/dist/components/ui/toggle-group/index.js +73 -4
  84. package/dist/components/ui/tooltip/index.d.ts +15 -0
  85. package/dist/components/ui/tooltip/index.d.ts.map +1 -1
  86. package/dist/components/ui/tooltip/index.js +16 -1
  87. package/dist/domain/blotter/index.d.ts +4 -2
  88. package/dist/domain/blotter/index.d.ts.map +1 -1
  89. package/dist/domain/delinquency-aging/aging-buckets.d.ts.map +1 -1
  90. package/dist/domain/delinquency-aging/aging-buckets.js +8 -3
  91. package/dist/domain/delinquency-aging/index.d.ts.map +1 -1
  92. package/dist/domain/delinquency-aging/index.js +8 -3
  93. package/dist/domain/key-id/index.js +1 -1
  94. package/dist/domain/status-rail/index.js +1 -1
  95. package/dist/domain/threshold-gauge/index.d.ts.map +1 -1
  96. package/dist/domain/threshold-gauge/index.js +9 -5
  97. package/dist/enterprise/stat-card/index.d.ts.map +1 -1
  98. package/dist/enterprise/stat-card/index.js +12 -4
  99. package/dist/esm/components/breadcrumb/index.d.ts.map +1 -1
  100. package/dist/esm/components/breadcrumb/index.js +25 -1
  101. package/dist/esm/components/card/account-card/index.d.ts.map +1 -1
  102. package/dist/esm/components/card/account-card/index.js +5 -5
  103. package/dist/esm/components/card/basic-card/index.d.ts.map +1 -1
  104. package/dist/esm/components/card/basic-card/index.js +2 -2
  105. package/dist/esm/components/card/entity-card/index.js +4 -4
  106. package/dist/esm/components/dialog/confirmation-dialog/index.d.ts +36 -5
  107. package/dist/esm/components/dialog/confirmation-dialog/index.d.ts.map +1 -1
  108. package/dist/esm/components/dialog/confirmation-dialog/index.js +5 -2
  109. package/dist/esm/components/entity-box/index.d.ts +18 -2
  110. package/dist/esm/components/entity-box/index.d.ts.map +1 -1
  111. package/dist/esm/components/entity-box/index.js +30 -5
  112. package/dist/esm/components/entity-data-table/index.d.ts.map +1 -1
  113. package/dist/esm/components/entity-data-table/index.js +1 -1
  114. package/dist/esm/components/form/file-upload-field/index.d.ts +11 -2
  115. package/dist/esm/components/form/file-upload-field/index.d.ts.map +1 -1
  116. package/dist/esm/components/form/file-upload-field/index.js +8 -3
  117. package/dist/esm/components/form/input-field/index.d.ts +41 -2
  118. package/dist/esm/components/form/input-field/index.d.ts.map +1 -1
  119. package/dist/esm/components/form/input-field/index.js +73 -15
  120. package/dist/esm/components/form/password-field/index.d.ts +5 -1
  121. package/dist/esm/components/form/password-field/index.d.ts.map +1 -1
  122. package/dist/esm/components/form/password-field/index.js +2 -2
  123. package/dist/esm/components/form/select-field/index.d.ts +9 -1
  124. package/dist/esm/components/form/select-field/index.d.ts.map +1 -1
  125. package/dist/esm/components/form/select-field/index.js +2 -2
  126. package/dist/esm/components/page-header/index.d.ts.map +1 -1
  127. package/dist/esm/components/page-header/index.js +2 -2
  128. package/dist/esm/components/table/id-table-cell.d.ts +5 -1
  129. package/dist/esm/components/table/id-table-cell.d.ts.map +1 -1
  130. package/dist/esm/components/table/id-table-cell.js +48 -3
  131. package/dist/esm/components/ui/autocomplete/index.d.ts +58 -6
  132. package/dist/esm/components/ui/autocomplete/index.d.ts.map +1 -1
  133. package/dist/esm/components/ui/autocomplete/index.js +96 -16
  134. package/dist/esm/components/ui/badge/index.d.ts +11 -7
  135. package/dist/esm/components/ui/badge/index.d.ts.map +1 -1
  136. package/dist/esm/components/ui/badge/index.js +31 -9
  137. package/dist/esm/components/ui/calendar/index.d.ts.map +1 -1
  138. package/dist/esm/components/ui/calendar/index.js +8 -2
  139. package/dist/esm/components/ui/card/index.d.ts.map +1 -1
  140. package/dist/esm/components/ui/card/index.js +1 -1
  141. package/dist/esm/components/ui/copy-field/index.d.ts.map +1 -1
  142. package/dist/esm/components/ui/copy-field/index.js +1 -1
  143. package/dist/esm/components/ui/dropdown-menu/index.d.ts.map +1 -1
  144. package/dist/esm/components/ui/dropdown-menu/index.js +4 -1
  145. package/dist/esm/components/ui/field/index.d.ts.map +1 -1
  146. package/dist/esm/components/ui/field/index.js +5 -2
  147. package/dist/esm/components/ui/file-upload/index.d.ts +235 -9
  148. package/dist/esm/components/ui/file-upload/index.d.ts.map +1 -1
  149. package/dist/esm/components/ui/file-upload/index.js +353 -17
  150. package/dist/esm/components/ui/form.d.ts +4 -0
  151. package/dist/esm/components/ui/form.d.ts.map +1 -1
  152. package/dist/esm/components/ui/form.js +53 -8
  153. package/dist/esm/components/ui/multiple-select/index.js +2 -2
  154. package/dist/esm/components/ui/progress/index.d.ts +15 -1
  155. package/dist/esm/components/ui/progress/index.d.ts.map +1 -1
  156. package/dist/esm/components/ui/progress/index.js +25 -2
  157. package/dist/esm/components/ui/sheet/index.d.ts.map +1 -1
  158. package/dist/esm/components/ui/sheet/index.js +2 -2
  159. package/dist/esm/components/ui/sidebar/index.d.ts +1 -0
  160. package/dist/esm/components/ui/sidebar/index.d.ts.map +1 -1
  161. package/dist/esm/components/ui/sidebar/index.js +1 -0
  162. package/dist/esm/components/ui/sidebar/sidebar-expand-button.d.ts +1 -1
  163. package/dist/esm/components/ui/sidebar/sidebar-expand-button.d.ts.map +1 -1
  164. package/dist/esm/components/ui/sidebar/sidebar-expand-button.js +32 -2
  165. package/dist/esm/components/ui/sidebar/sidebar-item.d.ts +1 -1
  166. package/dist/esm/components/ui/sidebar/sidebar-item.d.ts.map +1 -1
  167. package/dist/esm/components/ui/sidebar/sidebar-item.js +28 -4
  168. package/dist/esm/components/ui/sidebar/sidebar-provider.d.ts +37 -0
  169. package/dist/esm/components/ui/sidebar/sidebar-provider.d.ts.map +1 -1
  170. package/dist/esm/components/ui/sidebar/sidebar-provider.js +122 -1
  171. package/dist/esm/components/ui/sidebar/sidebar-root.d.ts +38 -1
  172. package/dist/esm/components/ui/sidebar/sidebar-root.d.ts.map +1 -1
  173. package/dist/esm/components/ui/sidebar/sidebar-root.js +102 -7
  174. package/dist/esm/components/ui/sidebar/sidebar-trigger.d.ts +18 -0
  175. package/dist/esm/components/ui/sidebar/sidebar-trigger.d.ts.map +1 -0
  176. package/dist/esm/components/ui/sidebar/sidebar-trigger.js +26 -0
  177. package/dist/esm/components/ui/toggle-group/index.d.ts +1 -1
  178. package/dist/esm/components/ui/toggle-group/index.d.ts.map +1 -1
  179. package/dist/esm/components/ui/toggle-group/index.js +73 -4
  180. package/dist/esm/components/ui/tooltip/index.d.ts +15 -0
  181. package/dist/esm/components/ui/tooltip/index.d.ts.map +1 -1
  182. package/dist/esm/components/ui/tooltip/index.js +16 -1
  183. package/dist/esm/domain/blotter/index.d.ts +4 -2
  184. package/dist/esm/domain/blotter/index.d.ts.map +1 -1
  185. package/dist/esm/domain/delinquency-aging/aging-buckets.d.ts.map +1 -1
  186. package/dist/esm/domain/delinquency-aging/aging-buckets.js +8 -3
  187. package/dist/esm/domain/delinquency-aging/index.d.ts.map +1 -1
  188. package/dist/esm/domain/delinquency-aging/index.js +8 -3
  189. package/dist/esm/domain/key-id/index.js +1 -1
  190. package/dist/esm/domain/status-rail/index.js +1 -1
  191. package/dist/esm/domain/threshold-gauge/index.d.ts.map +1 -1
  192. package/dist/esm/domain/threshold-gauge/index.js +9 -5
  193. package/dist/esm/enterprise/stat-card/index.d.ts.map +1 -1
  194. package/dist/esm/enterprise/stat-card/index.js +12 -4
  195. package/dist/globals.css +101 -32
  196. package/package.json +5 -3
@@ -34,15 +34,23 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  };
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
- exports.FileUpload = void 0;
37
+ exports.MultipleFileUpload = 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 = () => {
@@ -191,10 +226,18 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
191
226
  event.target.value = '';
192
227
  handleFile(file);
193
228
  };
229
+ // `preventDefault` FIRST, and unconditionally. Returning before it does
230
+ // not merely refuse the drop: nothing cancels the browser's OWN action for
231
+ // a dropped file, so the window navigates to the file and whatever the
232
+ // page held unsaved is gone. A zone the host turned off has to swallow the
233
+ // drop, never hand it back to the browser. `dropEffect` says so to the
234
+ // cursor while the file is still in the air.
194
235
  const onDragOver = (event) => {
195
- if (disabled)
196
- return;
197
236
  event.preventDefault();
237
+ if (disabled) {
238
+ event.dataTransfer.dropEffect = 'none';
239
+ return;
240
+ }
198
241
  setDragActive(true);
199
242
  };
200
243
  const onDragLeave = (event) => {
@@ -202,10 +245,13 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
202
245
  setDragActive(false);
203
246
  };
204
247
  const onDrop = (event) => {
205
- if (disabled)
206
- return;
207
248
  event.preventDefault();
249
+ // Cleared before the gate, not after, so a host that disables the zone
250
+ // mid-drag does not leave it highlighted for a drag that can no longer
251
+ // land.
208
252
  setDragActive(false);
253
+ if (disabled)
254
+ return;
209
255
  handleFile(event.dataTransfer.files?.[0]);
210
256
  };
211
257
  // Mouse convenience only: clicking the styled zone opens the picker. A
@@ -228,10 +274,301 @@ exports.FileUpload = React.forwardRef(function FileUpload({ accept, maxSizeBytes
228
274
  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
275
  'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', disabled
230
276
  ? '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) => {
277
+ : '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
278
  // Don't bubble to the zone's openPicker and re-open the dialog.
233
279
  event.stopPropagation();
234
280
  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-destructive text-xs font-medium", children: errorMessage(error) })) : null] }));
281
+ }, 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
282
  });
237
283
  exports.FileUpload.displayName = 'FileUpload';
284
+ /** The default English announcement for each rejection kind. */
285
+ function defaultMultipleErrorMessage(error) {
286
+ if (error.kind === 'too-many') {
287
+ return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
288
+ }
289
+ return defaultErrorMessage(error);
290
+ }
291
+ /** The default zone copy once the cap is reached, in grammatical English. */
292
+ function defaultFullMessage(maxFiles) {
293
+ return maxFiles === 1
294
+ ? 'Maximum of 1 file reached.'
295
+ : `Maximum of ${maxFiles} files reached.`;
296
+ }
297
+ /**
298
+ * MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
299
+ * accumulate them across repeated picks, validate each one, cap the total, and
300
+ * hand back `FileUploadResult[]`.
301
+ *
302
+ * A SIBLING COMPONENT, not a `multiple` flag. This library already answers
303
+ * "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
304
+ * strips `'multiple'` from its props on purpose: single-file is its contract,
305
+ * not a default it happens to have. A boolean would have forced every prop
306
+ * here into a union that means one thing when the flag is set and another when
307
+ * it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
308
+ * meaningless in half the configurations, and a remove control whose
309
+ * accessible name is fixed copy in one mode and per-file in the other. The
310
+ * plural props follow the house shape for plural components: `value?: T[]`
311
+ * with `onValueChange?: (values: T[]) => void`.
312
+ *
313
+ * WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
314
+ * Choosing, validating, capping, listing and removing are its job; uploading
315
+ * is not. That split is not squeamishness about scope, it is where the
316
+ * knowledge actually lives. An upload needs an endpoint, an auth scheme, a
317
+ * concurrency policy, a retry policy and, very often, a parent id that does
318
+ * not exist yet when the files are chosen: the motivating host stages evidence
319
+ * files while a form is being filled and can only upload them against the id
320
+ * that its create call returns afterwards. None of that is knowable from
321
+ * inside a library primitive, and a component that guessed would have to be
322
+ * fought rather than used. So the host keeps its own per-file record with
323
+ * status and retry, and this component keeps the part a form can hold and
324
+ * validate: the chosen files. `FileUploadResult[]` is a value; an upload state
325
+ * machine is not.
326
+ *
327
+ * ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
328
+ * rather than replacing it, because a user assembling five documents does it
329
+ * in two or three trips to the file dialog, not one. Everything else follows
330
+ * from that: room is measured against what is already selected, and the batch
331
+ * that overflows the cap still contributes the files that fit.
332
+ *
333
+ * A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
334
+ * failed read does not discard the rest of the batch, and it does not consume
335
+ * a slot either: validation runs over the WHOLE batch before the cap is
336
+ * applied, so a file that was never eligible cannot cost an eligible one its
337
+ * place, and `'too-many'` always names a file a slot would genuinely have
338
+ * taken. The alternative punishes
339
+ * a user for a mistake in one file by throwing away four good ones, and hands
340
+ * back no way to tell which was which. Every rejection is reported through
341
+ * `onError` and announced together in one `role="alert"`.
342
+ *
343
+ * Accessibility follows the sibling BELOW THE CAP: the real `<input
344
+ * type="file">` is `sr-only` but focusable and labelable, and never
345
+ * `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
346
+ * error both work while the picker is enabled. At the cap that changes, and
347
+ * the paragraph below says how.
348
+ * The file list sits OUTSIDE the click zone, so activating a remove control
349
+ * cannot also reopen the picker, and each remove control is named after its
350
+ * own file: a column of identical "Remove file" buttons tells a screen-reader
351
+ * user nothing about which row they are on.
352
+ *
353
+ * AT THE CAP the picker takes the native `disabled` attribute, and that DOES
354
+ * take it out of the tab order. This is the one state in which the input is
355
+ * not a focus target, and the one state in which focus-on-error cannot land on
356
+ * it, so it is a real cost rather than a free win. The cap has no counterpart
357
+ * in the single-file sibling, so the precedent followed here is
358
+ * `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
359
+ * no state worth keeping focusable, and native `disabled` is what both removes
360
+ * it from the tab order and keeps the dialog shut. The alternative, an enabled
361
+ * picker that opens the file dialog and then refuses every file with
362
+ * `too-many`, is a control that lies about being available. What keeps the cap
363
+ * from being a dead end is the escape hatch: the remove controls answer to
364
+ * `disabled` alone and NEVER to the cap, so they stay focusable and removing
365
+ * one file reopens the picker.
366
+ */
367
+ exports.MultipleFileUpload = React.forwardRef(function MultipleFileUpload({ accept, maxSizeBytes, maxFiles, value = [], readAs = 'text', labels, onValueChange, onError, disabled = false, id, className, 'aria-invalid': ariaInvalid, 'aria-required': ariaRequired, 'aria-describedby': ariaDescribedby, 'aria-label': ariaLabel, ...rest }, ref) {
368
+ const internalRef = React.useRef(null);
369
+ React.useImperativeHandle(ref, () => internalRef.current);
370
+ const reactId = React.useId();
371
+ const inputId = id ?? reactId;
372
+ const errorId = `${inputId}-file-upload-error`;
373
+ const [dragActive, setDragActive] = React.useState(false);
374
+ const [errors, setErrors] = React.useState([]);
375
+ // The authoritative base for the next append. Props win on every render, so
376
+ // the host stays in control; the commit below also writes through, so a
377
+ // second batch that settles before the host has re-rendered still appends to
378
+ // the first batch's result instead of overwriting it.
379
+ const valueRef = React.useRef(value);
380
+ React.useEffect(() => {
381
+ valueRef.current = value;
382
+ });
383
+ // Superseding is NOT the contract here the way it is in the single-file
384
+ // sibling — batches accumulate, so an in-flight read is never stale. The
385
+ // readers are tracked for the one case that does have to stop them: an
386
+ // unmount. A read that lands afterwards would settle its batch and commit,
387
+ // calling the host's `onValueChange` for a component that no longer exists.
388
+ const readersRef = React.useRef(new Set());
389
+ React.useEffect(() => () => {
390
+ for (const reader of readersRef.current)
391
+ reader.abort();
392
+ readersRef.current.clear();
393
+ }, []);
394
+ const invalid = ariaInvalid || errors.length > 0;
395
+ // Resolve the announcements BEFORE deciding whether the alert exists: a
396
+ // consumer that returns nothing for every rejection is opting out of this
397
+ // surface, and an association pointing at an unrendered node is worse than
398
+ // none.
399
+ const messages = errors
400
+ .map((failure) => labels?.error
401
+ ? labels.error(failure)
402
+ : defaultMultipleErrorMessage(failure))
403
+ .filter((message) => Boolean(message));
404
+ // A plain join, never `cn`: tailwind-merge treats these as class names and
405
+ // would drop an id that happens to look like a conflicting utility.
406
+ const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
407
+ .filter(Boolean)
408
+ .join(' ') || undefined;
409
+ const full = maxFiles !== undefined && value.length >= maxFiles;
410
+ // Removing must stay possible at the cap, so only the PICKER closes.
411
+ const pickerDisabled = disabled || full;
412
+ const roomFor = (selected) => maxFiles === undefined
413
+ ? Number.POSITIVE_INFINITY
414
+ : Math.max(maxFiles - selected, 0);
415
+ // The cap is enforced HERE, at the only place that appends, because only the
416
+ // commit knows the base it lands on. `ingest` measures room too, but for an
417
+ // asynchronous batch it measures it BEFORE any read settles: two overlapping
418
+ // batches both see the pre-commit selection and would each believe they fit.
419
+ const commit = (accepted, rejections) => {
420
+ const room = roomFor(valueRef.current.length);
421
+ const fitting = accepted.slice(0, room);
422
+ const overflow = accepted[room];
423
+ const failures = overflow !== undefined && maxFiles !== undefined
424
+ ? [
425
+ ...rejections,
426
+ { kind: 'too-many', file: overflow.file, maxFiles }
427
+ ]
428
+ : rejections;
429
+ setErrors(failures);
430
+ for (const rejection of failures)
431
+ onError?.(rejection);
432
+ if (fitting.length === 0)
433
+ return;
434
+ const next = [...valueRef.current, ...fitting];
435
+ valueRef.current = next;
436
+ onValueChange(next);
437
+ };
438
+ const ingest = (incoming) => {
439
+ if (incoming.length === 0)
440
+ return;
441
+ const room = roomFor(valueRef.current.length);
442
+ const rejections = [];
443
+ // Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
444
+ // room first would let a file that was never eligible consume a slot a
445
+ // good file could have used — one bad pick costing a good one, which is
446
+ // the opposite of a batch surviving its own casualties — and it would
447
+ // leave every file past the slice window neither validated nor reported.
448
+ // Validation is pure metadata (size and accept), so running it over files
449
+ // that may not fit costs nothing.
450
+ const eligible = [];
451
+ for (const file of incoming) {
452
+ const rejection = validateFile(file, { accept, maxSizeBytes });
453
+ // Continue rather than abort: one bad file must not cost the good ones.
454
+ if (rejection) {
455
+ rejections.push(rejection);
456
+ continue;
457
+ }
458
+ eligible.push(file);
459
+ }
460
+ // The cap then applies to the SURVIVORS. One rejection for the batch,
461
+ // naming the FIRST eligible file that did not fit: naming one already
462
+ // refused for its size or type would blame the cap for the wrong thing,
463
+ // and repeating it per overflowing file buries the actionable part.
464
+ if (eligible.length > room && maxFiles !== undefined) {
465
+ rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
466
+ }
467
+ const candidates = eligible.slice(0, room);
468
+ // Binary path: no decode, no reader, no retained garbage string.
469
+ if (readAs === 'none') {
470
+ commit(candidates.map((file) => ({ file, text: '' })), rejections);
471
+ return;
472
+ }
473
+ if (candidates.length === 0) {
474
+ commit([], rejections);
475
+ return;
476
+ }
477
+ // Slot-per-candidate so the emitted batch keeps PICK order regardless of
478
+ // the order the reads settle in. A null slot is a read that failed; the
479
+ // batch commits once every read has settled, one way or the other.
480
+ const slots = new Array(candidates.length).fill(null);
481
+ let remaining = candidates.length;
482
+ const settle = () => {
483
+ remaining -= 1;
484
+ if (remaining > 0)
485
+ return;
486
+ const accepted = [];
487
+ const readFailures = [];
488
+ slots.forEach((slot, index) => {
489
+ if (slot)
490
+ accepted.push(slot);
491
+ else
492
+ readFailures.push({ kind: 'read-failed', file: candidates[index] });
493
+ });
494
+ commit(accepted, [...rejections, ...readFailures]);
495
+ };
496
+ candidates.forEach((file, index) => {
497
+ const reader = new FileReader();
498
+ readersRef.current.add(reader);
499
+ // Deliberately no `onabort` handler: an aborted read must NOT settle,
500
+ // or the batch would commit at exactly the moment we are stopping it.
501
+ reader.onload = () => {
502
+ readersRef.current.delete(reader);
503
+ slots[index] = { file, text: String(reader.result ?? '') };
504
+ settle();
505
+ };
506
+ reader.onerror = () => {
507
+ readersRef.current.delete(reader);
508
+ settle();
509
+ };
510
+ reader.readAsText(file);
511
+ });
512
+ };
513
+ const onInputChange = (event) => {
514
+ const files = Array.from(event.target.files ?? []);
515
+ // Release the FileList the moment it has been read, or re-picking the same
516
+ // file is silently a no-op: the browser fires `change` only when the
517
+ // selection DIFFERS from what the input already holds.
518
+ event.target.value = '';
519
+ ingest(files);
520
+ };
521
+ // `preventDefault` FIRST, and unconditionally. Returning before it does
522
+ // not merely refuse the drop: nothing cancels the browser's OWN action for
523
+ // a dropped file, so the window navigates to the file and whatever the
524
+ // page held unsaved is gone. A zone the host turned off has to swallow the
525
+ // drop, never hand it back to the browser. `dropEffect` says so to the
526
+ // cursor while the file is still in the air.
527
+ const onDragOver = (event) => {
528
+ event.preventDefault();
529
+ if (disabled) {
530
+ event.dataTransfer.dropEffect = 'none';
531
+ return;
532
+ }
533
+ setDragActive(true);
534
+ };
535
+ const onDragLeave = (event) => {
536
+ event.preventDefault();
537
+ setDragActive(false);
538
+ };
539
+ const onDrop = (event) => {
540
+ event.preventDefault();
541
+ // Cleared before the gate, not after, so a host that disables the zone
542
+ // mid-drag does not leave it highlighted for a drag that can no longer
543
+ // land.
544
+ setDragActive(false);
545
+ if (disabled)
546
+ return;
547
+ // Deliberately NOT gated on `full`: a drop onto a full zone is answered
548
+ // with the too-many rejection, which says why, instead of nothing at all.
549
+ ingest(Array.from(event.dataTransfer.files ?? []));
550
+ };
551
+ // Mouse convenience only. A click that ORIGINATED on the input already opens
552
+ // the picker natively and bubbles up here, so ignore it or it opens twice.
553
+ const openPicker = (event) => {
554
+ if (pickerDisabled || event.target === internalRef.current)
555
+ return;
556
+ internalRef.current?.click();
557
+ };
558
+ const removeAt = (index) => {
559
+ // By index, not by name: two files can share a filename and identity is
560
+ // what the row actually stands for.
561
+ const next = valueRef.current.filter((_, position) => position !== index);
562
+ valueRef.current = next;
563
+ onValueChange(next);
564
+ };
565
+ 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 &&
566
+ 'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
567
+ ? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
568
+ : 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [(0, jsx_runtime_1.jsx)("input", { ref: internalRef, id: inputId, type: "file", multiple: true, accept: accept, disabled: pickerDisabled, 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" }), full && maxFiles !== undefined ? ((0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground", children: labels?.full
569
+ ? labels.full(maxFiles)
570
+ : defaultFullMessage(maxFiles) })) : ((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 files' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), value.length > 0 ? ((0, jsx_runtime_1.jsx)("ul", { className: "space-y-1", children: value.map((entry, index) => ((0, jsx_runtime_1.jsxs)("li", { className: "flex items-center gap-3 text-sm", 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: entry.file.name }), ' ', (0, jsx_runtime_1.jsx)("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(entry.file.size) })] }), (0, jsx_runtime_1.jsx)(icon_button_1.IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove
571
+ ? labels.remove(entry.file)
572
+ : `Remove ${entry.file.name}`, onClick: () => removeAt(index), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4", "aria-hidden": "true" }) })] }, `${entry.file.name}-${index}`))) })) : null, messages.length > 0 ? ((0, jsx_runtime_1.jsx)("div", { id: errorId, role: "alert", className: "space-y-1", children: messages.map((message, index) => ((0, jsx_runtime_1.jsx)("p", { className: "text-system-error-h1a text-xs font-medium", children: message }, index))) })) : null] }));
573
+ });
574
+ exports.MultipleFileUpload.displayName = 'MultipleFileUpload';
@@ -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;0CA2BvB,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,42 +69,87 @@ 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';
83
107
  const FormLabel = React.forwardRef(({ className, extra, children, ...props }, ref) => {
84
108
  const { required, error, formItemId } = useFormField();
85
- return ((0, jsx_runtime_1.jsxs)(label_1.Label, { ref: ref, className: (0, utils_1.cn)('text-muted-foreground flex justify-between text-sm font-semibold', error && 'text-destructive', className), htmlFor: formItemId, ...props, children: [(0, jsx_runtime_1.jsxs)("span", { children: [children, required ? ' *' : ''] }), extra] }));
109
+ return ((0, jsx_runtime_1.jsxs)(label_1.Label, { ref: ref, className: (0, utils_1.cn)('text-muted-foreground flex justify-between text-sm font-semibold',
110
+ // The error TEXT token, matching FormMessage below: `--destructive`
111
+ // is the badge/fill family and reads 3.80:1 as ink on the dark card.
112
+ error && 'text-system-error-h1a', className), htmlFor: formItemId, ...props, children: [(0, jsx_runtime_1.jsxs)("span", { children: [children, required ? ' *' : ''] }), extra] }));
86
113
  });
87
114
  exports.FormLabel = FormLabel;
88
115
  FormLabel.displayName = 'FormLabel';
89
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 })] }) }));
90
117
  exports.FormTooltip = FormTooltip;
91
118
  const FormControl = React.forwardRef(({ ...props }, ref) => {
92
- const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
93
- return ((0, jsx_runtime_1.jsx)(react_slot_1.Slot, { ref: ref, id: formItemId, "aria-describedby": !error
94
- ? `${formDescriptionId}`
95
- : `${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 }));
96
135
  });
97
136
  exports.FormControl = FormControl;
98
137
  FormControl.displayName = 'FormControl';
99
138
  const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
100
139
  const { formDescriptionId } = useFormField();
101
- return ((0, jsx_runtime_1.jsx)("p", { ref: ref, id: formDescriptionId, className: (0, utils_1.cn)('text-shadcn-400 text-xs font-medium', className), ...props }));
140
+ useDescribingSlot('description', true);
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 }));
102
142
  });
103
143
  exports.FormDescription = FormDescription;
104
144
  FormDescription.displayName = 'FormDescription';
105
145
  const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
106
146
  const { error, formMessageId } = useFormField();
107
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));
108
153
  if (!body) {
109
154
  return null;
110
155
  }
@@ -27,9 +27,37 @@
27
27
  /* Don't give the placeholder its own size or weight. At a smaller size it
28
28
  still shares the input's baseline, so it rode ~0.75px below the value and
29
29
  the text jumped the moment you typed; font-medium gave the hint the same
30
- presence as the label naming the field. */
30
+ presence as the label naming the field.
31
+
32
+ `w-0` is what lets the input shrink, and it pairs with `flex-1` rather
33
+ than fighting it: `flex-1` is `flex: 1 1 0%`, so the flex base size comes
34
+ from the basis and the input still grows back to fill its wrapper. What
35
+ `w-0` changes is the floor. A flex item never shrinks below its automatic
36
+ minimum size, and with no declared width that minimum falls back to the
37
+ control's own intrinsic size — 239px for a default `<input>`. Declaring a
38
+ width gives the flex algorithm a specified size to use instead, and 0 is
39
+ the only value that never floors anything.
40
+
41
+ `min-w-0` is NOT the fix here and was measured failing: it lowers this
42
+ input's own floor, but the automatic minimum of an ANCESTOR flex item is
43
+ computed from this control's min-content contribution, which `min-width`
44
+ does not lower. A consumer's own `flex-1` column around the field would
45
+ still refuse to shrink.
46
+
47
+ There is deliberately NO min-width floor either, and that was measured
48
+ too. A floor here cannot reach the sites that want one: a consumer
49
+ writing `.input-base` by hand tends to add its own `min-w-0`, which is a
50
+ `utilities`-layer class and outranks anything this `components`-layer rule
51
+ set declares. With `min-w-16` added here, the same fixture read 64px wide
52
+ without that hand-written `min-w-0` and 32px with it. The floor also cost
53
+ ground elsewhere: two inputs sharing a 120px row went from fitting to
54
+ overflowing by 52px. A caller whose field must hold its width says so on
55
+ its own wrapper, with `shrink-0`.
56
+
57
+ `scripts/measure-input-shrink.mjs` carries the fixtures and the numbers;
58
+ run it when touching this rule set. */
31
59
  .input-base {
32
- @apply px-input-px text-input-foreground placeholder:text-input-placeholder h-full min-h-0 flex-1 border-none bg-transparent outline-none focus:ring-0 focus:ring-offset-0;
60
+ @apply px-input-px text-input-foreground placeholder:text-input-placeholder h-full min-h-0 w-0 flex-1 border-none bg-transparent outline-none focus:ring-0 focus:ring-offset-0;
33
61
  }
34
62
 
35
63
  .input-disabled {