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

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 +235 -9
  14. package/dist/components/ui/file-upload/index.d.ts.map +1 -1
  15. package/dist/components/ui/file-upload/index.js +328 -13
  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 +235 -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 +327 -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
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAM9B,MAAM,MAAM,gBAAgB,GAAG;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAE3D,MAAM,MAAM,eAAe,GACvB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,IAAI,EAAE,IAAI,CAAA;CAAE,CAAA;AAEvC,MAAM,MAAM,eAAe,GAAG;IAC5B,wGAAwG;IACxG,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0FAA0F;IAC1F,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iEAAiE;IACjE,KAAK,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAA;IAC/B,iGAAiG;IACjG,QAAQ,EAAE,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI,CAAA;IACnD,gHAAgH;IAChH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IAC1C,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB,GAAG,IAAI,CACN,KAAK,CAAC,mBAAmB,CAAC,gBAAgB,CAAC,EAEzC,MAAM,GACN,QAAQ,GACR,OAAO,GACP,UAAU,GACV,UAAU,GACV,UAAU,GACV,WAAW,GAEX,UAAU,CACb,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,IAAI,EACV,IAAI,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/C,eAAe,GAAG,IAAI,CASxB;AA8CD,eAAO,MAAM,UAAU;IAhGrB,wGAAwG;aAC/F,MAAM;IACf,0FAA0F;mBAC3E,MAAM;IACrB,iEAAiE;YACzD,gBAAgB,GAAG,IAAI;IAC/B,iGAAiG;cACvF,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,KAAK,IAAI;IACnD,gHAAgH;cACtG,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI;eAC/B,OAAO;SACb,MAAM;gBACC,MAAM;qBACD,OAAO;sBACN,OAAO;yBACJ,MAAM;mBACZ,MAAM;6LA6RtB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/components/ui/file-upload/index.tsx"],"names":[],"mappings":"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"}
@@ -1,11 +1,18 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  /**
4
- * FileUpload — a controlled "pick a file, read it to text, hand back the text"
5
- * primitive. The defining job: select one file (by click, keyboard, or
6
- * drag-and-drop), validate it against an accept filter + a byte ceiling, read
7
- * it as UTF-8 via FileReader.readAsText, and emit `{ file, text }`. Binary/DER
8
- * (.pfx/.p12) is explicitly OUT of scope — readAsText only.
4
+ * FileUpload — a controlled "pick a file, hand it back" primitive. The defining
5
+ * job: select one file (by click, keyboard, or drag-and-drop), validate it
6
+ * against an accept filter + a byte ceiling, and emit `{ file, text }`.
7
+ *
8
+ * `readAs` decides whether the bytes are decoded on the way through. `'text'`
9
+ * (the default) reads UTF-8 via FileReader.readAsText, which is what a PEM/CSV
10
+ * host wants. `'none'` hands the `File` over untouched and is the only mode a
11
+ * BINARY file can use: a PDF, XLSX or PFX put through readAsText decodes into
12
+ * replacement-character garbage, and that garbage string is then retained for
13
+ * as long as the host holds the value — 20 MiB of PDF became a useless 20 MiB
14
+ * string, and the host still had to read the file a second time to get at the
15
+ * real bytes.
9
16
  *
10
17
  * The real `<input type="file">` IS the accessible control: it is `sr-only`
11
18
  * (visually hidden) but focusable and labelable — never `aria-hidden`, never
@@ -26,6 +33,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
26
33
  * a value, so it cannot masquerade as valid. `accept` is validated against
27
34
  * BOTH extension and MIME because the native `accept` attribute is only a
28
35
  * browser hint and is bypassable via drag-drop.
36
+ *
37
+ * A host that already announces the rejection itself — a toast in its own
38
+ * locale, driven off `onError` — silences this one by returning nothing from
39
+ * `labels.error`. Two announcements for one event, in two languages, is the
40
+ * accessibility defect; the wording is not.
29
41
  */
30
42
  import * as React from 'react';
31
43
  import { Upload, X } from 'lucide-react';
@@ -68,8 +80,12 @@ function matchesAccept(file, accept) {
68
80
  return mime === token;
69
81
  });
70
82
  }
