@eifi1/ui-kit 0.5.1 → 0.6.1

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 (136) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +103 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/checkbox.d.ts +9 -0
  8. package/dist/components/checkbox.js +7 -2
  9. package/dist/components/checkbox.js.map +1 -1
  10. package/dist/components/chip.d.ts +3 -2
  11. package/dist/components/chip.js +14 -1
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/choice-card.d.ts +100 -0
  14. package/dist/components/choice-card.js +170 -0
  15. package/dist/components/choice-card.js.map +1 -0
  16. package/dist/components/combobox-core.d.ts +76 -6
  17. package/dist/components/combobox-core.js +119 -49
  18. package/dist/components/combobox-core.js.map +1 -1
  19. package/dist/components/combobox.d.ts +12 -2
  20. package/dist/components/combobox.js +42 -17
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/danger-confirm.d.ts +91 -0
  23. package/dist/components/danger-confirm.js +181 -0
  24. package/dist/components/danger-confirm.js.map +1 -0
  25. package/dist/components/dialog-frame.d.ts +84 -0
  26. package/dist/components/dialog-frame.js +86 -0
  27. package/dist/components/dialog-frame.js.map +1 -0
  28. package/dist/components/disclosure.d.ts +108 -0
  29. package/dist/components/disclosure.js +127 -0
  30. package/dist/components/disclosure.js.map +1 -0
  31. package/dist/components/entity-combobox.d.ts +17 -3
  32. package/dist/components/entity-combobox.js +25 -5
  33. package/dist/components/entity-combobox.js.map +1 -1
  34. package/dist/components/file-button.d.ts +161 -0
  35. package/dist/components/file-button.js +243 -0
  36. package/dist/components/file-button.js.map +1 -0
  37. package/dist/components/file-dropzone.d.ts +72 -23
  38. package/dist/components/file-dropzone.js +219 -94
  39. package/dist/components/file-dropzone.js.map +1 -1
  40. package/dist/components/icon-picker.d.ts +72 -0
  41. package/dist/components/icon-picker.js +104 -0
  42. package/dist/components/icon-picker.js.map +1 -0
  43. package/dist/components/mini-calendar.d.ts +3 -0
  44. package/dist/components/mini-calendar.js +4 -3
  45. package/dist/components/mini-calendar.js.map +1 -1
  46. package/dist/components/modal.d.ts +8 -1
  47. package/dist/components/modal.js +4 -2
  48. package/dist/components/modal.js.map +1 -1
  49. package/dist/components/multi-entity-combobox.d.ts +16 -3
  50. package/dist/components/multi-entity-combobox.js +25 -5
  51. package/dist/components/multi-entity-combobox.js.map +1 -1
  52. package/dist/components/number-field.d.ts +41 -1
  53. package/dist/components/number-field.js +42 -10
  54. package/dist/components/number-field.js.map +1 -1
  55. package/dist/components/number-input.d.ts +35 -2
  56. package/dist/components/number-input.js +35 -4
  57. package/dist/components/number-input.js.map +1 -1
  58. package/dist/components/numpad-sheet.d.ts +7 -0
  59. package/dist/components/search-field.d.ts +16 -0
  60. package/dist/components/search-field.js +29 -7
  61. package/dist/components/search-field.js.map +1 -1
  62. package/dist/components/signature-pad.d.ts +43 -1
  63. package/dist/components/signature-pad.js +74 -2
  64. package/dist/components/signature-pad.js.map +1 -1
  65. package/dist/components/swatch-picker.d.ts +69 -0
  66. package/dist/components/swatch-picker.js +75 -0
  67. package/dist/components/swatch-picker.js.map +1 -0
  68. package/dist/components/switch.d.ts +9 -0
  69. package/dist/components/switch.js +7 -2
  70. package/dist/components/switch.js.map +1 -1
  71. package/dist/components/tile-radio.d.ts +50 -0
  72. package/dist/components/tile-radio.js +140 -0
  73. package/dist/components/tile-radio.js.map +1 -0
  74. package/dist/components/toggle-group.d.ts +27 -5
  75. package/dist/components/toggle-group.js +22 -15
  76. package/dist/components/toggle-group.js.map +1 -1
  77. package/dist/components/ui.d.ts +150 -12
  78. package/dist/components/ui.js +196 -22
  79. package/dist/components/ui.js.map +1 -1
  80. package/dist/i18n/defaults.d.ts +7 -0
  81. package/dist/i18n/defaults.js +13 -2
  82. package/dist/i18n/defaults.js.map +1 -1
  83. package/dist/i18n/kit-labels.d.ts +38 -5
  84. package/dist/i18n/kit-labels.js +12 -4
  85. package/dist/i18n/kit-labels.js.map +1 -1
  86. package/dist/index.d.ts +16 -7
  87. package/dist/index.js +12 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/lib/table-text.d.ts +127 -0
  90. package/dist/lib/table-text.js +82 -0
  91. package/dist/lib/table-text.js.map +1 -0
  92. package/dist/rhf/form.d.ts +79 -0
  93. package/dist/rhf/form.js +143 -0
  94. package/dist/rhf/form.js.map +1 -0
  95. package/dist/rhf.d.ts +4 -0
  96. package/dist/rhf.js +3 -0
  97. package/dist/rhf.js.map +1 -0
  98. package/dist/shell/app-shell.js +3 -1
  99. package/dist/shell/app-shell.js.map +1 -1
  100. package/dist/table-text.d.ts +1 -0
  101. package/dist/table-text.js +3 -0
  102. package/dist/table-text.js.map +1 -0
  103. package/package.json +14 -1
  104. package/src/components/autocomplete.tsx +429 -0
  105. package/src/components/checkbox.tsx +16 -0
  106. package/src/components/chip.tsx +24 -2
  107. package/src/components/choice-card.tsx +305 -0
  108. package/src/components/combobox-core.tsx +228 -58
  109. package/src/components/combobox.tsx +58 -21
  110. package/src/components/danger-confirm.tsx +286 -0
  111. package/src/components/dialog-frame.tsx +179 -0
  112. package/src/components/disclosure.tsx +259 -0
  113. package/src/components/entity-combobox.tsx +41 -6
  114. package/src/components/file-button.tsx +458 -0
  115. package/src/components/file-dropzone.tsx +323 -117
  116. package/src/components/icon-picker.tsx +181 -0
  117. package/src/components/mini-calendar.tsx +7 -3
  118. package/src/components/modal.tsx +10 -2
  119. package/src/components/multi-entity-combobox.tsx +40 -6
  120. package/src/components/number-field.tsx +86 -10
  121. package/src/components/number-input.tsx +79 -2
  122. package/src/components/search-field.tsx +49 -6
  123. package/src/components/signature-pad.tsx +112 -0
  124. package/src/components/swatch-picker.tsx +141 -0
  125. package/src/components/switch.tsx +16 -0
  126. package/src/components/tile-radio.tsx +228 -0
  127. package/src/components/toggle-group.tsx +54 -18
  128. package/src/components/ui.tsx +400 -24
  129. package/src/i18n/defaults.ts +12 -1
  130. package/src/i18n/kit-labels.tsx +45 -5
  131. package/src/index.ts +19 -0
  132. package/src/lib/table-text.ts +265 -0
  133. package/src/rhf/form.tsx +300 -0
  134. package/src/rhf.ts +9 -0
  135. package/src/shell/app-shell.tsx +3 -1
  136. package/src/table-text.ts +8 -0
