@eifi1/ui-kit 0.7.1 → 0.8.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 (222) hide show
  1. package/README.md +32 -17
  2. package/dist/chart.d.ts +3 -2
  3. package/dist/components/alert-banner.d.ts +59 -10
  4. package/dist/components/alert-banner.js +114 -8
  5. package/dist/components/alert-banner.js.map +1 -1
  6. package/dist/components/amount-input.d.ts +3 -0
  7. package/dist/components/button-group.d.ts +26 -0
  8. package/dist/components/button-group.js +49 -0
  9. package/dist/components/button-group.js.map +1 -0
  10. package/dist/components/calculator.d.ts +3 -0
  11. package/dist/components/chart-zoom.d.ts +63 -14
  12. package/dist/components/chart-zoom.js +82 -16
  13. package/dist/components/chart-zoom.js.map +1 -1
  14. package/dist/components/chip.d.ts +67 -2
  15. package/dist/components/chip.js +73 -8
  16. package/dist/components/chip.js.map +1 -1
  17. package/dist/components/confirm-dialog.d.ts +120 -0
  18. package/dist/components/confirm-dialog.js +96 -0
  19. package/dist/components/confirm-dialog.js.map +1 -0
  20. package/dist/components/copy-button.d.ts +62 -0
  21. package/dist/components/copy-button.js +95 -0
  22. package/dist/components/copy-button.js.map +1 -0
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table-pagination.d.ts +5 -1
  26. package/dist/components/data-table-pagination.js +71 -58
  27. package/dist/components/data-table-pagination.js.map +1 -1
  28. package/dist/components/data-table-sort.d.ts +28 -5
  29. package/dist/components/data-table-sort.js +11 -7
  30. package/dist/components/data-table-sort.js.map +1 -1
  31. package/dist/components/data-table.d.ts +1 -1
  32. package/dist/components/data-table.js +544 -423
  33. package/dist/components/data-table.js.map +1 -1
  34. package/dist/components/date-picker.d.ts +51 -5
  35. package/dist/components/date-picker.js +152 -40
  36. package/dist/components/date-picker.js.map +1 -1
  37. package/dist/components/description-list.d.ts +60 -0
  38. package/dist/components/description-list.js +112 -0
  39. package/dist/components/description-list.js.map +1 -0
  40. package/dist/components/disclosure.d.ts +37 -2
  41. package/dist/components/disclosure.js +14 -5
  42. package/dist/components/disclosure.js.map +1 -1
  43. package/dist/components/facing-pair.d.ts +2 -0
  44. package/dist/components/file-button.d.ts +17 -1
  45. package/dist/components/file-button.js +8 -1
  46. package/dist/components/file-button.js.map +1 -1
  47. package/dist/components/file-dropzone.d.ts +54 -9
  48. package/dist/components/file-dropzone.js +100 -70
  49. package/dist/components/file-dropzone.js.map +1 -1
  50. package/dist/components/floating-panel.d.ts +107 -0
  51. package/dist/components/floating-panel.js +206 -0
  52. package/dist/components/floating-panel.js.map +1 -0
  53. package/dist/components/full-bleed-dialog.d.ts +12 -1
  54. package/dist/components/full-bleed-dialog.js +28 -5
  55. package/dist/components/full-bleed-dialog.js.map +1 -1
  56. package/dist/components/modal.d.ts +6 -2
  57. package/dist/components/modal.js +2 -1
  58. package/dist/components/modal.js.map +1 -1
  59. package/dist/components/number-field.d.ts +3 -0
  60. package/dist/components/number-input.d.ts +3 -0
  61. package/dist/components/numpad-sheet.d.ts +3 -0
  62. package/dist/components/progress-bar.d.ts +54 -0
  63. package/dist/components/progress-bar.js +94 -0
  64. package/dist/components/progress-bar.js.map +1 -0
  65. package/dist/components/scroll-area.d.ts +53 -0
  66. package/dist/components/scroll-area.js +86 -0
  67. package/dist/components/scroll-area.js.map +1 -0
  68. package/dist/components/separator.d.ts +23 -0
  69. package/dist/components/separator.js +24 -0
  70. package/dist/components/separator.js.map +1 -0
  71. package/dist/components/series-chart-ticks.d.ts +34 -1
  72. package/dist/components/series-chart-ticks.js +83 -1
  73. package/dist/components/series-chart-ticks.js.map +1 -1
  74. package/dist/components/series-chart.d.ts +314 -19
  75. package/dist/components/series-chart.js +510 -120
  76. package/dist/components/series-chart.js.map +1 -1
  77. package/dist/components/skeleton.d.ts +36 -0
  78. package/dist/components/skeleton.js +35 -0
  79. package/dist/components/skeleton.js.map +1 -0
  80. package/dist/components/stat-tile.d.ts +60 -6
  81. package/dist/components/stat-tile.js +60 -26
  82. package/dist/components/stat-tile.js.map +1 -1
  83. package/dist/components/table.d.ts +83 -0
  84. package/dist/components/table.js +166 -0
  85. package/dist/components/table.js.map +1 -0
  86. package/dist/components/toggle-legend.d.ts +3 -2
  87. package/dist/components/toggle-legend.js +1 -1
  88. package/dist/components/toggle-legend.js.map +1 -1
  89. package/dist/components/tree-view.d.ts +129 -0
  90. package/dist/components/tree-view.js +376 -0
  91. package/dist/components/tree-view.js.map +1 -0
  92. package/dist/components/treemap.d.ts +9 -2
  93. package/dist/components/treemap.js +6 -2
  94. package/dist/components/treemap.js.map +1 -1
  95. package/dist/components/ui.d.ts +94 -13
  96. package/dist/components/ui.js +111 -29
  97. package/dist/components/ui.js.map +1 -1
  98. package/dist/components/use-table-state.d.ts +1 -1
  99. package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
  100. package/dist/data-table.d.ts +2 -2
  101. package/dist/data-table.js.map +1 -1
  102. package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
  103. package/dist/hooks/use-copy-to-clipboard.js +81 -0
  104. package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
  105. package/dist/hooks/use-debounce.d.ts +43 -0
  106. package/dist/hooks/use-debounce.js +56 -0
  107. package/dist/hooks/use-debounce.js.map +1 -0
  108. package/dist/hooks/use-file-drop.d.ts +72 -0
  109. package/dist/hooks/use-file-drop.js +57 -0
  110. package/dist/hooks/use-file-drop.js.map +1 -0
  111. package/dist/i18n/defaults.d.ts +3 -0
  112. package/dist/i18n/defaults.js +7 -1
  113. package/dist/i18n/defaults.js.map +1 -1
  114. package/dist/i18n/kit-labels.d.ts +15 -0
  115. package/dist/i18n/kit-labels.js +6 -2
  116. package/dist/i18n/kit-labels.js.map +1 -1
  117. package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
  118. package/dist/i18n/locales/de-CH-informal.js +8 -0
  119. package/dist/i18n/locales/de-CH-informal.js.map +1 -0
  120. package/dist/i18n/locales/de-CH.d.ts +3 -0
  121. package/dist/i18n/locales/de-CH.js +1 -14
  122. package/dist/i18n/locales/de-CH.js.map +1 -1
  123. package/dist/i18n/locales/de-informal.d.ts +64 -0
  124. package/dist/i18n/locales/de-informal.js +37 -0
  125. package/dist/i18n/locales/de-informal.js.map +1 -0
  126. package/dist/i18n/locales/de.d.ts +3 -0
  127. package/dist/i18n/locales/de.js +25 -0
  128. package/dist/i18n/locales/de.js.map +1 -1
  129. package/dist/i18n/locales/es.d.ts +3 -0
  130. package/dist/i18n/locales/es.js +25 -0
  131. package/dist/i18n/locales/es.js.map +1 -1
  132. package/dist/i18n/locales/fr.d.ts +3 -0
  133. package/dist/i18n/locales/fr.js +25 -0
  134. package/dist/i18n/locales/fr.js.map +1 -1
  135. package/dist/i18n/locales/hu.d.ts +3 -0
  136. package/dist/i18n/locales/hu.js +25 -0
  137. package/dist/i18n/locales/hu.js.map +1 -1
  138. package/dist/i18n/locales/it.d.ts +3 -0
  139. package/dist/i18n/locales/it.js +25 -0
  140. package/dist/i18n/locales/it.js.map +1 -1
  141. package/dist/i18n/locales/zh.d.ts +3 -0
  142. package/dist/i18n/locales/zh.js +25 -0
  143. package/dist/i18n/locales/zh.js.map +1 -1
  144. package/dist/i18n/swiss.d.ts +13 -0
  145. package/dist/i18n/swiss.js +19 -0
  146. package/dist/i18n/swiss.js.map +1 -0
  147. package/dist/index.d.ts +25 -10
  148. package/dist/index.js +22 -0
  149. package/dist/index.js.map +1 -1
  150. package/dist/lib/dates.d.ts +37 -1
  151. package/dist/lib/dates.js +19 -0
  152. package/dist/lib/dates.js.map +1 -1
  153. package/dist/search/command-palette.d.ts +15 -1
  154. package/dist/search/command-palette.js +10 -2
  155. package/dist/search/command-palette.js.map +1 -1
  156. package/dist/wizard/stepper-nav.d.ts +35 -2
  157. package/dist/wizard/stepper-nav.js +54 -22
  158. package/dist/wizard/stepper-nav.js.map +1 -1
  159. package/dist/wizard/types.d.ts +107 -5
  160. package/dist/wizard/types.js +2 -1
  161. package/dist/wizard/types.js.map +1 -1
  162. package/dist/wizard/use-wizard.js +141 -21
  163. package/dist/wizard/use-wizard.js.map +1 -1
  164. package/dist/wizard/wizard-summary.d.ts +10 -2
  165. package/dist/wizard/wizard-summary.js +3 -1
  166. package/dist/wizard/wizard-summary.js.map +1 -1
  167. package/dist/wizard.d.ts +4 -1
  168. package/dist/wizard.js.map +1 -1
  169. package/package.json +1 -1
  170. package/src/components/alert-banner.tsx +211 -20
  171. package/src/components/button-group.tsx +75 -0
  172. package/src/components/chart-zoom.tsx +158 -20
  173. package/src/components/chip.tsx +160 -9
  174. package/src/components/confirm-dialog.tsx +242 -0
  175. package/src/components/copy-button.tsx +158 -0
  176. package/src/components/data-table-pagination.tsx +19 -6
  177. package/src/components/data-table-sort.ts +49 -10
  178. package/src/components/data-table.tsx +293 -24
  179. package/src/components/date-picker.tsx +262 -57
  180. package/src/components/description-list.tsx +174 -0
  181. package/src/components/disclosure.tsx +58 -5
  182. package/src/components/file-button.tsx +22 -2
  183. package/src/components/file-dropzone.tsx +198 -100
  184. package/src/components/floating-panel.tsx +341 -0
  185. package/src/components/full-bleed-dialog.tsx +53 -5
  186. package/src/components/modal.tsx +9 -2
  187. package/src/components/progress-bar.tsx +163 -0
  188. package/src/components/scroll-area.tsx +129 -0
  189. package/src/components/separator.tsx +37 -0
  190. package/src/components/series-chart-ticks.ts +135 -0
  191. package/src/components/series-chart.tsx +934 -67
  192. package/src/components/skeleton.tsx +65 -0
  193. package/src/components/stat-tile.tsx +160 -27
  194. package/src/components/table.tsx +263 -0
  195. package/src/components/toggle-legend.tsx +4 -3
  196. package/src/components/tree-view.tsx +589 -0
  197. package/src/components/treemap.tsx +13 -2
  198. package/src/components/ui.tsx +270 -39
  199. package/src/data-table.ts +2 -0
  200. package/src/hooks/use-copy-to-clipboard.ts +122 -0
  201. package/src/hooks/use-debounce.ts +105 -0
  202. package/src/hooks/use-file-drop.ts +123 -0
  203. package/src/i18n/defaults.ts +6 -0
  204. package/src/i18n/kit-labels.tsx +19 -0
  205. package/src/i18n/locales/de-CH-informal.ts +10 -0
  206. package/src/i18n/locales/de-CH.ts +1 -16
  207. package/src/i18n/locales/de-informal.ts +61 -0
  208. package/src/i18n/locales/de.ts +25 -0
  209. package/src/i18n/locales/es.ts +25 -0
  210. package/src/i18n/locales/fr.ts +25 -0
  211. package/src/i18n/locales/hu.ts +25 -0
  212. package/src/i18n/locales/it.ts +25 -0
  213. package/src/i18n/locales/zh.ts +25 -0
  214. package/src/i18n/swiss.ts +25 -0
  215. package/src/index.ts +29 -0
  216. package/src/lib/dates.ts +48 -0
  217. package/src/search/command-palette.tsx +29 -3
  218. package/src/wizard/stepper-nav.tsx +102 -28
  219. package/src/wizard/types.ts +106 -4
  220. package/src/wizard/use-wizard.ts +211 -31
  221. package/src/wizard/wizard-summary.tsx +24 -12
  222. package/src/wizard.ts +3 -2