71
- /** Humanize a byte count for the selected-file chip. Binary units, 1 decimal. */
72
- function humanizeSize(bytes) {
83
+ /**
84
+ * Humanize a byte count for the selected-file chip. Binary units, 1 decimal.
85
+ * Exported so a `labels.error` override can format `maxSizeBytes` exactly the
86
+ * way the chip and the default message do, instead of re-deriving binary units.
87
+ */
88
+ export function humanizeSize(bytes) {
73
89
  if (bytes < 1024)
74
90
  return `${bytes} B`;
75
91
  const units = ['KB', 'MB', 'GB'];
@@ -81,8 +97,8 @@ function humanizeSize(bytes) {
81
97
  }
82
98
  return `${size.toFixed(1)} ${units[unit]}`;
83
99
  }
84
- /** A human-readable announcement for each rejection kind. */
85
- function errorMessage(error) {
100
+ /** The default English announcement for each rejection kind. */
101
+ function defaultErrorMessage(error) {
86
102
  switch (error.kind) {
87
103
  case 'too-large':
88
104
  return `File is too large (max ${humanizeSize(error.maxSizeBytes)}).`;
@@ -92,7 +108,7 @@ function errorMessage(error) {
92
108
  return 'Could not read the file.';
93
109
  }
94
110
  }
95
- export const 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) {
111
+ export const 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) {
96
112
  const internalRef = React.useRef(null);
97
113
  React.useImperativeHandle(ref, () => internalRef.current);
98
114
  const reactId = React.useId();
@@ -104,12 +120,21 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
104
120
  // component's own validation/read error — a self-detected bad pick must read
105
121
  // as invalid even outside a form.
106
122
  const invalid = ariaInvalid || error !== null;
123
+ // Resolve the announcement BEFORE deciding whether the alert exists: a
124
+ // consumer that returns nothing is opting out of this surface entirely,
125
+ // and an association pointing at an unrendered node is worse than none.
126
+ const resolved = error
127
+ ? labels?.error
128
+ ? labels.error(error)
129
+ : defaultErrorMessage(error)
130
+ : null;
131
+ const errorText = resolved ? resolved : null;
107
132
  // Radix Slot OVERWRITES aria-describedby (it does not merge), so merge the
108
133
  // primitive's own role=alert error id with the FormControl-injected one so
109
134
  // both associations coexist on the input. A plain join, never `cn` —
110
135
  // tailwind-merge treats these as class names and would drop an id that
111
136
  // happens to look like a conflicting utility.
112
- const describedBy = [ariaDescribedby, error ? errorId : undefined]
137
+ const describedBy = [ariaDescribedby, errorText ? errorId : undefined]
113
138
  .filter(Boolean)
114
139
  .join(' ') || undefined;
115
140
  // Last-resolved-wins: a slow read for pick A must not overwrite a newer
@@ -125,6 +150,15 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
125
150
  onError?.(validationError);
126
151
  return;
127
152
  }
153
+ // Binary path: no decode, no reader, no retained garbage string. The
154
+ // stale-rejection clear still has to happen here — the alert is this
155
+ // component's own state and a good pick must retire it.
156
+ if (readAs === 'none') {
157
+ readerRef.current = null;
158
+ setError(null);
159
+ onSelect({ file, text: '' });
160
+ return;
161
+ }
128
162
  const reader = new FileReader();
129
163
  readerRef.current = reader;
130
164
  reader.onload = () => {
@@ -191,10 +225,290 @@ export const FileUpload = React.forwardRef(function FileUpload({ accept, maxSize
191
225
  return (_jsxs("div", { className: cn('space-y-2', className), children: [_jsxs("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: 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 &&
192
226
  'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', disabled
193
227
  ? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
194
- : 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [_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 }), _jsx(Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', _jsx("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), _jsx(IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": "Remove file", onClick: (event) => {
228
+ : 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [_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 }), _jsx(Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), value ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "text-foreground font-medium", children: value.file.name }), ' ', _jsx("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(value.file.size) })] }), _jsx(IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove?.trim() ? labels.remove : 'Remove file', onClick: (event) => {
195
229
  // Don't bubble to the zone's openPicker and re-open the dialog.
196
230
  event.stopPropagation();
197
231
  clear();
198
- }, children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children: "Choose a file" }), ' ', "or drag and drop"] }))] }), error ? (_jsx("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorMessage(error) })) : null] }));
232
+ }, children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose a file' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), errorText ? (_jsx("p", { id: errorId, role: "alert", className: "text-system-error-h1a text-xs font-medium", children: errorText })) : null] }));
199
233
  });