@@ -1,9 +1,25 @@
1
- import { useRef, useState } from "react";
1
+ import { useId, useRef, useState } from "react";
2
2
  import type { ComponentPropsWithoutRef } from "react";
3
- import { Upload } from "lucide-react";
4
- import { Button } from "./ui";
3
+ import { Upload, X } from "lucide-react";
4
+ import { Button, IconButton } from "./ui";
5
5
  import { cn } from "../lib/cn";
6
- import { useKitFileLabels } from "../i18n/kit-labels";
6
+ import { useAnnounce } from "../hooks/use-announce";
7
+ import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
8
+ import { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from "./file-button";
9
+ import type { FilePickerLabels, FileRejection } from "./file-button";
10
+
11
+ /**
12
+ * Where a refused file's message goes.
13
+ *
14
+ * - `"toast"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller
15
+ * that handles nothing itself so no existing dropzone goes quiet on upgrade).
16
+ * - `"inline"` — under the drop target, in the danger colour, tied to it with
17
+ * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live
18
+ * region. Cleared by the next accepted pick.
19
+ * - `"none"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.
20
+ * Still spoken, so a host rendering it should not ALSO make it `role="alert"`.
21
+ */
22
+ export type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
7
23
 
8
24
  /**
9
25
  * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
@@ -12,30 +28,64 @@ import { useKitFileLabels } from "../i18n/kit-labels";
12
28
  * writes. Everything else a `<div>` takes reaches the root — which carries
13
29
  * `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
14
30
  * belongs.
31
+ *
32
+ * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE
33
+ * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so
34
+ * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also
35
+ * reaches `onFilesSelected` as a one-element array, so one handler can serve both.
15
36
  */
16
37
  export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInvalid"> {
17
- file: File | null;
18
- onFileSelected: (file: File) => void;
38
+ /** Single mode: the chosen file, echoed with its size. */
39
+ file?: File | null;
40
+ /** Single mode: called with the accepted file. */
41
+ onFileSelected?: (file: File) => void;
42
+ /** Take several files per pick or drop. Without it a drop of several keeps the first. */
43
+ multiple?: boolean;
44
+ /** Multiple mode: the chosen files, listed in the zone. */
45
+ files?: readonly File[];
46
+ /** Every accepted file of one pick, in order. Never called with an empty array. */
47
+ onFilesSelected?: (files: File[]) => void;
48
+ /**
49
+ * Show a remove button inside the zone once something is chosen (kastlan asked for
50
+ * it: the only way to un-choose a file was to reload). Single mode: removes the file.
51
+ * Multiple mode: a "remove all" button. The host clears its own state here — the
52
+ * zone is controlled. Focus returns to the zone, and the removal is announced.
53
+ */
54
+ onClear?: () => void;
55
+ /** Multiple mode: a remove button per listed file. */
56
+ onRemove?: (file: File, index: number) => void;
19
57
  accept: string;
20
- isValid: (file: File) => boolean;
21
- invalidMessage: string;
22
58
  /**
23
- * How to surface {@link invalidMessage}. Defaults to a `sonner` toast — an
24
- * optional peer, imported only when a file is actually rejected. Pass your own to
25
- * route it somewhere else, or a no-op to silence it. The same escape hatch, for
26
- * the same reason, as `useWizard`'s `onValidationFailed`.
59
+ * The caller's check. Optional since 0.6: without it the zone checks `accept` itself
60
+ * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every
61
+ * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file
62
+ * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).
63
+ */
64
+ isValid?: (file: File) => boolean;
65
+ /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */
66
+ invalidMessage?: string;
67
+ /** Bytes; larger files are refused. */
68
+ maxSize?: number;
69
+ /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */
70
+ maxFiles?: number;
71
+ /**
72
+ * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}
73
+ * carries the reason and the translated message as well.
27
74
  *
28
- * It exists so this module does not import `sonner` STATICALLY. `package.json`
29
- * declares that peer `optional`, and the barrel re-exports this file — so a
30
- * top-level `import { toast } from "sonner"` made the claim false for everybody:
31
- * in an app that installed `@hb/ui` without sonner, `import { Button } from
32
- * "@hb/ui"` failed to resolve and `tsc --noEmit` failed on the missing types,
33
- * without a FileDropzone anywhere in it. Both current consumers happen to depend
34
- * on sonner, which is why nothing broke and why the contradiction survived:
35
- * `use-wizard.ts` had already answered the identical question the other way, in
36
- * writing, two modules over.
75
+ * Its existence used to be what switched the toast off, and it still does (see
76
+ * {@link rejectionFeedback}). The toast is a dynamic `import("sonner")` on the
77
+ * failure path only: `sonner` is an OPTIONAL peer, and a static import here once
78
+ * broke `import { Button }` for every app without it (see
79
+ * `optional-peer-imports.test.tsx`).
37
80
  */
38
81
  onInvalid?: (file: File) => void;
82
+ /** Every refused file of one pick, with its reason and message. */
83
+ onReject?: (rejections: FileRejection[]) => void;
84
+ /** See {@link FileDropzoneRejectionFeedback}. Defaults to `"toast"` when neither
85
+ * `onInvalid` nor `onReject` is given, `"none"` when one is. */
86
+ rejectionFeedback?: FileDropzoneRejectionFeedback;
87
+ /** Per-instance overrides of the `filePicker` label namespace. */
88
+ labels?: Partial<FilePickerLabels>;
39
89
  /** The dropzone's accessible name, and the instruction shown on it. A caller's own
40
90
  * `aria-label` wins over it — see the root element below. */
41
91
  dropLabel: string;
@@ -46,136 +96,292 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
46
96
  className?: string;
47
97
  }