@@ -64,6 +64,19 @@ export interface FilePickerLabels {
64
64
  /** Spoken after a remove / clear, since the button that was pressed is gone. */
65
65
  removed: (name: string) => string;
66
66
  cleared: string;
67
+ /** The dropzone's accessible name, when the host passes no `dropLabel`. */
68
+ dropzone: string;
69
+ /** The dropzone's Browse button, when the host passes no `browseLabel`. */
70
+ browse: string;
71
+ /** What an empty single-file dropzone says (no `emptyLabel` given). */
72
+ empty: string;
73
+ /** …and an empty `multiple` one. */
74
+ emptyMultiple: string;
75
+ /** The dropzone's second line (no `hint` given). Receives `accept` as a person
76
+ * reads it (`".pdf, image/png"`), or `""` when anything goes. */
77
+ hint: (accept: string) => string;
78
+ /** What a `busy` dropzone says in place of its body ("Uploading…"). */
79
+ busy: string;
67
80
  }
68
81
 
69
82
  export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
@@ -81,6 +94,12 @@ export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
81
94
  clearAll: "Remove all files",
82
95
  removed: (name) => `“${name}” removed`,
83
96
  cleared: "All files removed",
97
+ dropzone: "File upload",
98
+ browse: "Browse",
99
+ empty: "Drop a file here",
100
+ emptyMultiple: "Drop files here",
101
+ hint: (accept) => (accept ? `Accepted: ${accept}` : "Any file type"),
102
+ busy: "Uploading…",
84
103
  };