200
234
  FileUpload.displayName = 'FileUpload';
235
+ /** The default English announcement for each rejection kind. */
236
+ function defaultMultipleErrorMessage(error) {
237
+ if (error.kind === 'too-many') {
238
+ return `Too many files (max ${error.maxFiles}). ${error.file.name} was not added.`;
239
+ }
240
+ return defaultErrorMessage(error);
241
+ }
242
+ /** The default zone copy once the cap is reached, in grammatical English. */
243
+ function defaultFullMessage(maxFiles) {
244
+ return maxFiles === 1
245
+ ? 'Maximum of 1 file reached.'
246
+ : `Maximum of ${maxFiles} files reached.`;
247
+ }
248
+ /**
249
+ * MultipleFileUpload: the plural sibling of FileUpload. Pick SEVERAL files,
250
+ * accumulate them across repeated picks, validate each one, cap the total, and
251
+ * hand back `FileUploadResult[]`.
252
+ *
253
+ * A SIBLING COMPONENT, not a `multiple` flag. This library already answers
254
+ * "this one takes many" that way (`Select` / `MultipleSelect`), and FileUpload
255
+ * strips `'multiple'` from its props on purpose: single-file is its contract,
256
+ * not a default it happens to have. A boolean would have forced every prop
257
+ * here into a union that means one thing when the flag is set and another when
258
+ * it is not: `value` as `Result | Result[] | null`, a `maxFiles` that is
259
+ * meaningless in half the configurations, and a remove control whose
260
+ * accessible name is fixed copy in one mode and per-file in the other. The
261
+ * plural props follow the house shape for plural components: `value?: T[]`
262
+ * with `onValueChange?: (values: T[]) => void`.
263
+ *
264
+ * WHERE THE LINE SITS: this component owns SELECTION and nothing after it.
265
+ * Choosing, validating, capping, listing and removing are its job; uploading
266
+ * is not. That split is not squeamishness about scope, it is where the
267
+ * knowledge actually lives. An upload needs an endpoint, an auth scheme, a
268
+ * concurrency policy, a retry policy and, very often, a parent id that does
269
+ * not exist yet when the files are chosen: the motivating host stages evidence
270
+ * files while a form is being filled and can only upload them against the id
271
+ * that its create call returns afterwards. None of that is knowable from
272
+ * inside a library primitive, and a component that guessed would have to be
273
+ * fought rather than used. So the host keeps its own per-file record with
274
+ * status and retry, and this component keeps the part a form can hold and
275
+ * validate: the chosen files. `FileUploadResult[]` is a value; an upload state
276
+ * machine is not.
277
+ *
278
+ * ACCUMULATION is the defining behaviour. A second pick ADDS to the selection
279
+ * rather than replacing it, because a user assembling five documents does it
280
+ * in two or three trips to the file dialog, not one. Everything else follows
281
+ * from that: room is measured against what is already selected, and the batch
282
+ * that overflows the cap still contributes the files that fit.
283
+ *
284
+ * A BATCH SURVIVES ITS OWN CASUALTIES. One file rejected for type, size or a
285
+ * failed read does not discard the rest of the batch, and it does not consume
286
+ * a slot either: validation runs over the WHOLE batch before the cap is
287
+ * applied, so a file that was never eligible cannot cost an eligible one its
288
+ * place, and `'too-many'` always names a file a slot would genuinely have
289
+ * taken. The alternative punishes
290
+ * a user for a mistake in one file by throwing away four good ones, and hands
291
+ * back no way to tell which was which. Every rejection is reported through
292
+ * `onError` and announced together in one `role="alert"`.
293
+ *
294
+ * Accessibility follows the sibling BELOW THE CAP: the real `<input
295
+ * type="file">` is `sr-only` but focusable and labelable, and never
296
+ * `aria-hidden`, so FormControl-injected ARIA and react-hook-form's focus on
297
+ * error both work while the picker is enabled. At the cap that changes, and
298
+ * the paragraph below says how.
299
+ * The file list sits OUTSIDE the click zone, so activating a remove control
300
+ * cannot also reopen the picker, and each remove control is named after its
301
+ * own file: a column of identical "Remove file" buttons tells a screen-reader
302
+ * user nothing about which row they are on.
303
+ *
304
+ * AT THE CAP the picker takes the native `disabled` attribute, and that DOES
305
+ * take it out of the tab order. This is the one state in which the input is
306
+ * not a focus target, and the one state in which focus-on-error cannot land on
307
+ * it, so it is a real cost rather than a free win. The cap has no counterpart
308
+ * in the single-file sibling, so the precedent followed here is
309
+ * `DateRangePicker`'s trigger: a control whose only job is to open a dialog has
310
+ * no state worth keeping focusable, and native `disabled` is what both removes
311
+ * it from the tab order and keeps the dialog shut. The alternative, an enabled
312
+ * picker that opens the file dialog and then refuses every file with
313
+ * `too-many`, is a control that lies about being available. What keeps the cap
314
+ * from being a dead end is the escape hatch: the remove controls answer to
315
+ * `disabled` alone and NEVER to the cap, so they stay focusable and removing
316
+ * one file reopens the picker.
317
+ */
318
+ export const 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) {
319
+ const internalRef = React.useRef(null);
320
+ React.useImperativeHandle(ref, () => internalRef.current);
321
+ const reactId = React.useId();
322
+ const inputId = id ?? reactId;
323
+ const errorId = `${inputId}-file-upload-error`;
324
+ const [dragActive, setDragActive] = React.useState(false);
325
+ const [errors, setErrors] = React.useState([]);
326
+ // The authoritative base for the next append. Props win on every render, so
327
+ // the host stays in control; the commit below also writes through, so a
328
+ // second batch that settles before the host has re-rendered still appends to
329
+ // the first batch's result instead of overwriting it.
330
+ const valueRef = React.useRef(value);
331
+ React.useEffect(() => {
332
+ valueRef.current = value;
333
+ });
334
+ // Superseding is NOT the contract here the way it is in the single-file
335
+ // sibling — batches accumulate, so an in-flight read is never stale. The
336
+ // readers are tracked for the one case that does have to stop them: an
337
+ // unmount. A read that lands afterwards would settle its batch and commit,
338
+ // calling the host's `onValueChange` for a component that no longer exists.
339
+ const readersRef = React.useRef(new Set());
340
+ React.useEffect(() => () => {
341
+ for (const reader of readersRef.current)
342
+ reader.abort();
343
+ readersRef.current.clear();
344
+ }, []);
345
+ const invalid = ariaInvalid || errors.length > 0;
346
+ // Resolve the announcements BEFORE deciding whether the alert exists: a
347
+ // consumer that returns nothing for every rejection is opting out of this
348
+ // surface, and an association pointing at an unrendered node is worse than
349
+ // none.
350
+ const messages = errors
351
+ .map((failure) => labels?.error
352
+ ? labels.error(failure)
353
+ : defaultMultipleErrorMessage(failure))
354
+ .filter((message) => Boolean(message));
355
+ // A plain join, never `cn`: tailwind-merge treats these as class names and
356
+ // would drop an id that happens to look like a conflicting utility.
357
+ const describedBy = [ariaDescribedby, messages.length > 0 ? errorId : undefined]
358
+ .filter(Boolean)
359
+ .join(' ') || undefined;
360
+ const full = maxFiles !== undefined && value.length >= maxFiles;
361
+ // Removing must stay possible at the cap, so only the PICKER closes.
362
+ const pickerDisabled = disabled || full;
363
+ const roomFor = (selected) => maxFiles === undefined
364
+ ? Number.POSITIVE_INFINITY
365
+ : Math.max(maxFiles - selected, 0);
366
+ // The cap is enforced HERE, at the only place that appends, because only the
367
+ // commit knows the base it lands on. `ingest` measures room too, but for an
368
+ // asynchronous batch it measures it BEFORE any read settles: two overlapping
369
+ // batches both see the pre-commit selection and would each believe they fit.
370
+ const commit = (accepted, rejections) => {
371
+ const room = roomFor(valueRef.current.length);
372
+ const fitting = accepted.slice(0, room);
373
+ const overflow = accepted[room];
374
+ const failures = overflow !== undefined && maxFiles !== undefined
375
+ ? [
376
+ ...rejections,
377
+ { kind: 'too-many', file: overflow.file, maxFiles }
378
+ ]
379
+ : rejections;
380
+ setErrors(failures);
381
+ for (const rejection of failures)
382
+ onError?.(rejection);
383
+ if (fitting.length === 0)
384
+ return;
385
+ const next = [...valueRef.current, ...fitting];
386
+ valueRef.current = next;
387
+ onValueChange(next);
388
+ };
389
+ const ingest = (incoming) => {
390
+ if (incoming.length === 0)
391
+ return;
392
+ const room = roomFor(valueRef.current.length);
393
+ const rejections = [];
394
+ // Validate EVERY file BEFORE the cap is applied. Slicing to the remaining
395
+ // room first would let a file that was never eligible consume a slot a
396
+ // good file could have used — one bad pick costing a good one, which is
397
+ // the opposite of a batch surviving its own casualties — and it would
398
+ // leave every file past the slice window neither validated nor reported.
399
+ // Validation is pure metadata (size and accept), so running it over files
400
+ // that may not fit costs nothing.
401
+ const eligible = [];
402
+ for (const file of incoming) {
403
+ const rejection = validateFile(file, { accept, maxSizeBytes });
404
+ // Continue rather than abort: one bad file must not cost the good ones.
405
+ if (rejection) {
406
+ rejections.push(rejection);
407
+ continue;
408
+ }
409
+ eligible.push(file);
410
+ }
411
+ // The cap then applies to the SURVIVORS. One rejection for the batch,
412
+ // naming the FIRST eligible file that did not fit: naming one already
413
+ // refused for its size or type would blame the cap for the wrong thing,
414
+ // and repeating it per overflowing file buries the actionable part.
415
+ if (eligible.length > room && maxFiles !== undefined) {
416
+ rejections.push({ kind: 'too-many', file: eligible[room], maxFiles });
417
+ }
418
+ const candidates = eligible.slice(0, room);
419
+ // Binary path: no decode, no reader, no retained garbage string.
420
+ if (readAs === 'none') {
421
+ commit(candidates.map((file) => ({ file, text: '' })), rejections);
422
+ return;
423
+ }
424
+ if (candidates.length === 0) {
425
+ commit([], rejections);
426
+ return;
427
+ }
428
+ // Slot-per-candidate so the emitted batch keeps PICK order regardless of
429
+ // the order the reads settle in. A null slot is a read that failed; the
430
+ // batch commits once every read has settled, one way or the other.
431
+ const slots = new Array(candidates.length).fill(null);
432
+ let remaining = candidates.length;
433
+ const settle = () => {
434
+ remaining -= 1;
435
+ if (remaining > 0)
436
+ return;
437
+ const accepted = [];
438
+ const readFailures = [];
439
+ slots.forEach((slot, index) => {
440
+ if (slot)
441
+ accepted.push(slot);
442
+ else
443
+ readFailures.push({ kind: 'read-failed', file: candidates[index] });
444
+ });
445
+ commit(accepted, [...rejections, ...readFailures]);
446
+ };
447
+ candidates.forEach((file, index) => {
448
+ const reader = new FileReader();
449
+ readersRef.current.add(reader);
450
+ // Deliberately no `onabort` handler: an aborted read must NOT settle,
451
+ // or the batch would commit at exactly the moment we are stopping it.
452
+ reader.onload = () => {
453
+ readersRef.current.delete(reader);
454
+ slots[index] = { file, text: String(reader.result ?? '') };
455
+ settle();
456
+ };
457
+ reader.onerror = () => {
458
+ readersRef.current.delete(reader);
459
+ settle();
460
+ };
461
+ reader.readAsText(file);
462
+ });
463
+ };
464
+ const onInputChange = (event) => {
465
+ const files = Array.from(event.target.files ?? []);
466
+ // Release the FileList the moment it has been read, or re-picking the same
467
+ // file is silently a no-op: the browser fires `change` only when the
468
+ // selection DIFFERS from what the input already holds.
469
+ event.target.value = '';
470
+ ingest(files);
471
+ };
472
+ const onDragOver = (event) => {
473
+ if (disabled)
474
+ return;
475
+ event.preventDefault();
476
+ setDragActive(true);
477
+ };
478
+ const onDragLeave = (event) => {
479
+ event.preventDefault();
480
+ setDragActive(false);
481
+ };
482
+ const onDrop = (event) => {
483
+ if (disabled)
484
+ return;
485
+ event.preventDefault();
486
+ setDragActive(false);
487
+ // Deliberately NOT gated on `full`: a drop onto a full zone is answered
488
+ // with the too-many rejection, which says why, instead of nothing at all.
489
+ ingest(Array.from(event.dataTransfer.files ?? []));
490
+ };
491
+ // Mouse convenience only. A click that ORIGINATED on the input already opens
492
+ // the picker natively and bubbles up here, so ignore it or it opens twice.
493
+ const openPicker = (event) => {
494
+ if (pickerDisabled || event.target === internalRef.current)
495
+ return;
496
+ internalRef.current?.click();
497
+ };
498
+ const removeAt = (index) => {
499
+ // By index, not by name: two files can share a filename and identity is
500
+ // what the row actually stands for.
501
+ const next = valueRef.current.filter((_, position) => position !== index);
502
+ valueRef.current = next;
503
+ onValueChange(next);
504
+ };
505
+ return (_jsxs("div", { className: cn('space-y-2', className), children: [_jsxs("div", { onClick: openPicker, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, className: 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 &&
506
+ 'border-ring ring-ring ring-offset-background ring-2 ring-offset-1', pickerDisabled
507
+ ? 'border-muted bg-muted/30 cursor-not-allowed shadow-none'
508
+ : 'cursor-pointer'), "aria-invalid": invalid || undefined, children: [_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 }), _jsx(Upload, { className: "text-muted-foreground size-4 shrink-0", "aria-hidden": "true" }), full && maxFiles !== undefined ? (_jsx("span", { className: "text-muted-foreground", children: labels?.full
509
+ ? labels.full(maxFiles)
510
+ : defaultFullMessage(maxFiles) })) : (_jsxs("span", { className: "text-muted-foreground", children: [_jsx("span", { className: "text-foreground font-medium", children: labels?.action ?? 'Choose files' }), ' ', labels?.hint ?? 'or drag and drop'] }))] }), value.length > 0 ? (_jsx("ul", { className: "space-y-1", children: value.map((entry, index) => (_jsxs("li", { className: "flex items-center gap-3 text-sm", children: [_jsxs("span", { className: "min-w-0 flex-1 truncate", children: [_jsx("span", { className: "text-foreground font-medium", children: entry.file.name }), ' ', _jsx("span", { className: "text-muted-foreground tabular-nums", children: humanizeSize(entry.file.size) })] }), _jsx(IconButton, { type: "button", variant: "plain", size: "small", disabled: disabled, "aria-label": labels?.remove
511
+ ? labels.remove(entry.file)
512
+ : `Remove ${entry.file.name}`, onClick: () => removeAt(index), children: _jsx(X, { className: "size-4", "aria-hidden": "true" }) })] }, `${entry.file.name}-${index}`))) })) : null, messages.length > 0 ? (_jsx("div", { id: errorId, role: "alert", className: "space-y-1", children: messages.map((message, index) => (_jsx("p", { className: "text-system-error-h1a text-xs font-medium", children: message }, index))) })) : null] }));
513
+ });
514
+ 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;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"}
@@ -23,7 +23,7 @@ const useFormField = () => {
23
23
  const fieldState = form && fieldContext.name
24
24
  ? form.getFieldState(fieldContext.name, form.formState)
25
25
  : undefined;
26
- const { id, required } = itemContext;
26
+ const { id, required, described, onDescribedChange } = itemContext;
27
27
  return {
28
28
  id,
29
29
  name: fieldContext.name,
@@ -31,13 +31,37 @@ const useFormField = () => {
31
31
  formDescriptionId: `${id}-form-item-description`,
32
32
  formMessageId: `${id}-form-item-message`,
33
33
  required,
34
+ described,
35
+ onDescribedChange,
34
36
  ...fieldState
35
37
  };
36
38
  };
37
39
  const FormItemContext = React.createContext({});
40
+ /**
41
+ * Announce that this slot is on the page, for as long as it is.
42
+ *
43
+ * `rendered` is a parameter rather than a caller-side condition because
44
+ * `FormMessage` returns null when it has nothing to say, and a hook cannot sit
45
+ * behind that early return.
46
+ */
47
+ function useDescribingSlot(slot, rendered) {
48
+ const { onDescribedChange } = React.useContext(FormItemContext);
49
+ React.useEffect(() => {
50
+ if (!rendered)
51
+ return;
52
+ onDescribedChange?.(slot, true);
53
+ return () => onDescribedChange?.(slot, false);
54
+ }, [onDescribedChange, slot, rendered]);
55
+ }
38
56
  const FormItem = React.forwardRef(({ className, required, ...props }, ref) => {
39
57
  const id = React.useId();
40
- return (_jsx(FormItemContext.Provider, { value: { id, required }, children: _jsx("div", { ref: ref, className: cn('space-y-2', className), ...props }) }));
58
+ const [described, setDescribed] = React.useState({ message: false, description: false });
59
+ // Keeps the object identity when nothing moved, so a slot re-registering
60
+ // does not re-render the item for no reason.
61
+ const onDescribedChange = React.useCallback((slot, rendered) => setDescribed((current) => current[slot] === rendered
62
+ ? current
63
+ : { ...current, [slot]: rendered }), []);
64
+ return (_jsx(FormItemContext.Provider, { value: { id, required, described, onDescribedChange }, children: _jsx("div", { ref: ref, className: cn('space-y-2', className), ...props }) }));
41
65
  });
42
66
  FormItem.displayName = 'FormItem';
43
67
  const FormLabel = React.forwardRef(({ className, extra, children, ...props }, ref) => {
@@ -50,20 +74,38 @@ const FormLabel = React.forwardRef(({ className, extra, children, ...props }, re
50
74
  FormLabel.displayName = 'FormLabel';
51
75
  export const FormTooltip = ({ children, ...others }) => (_jsx(TooltipProvider, { ...others, children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(HelpCircle, { className: "text-container-text ml-2 h-4 w-4" }) }), _jsx(TooltipContent, { children: children })] }) }));