48
98
 
49
- /** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml).
50
- * Validation failures surface `invalidMessage` (a `sonner` toast by default, see
51
- * `onInvalid`); the chosen file is echoed with its size, otherwise `emptyLabel` +
52
- * `hint` describe what to drop. */
99
+ /** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and
100
+ * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to
101
+ * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise
102
+ * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see
103
+ * `FileButton`. */
53
104
  export function FileDropzone({
54
105
  file,
55
106
  onFileSelected,
107
+ multiple,
108
+ files,
109
+ onFilesSelected,
110
+ onClear,
111
+ onRemove,
56
112
  accept,
57
113
  isValid,
58
114
  invalidMessage,
115
+ maxSize,
116
+ maxFiles,
59
117
  onInvalid,
118
+ onReject,
119
+ rejectionFeedback,
120
+ labels: labelsProp,
60
121
  dropLabel,
61
122
  browseLabel,
62
123
  emptyLabel,
63
124
  hint,
64
125
  className,
65
126
  "aria-label": ariaLabel,
127
+ "aria-describedby": describedByProp,
66
128
  ...rest
67
129
  }: FileDropzoneProps) {
68
130
  const [dragOver, setDragOver] = useState(false);
131
+ const [error, setError] = useState<string | null>(null);
69
132
  const fileInputRef = useRef<HTMLInputElement>(null);
133
+ const rootRef = useRef<HTMLDivElement>(null);
134
+ const errorId = useId();
70
135
  // The size through the kit's `file.size` label (the provider's locale by default).
71
136
  // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte
72
137
  // mislabelled as a decimal one, all in one template literal.
73
138
  const fileText = useKitFileLabels();
139
+ const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
140
+ // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.
141
+ // Both are needed because nothing inside a `role="button"` is read out — its
142
+ // children are presentational — so the echoed file name never reaches a screen
143
+ // reader on its own.
144
+ const status = useAnnounce();
145
+ const alert = useAnnounce({ politeness: "assertive" });
146
+ const feedback: FileDropzoneRejectionFeedback =
147
+ rejectionFeedback ?? (onInvalid || onReject ? "none" : "toast");
74
148
 
75
- const acceptFile = (f: File | undefined | null) => {
76
- if (!f) return;
77
- if (!isValid(f)) {
78
- if (onInvalid) {
79
- onInvalid(f);
149
+ const acceptFiles = (list: ArrayLike<File> | null | undefined) => {
150
+ if (!list || list.length === 0) return;
151
+ const all = Array.from(list);
152
+ const { accepted, rejected } = screenFiles(
153
+ multiple ? all : all.slice(0, 1),
154
+ {
155
+ accept: isValid ? undefined : accept,
156
+ maxSize,
157
+ maxFiles: multiple ? maxFiles : undefined,
158
+ isValid,
159
+ invalidMessage,
160
+ },
161
+ labels,
162
+ fileText.size,
163
+ );
164
+ if (accepted.length > 0) {
165
+ setError(null);
166
+ if (multiple) {
167
+ onFilesSelected?.(accepted);
80
168
  } else {
169
+ onFileSelected?.(accepted[0]);
170
+ onFilesSelected?.(accepted);
171
+ }
172
+ status.announce(labels.selected(accepted.length, accepted[0].name));
173
+ }
174
+ if (rejected.length > 0) {
175
+ for (const r of rejected) onInvalid?.(r.file);
176
+ onReject?.(rejected);
177
+ const message = summariseRejections(rejected, labels);
178
+ if (feedback === "toast") {
81
179
  // sonner is an optional peer: imported here, on the failure path only, so an
82
180
  // app that never trips this never has to install it. Not awaited — nothing
83
181
  // downstream depends on the toast having appeared, and the handlers that
84
- // reach this are DOM events.
85
- void import("sonner").then(({ toast }) => toast.error(invalidMessage));
182
+ // reach this are DOM events. sonner's own toaster is a live region, so this
183
+ // path does not announce a second time.
184
+ void import("sonner").then(({ toast }) => toast.error(message));
185
+ } else {
186
+ if (feedback === "inline") setError(message);
187
+ alert.announce(message);
86
188
  }
87
- return;
88
189
  }
89
- onFileSelected(f);
90
190
  };
91
191
 
192
+ // The remove buttons unmount with the file they removed, and focus would fall to
193
+ // <body>. The zone is where the next action (pick again) starts.
194
+ const afterRemoval = (message: string) => {
195
+ rootRef.current?.focus();
196
+ status.announce(message);
197
+ };
198
+
199
+ const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];
200
+ const showError = feedback === "inline" && error !== null;
201
+ const describedBy = cn(describedByProp, showError && errorId) || undefined;
202
+
92
203
  return (
93
- <div
94
- // `...rest` first: every handler below is the drop gesture itself, and the
95
- // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.
96
- {...rest}
97
- onDragEnter={(e) => {
98
- e.preventDefault();
99
- e.stopPropagation();
100
- if (e.dataTransfer.types.includes("Files")) setDragOver(true);
101
- }}
102
- onDragOver={(e) => {
103
- e.preventDefault();
104
- e.stopPropagation();
105
- e.dataTransfer.dropEffect = "copy";
106
- if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
107
- }}
108
- onDragLeave={(e) => {
109
- e.preventDefault();
110
- e.stopPropagation();
111
- if (e.currentTarget.contains(e.relatedTarget as Node)) return;
112
- setDragOver(false);
113
- }}
114
- onDrop={(e) => {
115
- e.preventDefault();
116
- e.stopPropagation();
117
- setDragOver(false);
118
- acceptFile(e.dataTransfer.files?.[0]);
119
- }}
120
- onClick={() => fileInputRef.current?.click()}
121
- onKeyDown={(e) => {
122
- if (e.key === "Enter" || e.key === " ") {
204
+ <>
205
+ <div
206
+ // `...rest` first: every handler below is the drop gesture itself, and the
207
+ // `role`/`tabIndex` pair is what makes this div operable by keyboard at all.
208
+ {...rest}
209
+ ref={rootRef}
210
+ onDragEnter={(e) => {
123
211
  e.preventDefault();
124
- fileInputRef.current?.click();
125
- }
126
- }}
127
- role="button"
128
- tabIndex={0}
129
- // The DOM spelling wins over `dropLabel`, which stays the name for every caller
130
- // that passes no `aria-label` — i.e. all of them today. Standardising on
131
- // `aria-label` (audit §api-design) does not get to silently rename an existing
132
- // required prop.
133
- aria-label={ariaLabel ?? dropLabel}
134
- className={cn(
135
- // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
136
- // which Tailwind implements as `position: absolute` — so without a positioned
137
- // ancestor its containing block is the INITIAL one, and it is laid out at its
138
- // own offset from the top of the document. Nothing looks wrong, because the
139
- // input is 1x1 and clipped; what breaks is the page height. On a long page
140
- // every escaped sr-only element extends `documentElement.scrollHeight` to its
141
- // own offset, which produces a second, whole-document scrollbar alongside the
142
- // app shell's own — one that scrolls past the end of the content into nothing.
143
- // Measured on the showcase: body 900px, document 47,919px.
144
- "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
145
- // Three states out of two border tokens: the target rests on the plain
146
- // hairline, hover pulls it to `--border-strong`, and a live drag keeps that
147
- // border and adds the `--bg-active` wash on top — so "let go here" still reads
148
- // one step louder than "you are over it".
149
- dragOver
150
- ? "border-[var(--border-strong)] bg-[var(--bg-active)]"
151
- : "border-[var(--border)] hover:border-[var(--border-strong)]",
152
- className,
153
- )}
154
- >
155
- <Upload className="size-5 text-[var(--text-muted)]" />
156
- <div className="text-sm text-[var(--text-secondary)]">
157
- {file ? <span className="font-medium">{file.name}</span> : emptyLabel}
158
- </div>
159
- <div className="text-xs text-[var(--text-muted)]">
160
- {file ? fileText.size(file.size) : hint}
161
- </div>
162
- <Button
163
- type="button"
164
- variant="secondary"
165
- onClick={(e) => {
166
212
  e.stopPropagation();
167
- fileInputRef.current?.click();
213
+ if (e.dataTransfer.types.includes("Files")) setDragOver(true);
168
214
  }}
215
+ onDragOver={(e) => {
216
+ e.preventDefault();
217
+ e.stopPropagation();
218
+ e.dataTransfer.dropEffect = "copy";
219
+ if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
220
+ }}
221
+ onDragLeave={(e) => {
222
+ e.preventDefault();
223
+ e.stopPropagation();
224
+ if (e.currentTarget.contains(e.relatedTarget as Node)) return;
225
+ setDragOver(false);
226
+ }}
227
+ onDrop={(e) => {
228
+ e.preventDefault();
229
+ e.stopPropagation();
230
+ setDragOver(false);
231
+ acceptFiles(e.dataTransfer.files);
232
+ }}
233
+ onClick={() => fileInputRef.current?.click()}
234
+ onKeyDown={(e) => {
235
+ // Only the zone's OWN keys. A remove button inside it is a real button, and
236
+ // Enter on it bubbling up here would open the picker instead of removing.
237
+ if (e.target !== e.currentTarget) return;
238
+ if (e.key === "Enter" || e.key === " ") {
239
+ e.preventDefault();
240
+ fileInputRef.current?.click();
241
+ }
242
+ }}
243
+ role="button"
244
+ tabIndex={0}
245
+ // The DOM spelling wins over `dropLabel`, which stays the name for every caller
246
+ // that passes no `aria-label` — i.e. all of them today. Standardising on
247
+ // `aria-label` (audit §api-design) does not get to silently rename an existing
248
+ // required prop.
249
+ aria-label={ariaLabel ?? dropLabel}
250
+ // No `aria-invalid`: ARIA does not allow it on `role="button"`. The message is
251
+ // reached through the description instead, and spoken when it appears.
252
+ aria-describedby={describedBy}
253
+ data-invalid={showError || undefined}
254
+ className={cn(
255
+ // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
256
+ // which Tailwind implements as `position: absolute` — so without a positioned
257
+ // ancestor its containing block is the INITIAL one, and it is laid out at its
258
+ // own offset from the top of the document. Nothing looks wrong, because the
259
+ // input is 1x1 and clipped; what breaks is the page height. On a long page
260
+ // every escaped sr-only element extends `documentElement.scrollHeight` to its
261
+ // own offset, which produces a second, whole-document scrollbar alongside the
262
+ // app shell's own — one that scrolls past the end of the content into nothing.
263
+ // Measured on the showcase: body 900px, document 47,919px.
264
+ "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
265
+ // Three states out of two border tokens: the target rests on the plain
266
+ // hairline, hover pulls it to `--border-strong`, and a live drag keeps that
267
+ // border and adds the `--bg-active` wash on top — so "let go here" still reads
268
+ // one step louder than "you are over it". A refusal shown inline takes the
269
+ // danger border until the next good pick.
270
+ dragOver
271
+ ? "border-[var(--border-strong)] bg-[var(--bg-active)]"
272
+ : showError
273
+ ? "border-[var(--danger-border)]"
274
+ : "border-[var(--border)] hover:border-[var(--border-strong)]",
275
+ className,
276
+ )}
169
277
  >
170
- {browseLabel}
171
- </Button>
172
- <input
173
- ref={fileInputRef}
174
- type="file"
175
- accept={accept}
176
- onChange={(e) => acceptFile(e.target.files?.[0])}
177
- className="sr-only"
178
- />
179
- </div>
278
+ <Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
279
+ {multiple && chosen.length > 0 ? (
280
+ <ul className="flex w-full max-w-sm flex-col gap-1 text-start">
281
+ {chosen.map((f, i) => (
282
+ <li
283
+ // Name + size + position: two files may share a name, and the index
284
+ // alone would re-key every row below a removal.
285
+ key={`${f.name}-${f.size}-${i}`}
286
+ className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
287
+ >
288
+ <span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
289
+ <span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
290
+ {onRemove && (
291
+ <IconButton
292
+ type="button"
293
+ size="sm"
294
+ aria-label={labels.remove(f.name)}
295
+ onClick={(e) => {
296
+ e.stopPropagation();
297
+ onRemove(f, i);
298
+ afterRemoval(labels.removed(f.name));
299
+ }}
300
+ >
301
+ <X aria-hidden />
302
+ </IconButton>
303
+ )}
304
+ </li>
305
+ ))}
306
+ </ul>
307
+ ) : (
308
+ <>
309
+ <div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
310
+ {file ? <span className="font-medium">{file.name}</span> : emptyLabel}
311
+ {!multiple && file && onClear && (
312
+ <IconButton
313
+ type="button"
314
+ size="sm"
315
+ aria-label={labels.remove(file.name)}
316
+ onClick={(e) => {
317
+ e.stopPropagation();
318
+ onClear();
319
+ afterRemoval(labels.removed(file.name));
320
+ }}
321
+ >
322
+ <X aria-hidden />
323
+ </IconButton>
324
+ )}
325
+ </div>
326
+ <div className="text-xs text-[var(--text-muted)]">
327
+ {file && !multiple ? fileText.size(file.size) : hint}
328
+ </div>
329
+ </>
330
+ )}
331
+ <div className="flex flex-wrap items-center justify-center gap-2">
332
+ <Button
333
+ type="button"
334
+ variant="secondary"
335
+ onClick={(e) => {
336
+ e.stopPropagation();
337
+ fileInputRef.current?.click();
338
+ }}
339
+ >
340
+ {browseLabel}
341
+ </Button>
342
+ {multiple && chosen.length > 0 && onClear && (
343
+ <Button
344
+ type="button"
345
+ variant="ghost"
346
+ onClick={(e) => {
347
+ e.stopPropagation();
348
+ onClear();
349
+ afterRemoval(labels.cleared);
350
+ }}
351
+ >
352
+ {labels.clearAll}
353
+ </Button>
354
+ )}
355
+ </div>
356
+ {showError && (
357
+ <p id={errorId} className="text-xs text-[var(--danger)]">
358
+ {error}
359
+ </p>
360
+ )}
361
+ <input
362
+ ref={fileInputRef}
363
+ type="file"
364
+ accept={accept}
365
+ multiple={multiple}
366
+ onChange={(e) => {
367
+ // Copy, THEN reset: `files` is a live view of the value, and without the
368
+ // reset picking the same file a second time fires no `change` at all —
369
+ // the retry after a failed upload silently did nothing (kastlan).
370
+ const picked = Array.from(e.currentTarget.files ?? []);
371
+ e.currentTarget.value = "";
372
+ acceptFiles(picked);
373
+ }}
374
+ // Clicks are forwarded here programmatically; one reaching the zone's own
375
+ // handler again would open the dialog a second time.
376
+ onClick={(e) => e.stopPropagation()}
377
+ className="sr-only"
378
+ />
379
+ </div>
380
+ {/* Outside the zone: a live region inside a `role="button"` is inside
381
+ presentational content, and screen readers are not consistent about
382
+ reading those. `sr-only-fixed`, so they need no positioned ancestor. */}
383
+ <span {...status.regionProps} />
384
+ <span {...alert.regionProps} />
385
+ </>
180
386
  );
181
387
  }