85
104
 
86
105
  /* ── Screening ───────────────────────────────────────────────────────────── */
@@ -148,8 +167,9 @@ function typeFromExtension(name: string): string | undefined {
148
167
  return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];
149
168
  }
150
169
 
151
- /** `" .PDF, image/png ,"` => `".pdf, image/png"` — `accept` as a person reads it. */
152
- function formatAccept(accept: string): string {
170
+ /** @internal `" .PDF, image/png ,"` => `".pdf, image/png"` — `accept` as a person reads
171
+ * it. Also the dropzone's default hint. */
172
+ export function formatAccept(accept: string): string {
153
173
  return accept
154
174
  .split(",")
155
175
  .map((t) => t.trim().toLowerCase())
@@ -1,12 +1,19 @@
1
1
  import { useId, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef } from "react";
2
+ import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from "react";
3
3
  import { Upload, X } from "lucide-react";
4
- import { Button, IconButton } from "./ui";
4
+ import { Button, IconButton, Spinner } from "./ui";
5
5
  import { cn } from "../lib/cn";
6
6
  import { useAnnounce } from "../hooks/use-announce";
7
7
  import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
8
- import { DEFAULT_FILE_PICKER_LABELS, judgePick, screenFiles, summariseRejections } from "./file-button";
8
+ import {
9
+ DEFAULT_FILE_PICKER_LABELS,
10
+ formatAccept,
11
+ judgePick,
12
+ screenFiles,
13
+ summariseRejections,
14
+ } from "./file-button";
9
15
  import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
16
+ import { useDragTarget } from "../hooks/use-file-drop";
10
17
 
11
18
  /**
12
19
  * Where a refused file's message goes.
@@ -21,6 +28,23 @@ import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-bu
21
28
  */
22
29
  export type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
23
30
 
31
+ /** What a custom {@link FileDropzoneProps.renderBody} is handed. */
32
+ export interface FileDropzoneState {
33
+ /** A file drag is over the zone right now. */
34
+ dragOver: boolean;
35
+ disabled: boolean;
36
+ busy: boolean;
37
+ /** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */
38
+ files: readonly File[];
39
+ /** The inline refusal, when `rejectionFeedback="inline"` has one to show. */
40
+ error: string | null;
41
+ /** Open the system picker — for a body that brings its own trigger (a second
42
+ * "Take photo" button). Does nothing while `disabled` or `busy`. */
43
+ open: () => void;
44
+ /** The resolved `filePicker` strings, so a custom body speaks the same language. */
45
+ labels: FilePickerLabels;
46
+ }
47
+
24
48
  /**
25
49
  * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
26
50
  * The DOM event of that name belongs to form validation and takes a `FormEvent`; this
@@ -90,12 +114,41 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
90
114
  rejectionFeedback?: FileDropzoneRejectionFeedback;
91
115
  /** Per-instance overrides of the `filePicker` label namespace. */
92
116
  labels?: Partial<FilePickerLabels>;
93
- /** The dropzone's accessible name, and the instruction shown on it. A caller's own
94
- * `aria-label` wins over it — see the root element below. */
95
- dropLabel: string;
96
- browseLabel: string;
97
- emptyLabel: string;
98
- hint: string;
117
+ /** The dropzone's accessible name. A caller's own `aria-label` wins over it — see
118
+ * the root element below. Optional since 0.8, like the three below: each falls back
119
+ * to its `filePicker` string from `<UiKitProvider labels>`, then to English. They
120
+ * were required, so every call site passed four strings through `t()` by hand, and a
121
+ * zone nested in a kit component (the feedback form) had no call site to pass them. */
122
+ dropLabel?: string;
123
+ /** Default `filePicker.browse`. */
124
+ browseLabel?: string;
125
+ /** What the zone says while nothing is chosen. Default `filePicker.empty` /
126
+ * `emptyMultiple`. */
127
+ emptyLabel?: string;
128
+ /** The second line, e.g. what may be dropped. Default `filePicker.hint(accept)`. */
129
+ hint?: string;
130
+ /**
131
+ * Not taking files: no drop, no picker, no remove buttons; `aria-disabled` on the
132
+ * group. keksdose locks its invoice zone while another form holds unsaved changes,
133
+ * and hand-rolled the whole zone to be able to (invoice-upload.tsx, "Locked").
134
+ */
135
+ disabled?: boolean;
136
+ /**
137
+ * Working on what it was given — an upload, an OCR pass. Inert like `disabled`, plus
138
+ * `aria-busy`, a spinner in place of the upload icon, and `filePicker.busy` as the
139
+ * text. The other half of why keksdose's invoice zone was hand-rolled.
140
+ */
141
+ busy?: boolean;
142
+ /**
143
+ * Replace the zone's body — the icon, the text and the chosen-file list — with your
144
+ * own, given the zone's state: keksdose's spinner-plus-"uploading", its multi-shot
145
+ * hint ("several photos are possible"), a second trigger for the camera.
146
+ *
147
+ * The Browse button, the inline error and the live regions stay: Browse is the
148
+ * keyboard path, and a body that forgot it would leave the zone unreachable without
149
+ * a pointer. A click on a control inside the body does not also open the picker.
150
+ */
151
+ renderBody?: (state: FileDropzoneState) => ReactNode;
99
152
  /** Extra classes for the dropzone's root. */
100
153
  className?: string;
101
154
  }
@@ -123,18 +176,23 @@ export function FileDropzone({
123
176
  onPick,
124
177
  rejectionFeedback,
125
178
  labels: labelsProp,
126
- dropLabel,
127
- browseLabel,
128
- emptyLabel,
129
- hint,
179
+ dropLabel: dropLabelProp,
180
+ browseLabel: browseLabelProp,
181
+ emptyLabel: emptyLabelProp,
182
+ hint: hintProp,
183
+ disabled,
184
+ busy,
185
+ renderBody,
130
186
  className,
131
187
  "aria-label": ariaLabel,
132
188
  "aria-describedby": describedByProp,
133
189
  ...rest
134
190
  }: FileDropzoneProps) {
135
- const [dragOver, setDragOver] = useState(false);
136
191
  const [error, setError] = useState<string | null>(null);
137
- const fileInputRef = useRef<HTMLInputElement>(null);
192
+ const inert = Boolean(disabled || busy);
193
+ // State rather than a ref: `open` is handed to `renderBody` during render, and a
194
+ // function that reads a ref there is exactly what the refs rule cannot prove safe.
195
+ const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);
138
196
  const browseRef = useRef<HTMLButtonElement>(null);
139
197
  const errorId = useId();
140
198
  // The size through the kit's `file.size` label (the provider's locale by default).
@@ -149,9 +207,13 @@ export function FileDropzone({
149
207
  const alert = useAnnounce({ politeness: "assertive" });
150
208
  const feedback: FileDropzoneRejectionFeedback =
151
209
  rejectionFeedback ?? (onInvalid || onReject ? "none" : "toast");
210
+ const dropLabel = dropLabelProp ?? labels.dropzone;
211
+ const browseLabel = browseLabelProp ?? labels.browse;
212
+ const emptyLabel = emptyLabelProp ?? (multiple ? labels.emptyMultiple : labels.empty);
213
+ const hint = hintProp ?? labels.hint(formatAccept(accept));
152
214
 
153
215
  const acceptFiles = (list: ArrayLike<File> | null | undefined) => {
154
- if (!list || list.length === 0) return;
216
+ if (inert || !list || list.length === 0) return;
155
217
  const all = Array.from(list);
156
218
  const picked = multiple ? all : all.slice(0, 1);
157
219
  const { accepted, rejected } = screenFiles(
@@ -204,6 +266,17 @@ export function FileDropzone({
204
266
  status.announce(message);
205
267
  };
206
268
 
269
+ // The enter/leave counter lives in the shared hook: the boolean this was flickered
270
+ // across every child of the zone in Safari, whose drag events carry no
271
+ // `relatedTarget` to tell a move onto a child from a real leave.
272
+ const { dropProps, isOver: dragOver } = useDragTarget<HTMLDivElement>({
273
+ disabled: inert,
274
+ onDropFiles: acceptFiles,
275
+ });
276
+ const open = () => {
277
+ if (!inert) fileInput?.click();
278
+ };
279
+
207
280
  const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];
208
281
  const showError = feedback === "inline" && error !== null;
209
282
  const describedBy = cn(describedByProp, showError && errorId) || undefined;
@@ -221,32 +294,14 @@ export function FileDropzone({
221
294
  <div
222
295
  // `...rest` first: every handler below is the drop gesture itself.
223
296
  {...rest}
224
- onDragEnter={(e) => {
225
- e.preventDefault();
226
- e.stopPropagation();
227
- if (e.dataTransfer.types.includes("Files")) setDragOver(true);
228
- }}
229
- onDragOver={(e) => {
230
- e.preventDefault();
231
- e.stopPropagation();
232
- e.dataTransfer.dropEffect = "copy";
233
- if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
234
- }}
235
- onDragLeave={(e) => {
236
- e.preventDefault();
237
- e.stopPropagation();
238
- if (e.currentTarget.contains(e.relatedTarget as Node)) return;
239
- setDragOver(false);
297
+ {...dropProps}
298
+ // Pointer only; every control inside stops its own click from reaching here
299
+ // (and one in a custom body is skipped below). The keyboard equivalent is the
300
+ // Browse button, so no key handler belongs on a group.
301
+ onClick={(e) => {
302
+ if (fromControl(e)) return;
303
+ open();
240
304
  }}
241
- onDrop={(e) => {
242
- e.preventDefault();
243
- e.stopPropagation();
244
- setDragOver(false);
245
- acceptFiles(e.dataTransfer.files);
246
- }}
247
- // Pointer only; every control inside stops its own click from reaching here. The
248
- // keyboard equivalent is the Browse button, so no key handler belongs on a group.
249
- onClick={() => fileInputRef.current?.click()}
250
305
  role="group"
251
306
  // The DOM spelling wins over `dropLabel`, which stays the name for every caller
252
307
  // that passes no `aria-label` — i.e. all of them today. Standardising on
@@ -257,6 +312,9 @@ export function FileDropzone({
257
312
  // reached through the description instead, and spoken when it appears.
258
313
  aria-describedby={describedBy}
259
314
  data-invalid={showError || undefined}
315
+ aria-disabled={disabled || undefined}
316
+ aria-busy={busy || undefined}
317
+ data-drag-over={dragOver || undefined}
260
318
  className={cn(
261
319
  // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
262
320
  // which Tailwind implements as `position: absolute` — so without a positioned
@@ -267,73 +325,102 @@ export function FileDropzone({
267
325
  // own offset, which produces a second, whole-document scrollbar alongside the
268
326
  // app shell's own — one that scrolls past the end of the content into nothing.
269
327
  // Measured on the showcase: body 900px, document 47,919px.
270
- "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",
328
+ "relative flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
271
329
  // Three states out of two border tokens: the target rests on the plain
272
330
  // hairline, hover pulls it to `--border-strong`, and a live drag keeps that
273
331
  // border and adds the `--bg-active` wash on top — so "let go here" still reads
274
332
  // one step louder than "you are over it". A refusal shown inline takes the
275
333
  // danger border until the next good pick.
276
- dragOver
277
- ? "border-[var(--border-strong)] bg-[var(--bg-active)]"
278
- : showError
279
- ? "border-[var(--danger-border)]"
280
- : "border-[var(--border)] hover:border-[var(--border-strong)]",
334
+ // Inert zones keep the resting hairline and lose the hover pull: a border that
335
+ // answers the pointer promises a drop the zone will refuse.
336
+ disabled
337
+ ? "cursor-not-allowed border-[var(--border)] opacity-60"
338
+ : busy
339
+ ? "cursor-progress border-[var(--border)]"
340
+ : dragOver
341
+ ? "cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]"
342
+ : showError
343
+ ? "cursor-pointer border-[var(--danger-border)]"
344
+ : "cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]",
281
345
  className,
282
346
  )}
283
347
  >
284
- <Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
285
- {multiple && chosen.length > 0 ? (
286
- <ul className="flex w-full max-w-sm flex-col gap-1 text-start">
287
- {chosen.map((f, i) => (
288
- <li
289
- // Name + size + position: two files may share a name, and the index
290
- // alone would re-key every row below a removal.
291
- key={`${f.name}-${f.size}-${i}`}
292
- className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
293
- >
294
- <span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
295
- <span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
296
- {onRemove && (
297
- <IconButton
298
- type="button"
299
- size="sm"
300
- aria-label={labels.remove(f.name)}
301
- onClick={(e) => {
302
- e.stopPropagation();
303
- onRemove(f, i);
304
- afterRemoval(labels.removed(f.name));
305
- }}
306
- >
307
- <X aria-hidden />
308
- </IconButton>
309
- )}
310
- </li>
311
- ))}
312
- </ul>
348
+ {renderBody ? (
349
+ renderBody({
350
+ dragOver,
351
+ disabled: Boolean(disabled),
352
+ busy: Boolean(busy),
353
+ files: chosen,
354
+ error: showError ? error : null,
355
+ open,
356
+ labels,
357
+ })
358
+ ) : busy ? (
359
+ <>
360
+ {/* `label={null}`: the zone is `aria-busy` and the line below says the
361
+ same in words; a spoken "Loading" would only run into it. */}
362
+ <Spinner label={null} className="size-5" />
363
+ <div className="text-sm text-[var(--text-secondary)]">{labels.busy}</div>
364
+ </>
313
365
  ) : (
314
366
  <>
315
- <div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
316
- {/* `!multiple`: in multiple mode an empty `files` list is "nothing chosen",
317
- whatever a stray `file` prop says — it used to show that file's name. */}
318
- {!multiple && file ? <span className="font-medium">{file.name}</span> : emptyLabel}
319
- {!multiple && file && onClear && (
320
- <IconButton
321
- type="button"
322
- size="sm"
323
- aria-label={labels.remove(file.name)}
324
- onClick={(e) => {
325
- e.stopPropagation();
326
- onClear();
327
- afterRemoval(labels.removed(file.name));
328
- }}
329
- >
330
- <X aria-hidden />
331
- </IconButton>
332
- )}
333
- </div>
334
- <div className="text-xs text-[var(--text-muted)]">
335
- {file && !multiple ? fileText.size(file.size) : hint}
336
- </div>
367
+ <Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
368
+ {multiple && chosen.length > 0 ? (
369
+ <ul className="flex w-full max-w-sm flex-col gap-1 text-start">
370
+ {chosen.map((f, i) => (
371
+ <li
372
+ // Name + size + position: two files may share a name, and the index
373
+ // alone would re-key every row below a removal.
374
+ key={`${f.name}-${f.size}-${i}`}
375
+ className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
376
+ >
377
+ <span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
378
+ <span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
379
+ {onRemove && (
380
+ <IconButton
381
+ type="button"
382
+ size="sm"
383
+ disabled={inert}
384
+ aria-label={labels.remove(f.name)}
385
+ onClick={(e) => {
386
+ e.stopPropagation();
387
+ onRemove(f, i);
388
+ afterRemoval(labels.removed(f.name));
389
+ }}
390
+ >
391
+ <X aria-hidden />
392
+ </IconButton>
393
+ )}
394
+ </li>
395
+ ))}
396
+ </ul>
397
+ ) : (
398
+ <>
399
+ <div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
400
+ {/* `!multiple`: in multiple mode an empty `files` list is "nothing chosen",
401
+ whatever a stray `file` prop says — it used to show that file's name. */}
402
+ {!multiple && file ? <span className="font-medium">{file.name}</span> : emptyLabel}
403
+ {!multiple && file && onClear && (
404
+ <IconButton
405
+ type="button"
406
+ size="sm"
407
+ disabled={inert}
408
+ aria-label={labels.remove(file.name)}
409
+ onClick={(e) => {
410
+ e.stopPropagation();
411
+ onClear();
412
+ afterRemoval(labels.removed(file.name));
413
+ }}
414
+ >
415
+ <X aria-hidden />
416
+ </IconButton>
417
+ )}
418
+ </div>
419
+ <div className="text-xs text-[var(--text-muted)]">
420
+ {file && !multiple ? fileText.size(file.size) : hint}
421
+ </div>
422
+ </>
423
+ )}
337
424
  </>
338
425
  )}
339
426
  <div className="flex flex-wrap items-center justify-center gap-2">
@@ -341,9 +428,10 @@ export function FileDropzone({
341
428
  ref={browseRef}
342
429
  type="button"
343
430
  variant="secondary"
431
+ disabled={inert}
344
432
  onClick={(e) => {
345
433
  e.stopPropagation();
346
- fileInputRef.current?.click();
434
+ open();
347
435
  }}
348
436
  >
349
437
  {browseLabel}
@@ -352,6 +440,7 @@ export function FileDropzone({
352
440
  <Button
353
441
  type="button"
354
442
  variant="ghost"
443
+ disabled={inert}
355
444
  onClick={(e) => {
356
445
  e.stopPropagation();
357
446
  onClear();
@@ -368,10 +457,11 @@ export function FileDropzone({
368
457
  </p>
369
458
  )}
370
459
  <input
371
- ref={fileInputRef}
460
+ ref={setFileInput}
372
461
  type="file"
373
462
  accept={accept}
374
463
  multiple={multiple}
464
+ disabled={inert}
375
465
  onChange={(e) => {
376
466
  // Copy, THEN reset: `files` is a live view of the value, and without the
377
467
  // reset picking the same file a second time fires no `change` at all —
@@ -394,3 +484,11 @@ export function FileDropzone({
394
484
  </>
395
485
  );
396
486
  }
487
+
488
+ /** A click that landed on a control inside the zone (a custom body's own button, a
489
+ * link in the hint) is that control's, not a request to open the picker. */
490
+ function fromControl(e: MouseEvent<HTMLElement>): boolean {
491
+ const target = e.target as Element | null;
492
+ const control = target?.closest?.("button, a[href], input, select, textarea, label, [role='button']");
493
+ return Boolean(control && control !== e.currentTarget && e.currentTarget.contains(control));
494
+ }