52
76
  const FormControl = React.forwardRef(({ ...props }, ref) => {
53
- const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
54
- return (_jsx(Slot, { ref: ref, id: formItemId, "aria-describedby": !error
55
- ? `${formDescriptionId}`
56
- : `${formDescriptionId} ${formMessageId}`, "aria-invalid": !!error, ...props }));
77
+ const { error, formItemId, formDescriptionId, formMessageId, described } = useFormField();
78
+ // Only ids that resolve. A screen reader drops a dangling IDREF silently, so
79
+ // the barrier this removes is diagnostic rather than operational: a form with
80
+ // thirteen broken associations is a form where a real one cannot be spotted.
81
+ //
82
+ // Message first: a reader hears the error before the hint. `undefined` when
83
+ // both slots are empty, which drops the attribute — and, because Radix Slot
84
+ // OVERWRITES rather than merges, still lets the wrapped element carry its own
85
+ // (`ui/file-upload` merges the injected id with its own error id that way).
86
+ const describedBy = [
87
+ described?.message ? formMessageId : undefined,
88
+ described?.description ? formDescriptionId : undefined
89
+ ]
90
+ .filter(Boolean)
91
+ .join(' ') || undefined;
92
+ return (_jsx(Slot, { ref: ref, id: formItemId, "aria-describedby": describedBy, "aria-invalid": !!error, ...props }));
57
93
  });
58
94
  FormControl.displayName = 'FormControl';
59
95
  const FormDescription = React.forwardRef(({ className, ...props }, ref) => {
60
96
  const { formDescriptionId } = useFormField();
97
+ useDescribingSlot('description', true);
61
98
  return (_jsx("p", { ref: ref, id: formDescriptionId, className: cn('text-muted-foreground text-xs font-medium', className), ...props }));
62
99
  });
63
100
  FormDescription.displayName = 'FormDescription';
64
101
  const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => {
65
102
  const { error, formMessageId } = useFormField();
66
103
  const body = error ? String(error?.message) : children;
104
+ // Registered on `body`, not on `error`: a message given plain children
105
+ // renders copy that the control has to point at too. The old expression only
106
+ // added the message id when react-hook-form reported an error, so a
107
+ // standalone message was rendered and never associated.
108
+ useDescribingSlot('message', Boolean(body));
67
109
  if (!body) {
68
110
  return null;
69
111
  }
@@ -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"}