@dowel-ui/react 0.2.0 → 0.4.0

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 (141) hide show
  1. package/README.md +14 -7
  2. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
  3. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
  4. package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
  5. package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
  6. package/dist/components/ai-agent-plan/index.d.ts +2 -0
  7. package/dist/components/ai-agent-plan/index.js +2 -0
  8. package/dist/components/ai-agent-plan/meta.js +17 -0
  9. package/dist/components/ai-agent-plan/meta.js.map +1 -0
  10. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  11. package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
  12. package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
  13. package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
  14. package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
  15. package/dist/components/ai-approval-request/index.d.ts +2 -0
  16. package/dist/components/ai-approval-request/index.js +2 -0
  17. package/dist/components/ai-approval-request/meta.js +17 -0
  18. package/dist/components/ai-approval-request/meta.js.map +1 -0
  19. package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
  20. package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
  21. package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
  22. package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
  23. package/dist/components/ai-disclosure/index.d.ts +2 -0
  24. package/dist/components/ai-disclosure/index.js +2 -0
  25. package/dist/components/ai-disclosure/meta.js +17 -0
  26. package/dist/components/ai-disclosure/meta.js.map +1 -0
  27. package/dist/components/ai-response/ai-response.d.ts.map +1 -1
  28. package/dist/components/ai-response/ai-response.js +1 -0
  29. package/dist/components/ai-response/ai-response.js.map +1 -1
  30. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
  31. package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
  32. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
  33. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  34. package/dist/components/alert/alert.d.ts +1 -1
  35. package/dist/components/avatar/avatar.d.ts +1 -1
  36. package/dist/components/badge/badge.d.ts +1 -1
  37. package/dist/components/button/button.d.ts +1 -1
  38. package/dist/components/date-picker/date-picker.js +1 -1
  39. package/dist/components/diff-viewer/diff-model.d.ts +69 -0
  40. package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
  41. package/dist/components/diff-viewer/diff-model.js +180 -0
  42. package/dist/components/diff-viewer/diff-model.js.map +1 -0
  43. package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
  44. package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
  45. package/dist/components/diff-viewer/diff-viewer.js +212 -0
  46. package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
  47. package/dist/components/diff-viewer/index.d.ts +3 -0
  48. package/dist/components/diff-viewer/index.js +3 -0
  49. package/dist/components/diff-viewer/meta.js +17 -0
  50. package/dist/components/diff-viewer/meta.js.map +1 -0
  51. package/dist/components/file-upload/file-upload.d.ts +46 -0
  52. package/dist/components/file-upload/file-upload.d.ts.map +1 -0
  53. package/dist/components/file-upload/file-upload.js +204 -0
  54. package/dist/components/file-upload/file-upload.js.map +1 -0
  55. package/dist/components/file-upload/index.d.ts +3 -0
  56. package/dist/components/file-upload/index.js +3 -0
  57. package/dist/components/file-upload/meta.js +17 -0
  58. package/dist/components/file-upload/meta.js.map +1 -0
  59. package/dist/components/file-upload/upload-queue.d.ts +87 -0
  60. package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
  61. package/dist/components/file-upload/upload-queue.js +249 -0
  62. package/dist/components/file-upload/upload-queue.js.map +1 -0
  63. package/dist/components/log-viewer/index.d.ts +3 -0
  64. package/dist/components/log-viewer/index.js +3 -0
  65. package/dist/components/log-viewer/log-stream.d.ts +68 -0
  66. package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
  67. package/dist/components/log-viewer/log-stream.js +120 -0
  68. package/dist/components/log-viewer/log-stream.js.map +1 -0
  69. package/dist/components/log-viewer/log-viewer.d.ts +51 -0
  70. package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
  71. package/dist/components/log-viewer/log-viewer.js +284 -0
  72. package/dist/components/log-viewer/log-viewer.js.map +1 -0
  73. package/dist/components/log-viewer/meta.js +17 -0
  74. package/dist/components/log-viewer/meta.js.map +1 -0
  75. package/dist/components/progress/progress.d.ts +1 -1
  76. package/dist/components/progress/progress.js +1 -0
  77. package/dist/components/progress/progress.js.map +1 -1
  78. package/dist/components/spinner/spinner.d.ts +1 -1
  79. package/dist/components/spinner/spinner.js +1 -0
  80. package/dist/components/spinner/spinner.js.map +1 -1
  81. package/dist/components/tags-input/index.d.ts +2 -0
  82. package/dist/components/tags-input/index.js +2 -0
  83. package/dist/components/tags-input/meta.js +17 -0
  84. package/dist/components/tags-input/meta.js.map +1 -0
  85. package/dist/components/tags-input/tags-input.d.ts +47 -0
  86. package/dist/components/tags-input/tags-input.d.ts.map +1 -0
  87. package/dist/components/tags-input/tags-input.js +182 -0
  88. package/dist/components/tags-input/tags-input.js.map +1 -0
  89. package/dist/components/time-range-picker/index.d.ts +3 -0
  90. package/dist/components/time-range-picker/index.js +3 -0
  91. package/dist/components/time-range-picker/meta.js +22 -0
  92. package/dist/components/time-range-picker/meta.js.map +1 -0
  93. package/dist/components/time-range-picker/time-expression.d.ts +88 -0
  94. package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
  95. package/dist/components/time-range-picker/time-expression.js +335 -0
  96. package/dist/components/time-range-picker/time-expression.js.map +1 -0
  97. package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
  98. package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
  99. package/dist/components/time-range-picker/time-range-picker.js +377 -0
  100. package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
  101. package/dist/components/toast/toast.d.ts +1 -1
  102. package/dist/index.d.ts +21 -1
  103. package/dist/index.js +14 -2
  104. package/dist/registry/components.d.ts.map +1 -1
  105. package/dist/registry/components.js +66 -50
  106. package/dist/registry/components.js.map +1 -1
  107. package/package.json +6 -4
  108. package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
  109. package/src/components/ai-agent-plan/index.ts +10 -0
  110. package/src/components/ai-agent-plan/meta.ts +19 -0
  111. package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
  112. package/src/components/ai-approval-request/index.ts +7 -0
  113. package/src/components/ai-approval-request/meta.ts +21 -0
  114. package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
  115. package/src/components/ai-disclosure/index.ts +9 -0
  116. package/src/components/ai-disclosure/meta.ts +23 -0
  117. package/src/components/ai-response/ai-response.tsx +1 -0
  118. package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
  119. package/src/components/diff-viewer/diff-model.ts +256 -0
  120. package/src/components/diff-viewer/diff-viewer.tsx +339 -0
  121. package/src/components/diff-viewer/index.ts +17 -0
  122. package/src/components/diff-viewer/meta.ts +20 -0
  123. package/src/components/file-upload/file-upload.tsx +322 -0
  124. package/src/components/file-upload/index.ts +20 -0
  125. package/src/components/file-upload/meta.ts +21 -0
  126. package/src/components/file-upload/upload-queue.ts +337 -0
  127. package/src/components/log-viewer/index.ts +21 -0
  128. package/src/components/log-viewer/log-stream.ts +176 -0
  129. package/src/components/log-viewer/log-viewer.tsx +424 -0
  130. package/src/components/log-viewer/meta.ts +21 -0
  131. package/src/components/progress/progress.tsx +3 -0
  132. package/src/components/spinner/spinner.tsx +5 -0
  133. package/src/components/tags-input/index.ts +1 -0
  134. package/src/components/tags-input/meta.ts +23 -0
  135. package/src/components/tags-input/tags-input.tsx +300 -0
  136. package/src/components/time-range-picker/index.ts +25 -0
  137. package/src/components/time-range-picker/meta.ts +22 -0
  138. package/src/components/time-range-picker/time-expression.ts +491 -0
  139. package/src/components/time-range-picker/time-range-picker.tsx +522 -0
  140. package/src/index.ts +8 -0
  141. package/src/registry/components.ts +16 -0
@@ -0,0 +1,339 @@
1
+ "use client";
2
+
3
+ import { useMemo, type ComponentPropsWithRef, type ReactNode } from "react";
4
+
5
+ import { disabledStyles, focusRing } from "@/lib/styles";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ import { countChanges, toSplitRows, type DiffHunk, type DiffRow } from "./diff-model";
9
+
10
+ /**
11
+ * A diff, and a decision about it.
12
+ *
13
+ * The rendering half exists because every packaged viewer brings its own
14
+ * styling system — emotion, or HTML strings plus a stylesheet — which design
15
+ * tokens cannot reach and which is awkward under RSC. The deciding half exists
16
+ * because Dowel is for AI products, and an agent proposing a change to a file is
17
+ * the case that needs it: what every coding agent ships, and no component
18
+ * library does.
19
+ *
20
+ * Decisions are controlled. The component never mutates the diff or applies
21
+ * anything; it reports which hunks were accepted and rejected and leaves the
22
+ * consequences to the application, the same way ai-action-ledger does.
23
+ *
24
+ * Not a grid. A code listing is a table of text, and role="grid" would promise
25
+ * cell-by-cell arrow navigation that neither exists here nor makes sense for
26
+ * reading code. Line kind is carried in text, because a plus sign and a green
27
+ * background are not information.
28
+ */
29
+
30
+ export type HunkDecision = "accepted" | "rejected";
31
+
32
+ const KIND_LABEL: Record<DiffRow["kind"], string> = {
33
+ added: "Added",
34
+ removed: "Removed",
35
+ context: "Unchanged",
36
+ };
37
+
38
+ export interface DiffViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
39
+ hunks: DiffHunk[];
40
+ /** Names the diff — usually the path of the file being changed. */
41
+ label: string;
42
+ view?: "unified" | "split";
43
+ /** Per-hunk decisions, keyed by hunk id. Controlled. */
44
+ decisions?: Record<string, HunkDecision>;
45
+ onDecision?: (hunkId: string, decision: HunkDecision) => void;
46
+ children?: ReactNode;
47
+ }
48
+
49
+ export function DiffViewer({
50
+ className,
51
+ hunks,
52
+ label,
53
+ view = "unified",
54
+ decisions,
55
+ onDecision,
56
+ children,
57
+ ...props
58
+ }: DiffViewerProps) {
59
+ const counts = useMemo(() => countChanges(hunks), [hunks]);
60
+
61
+ return (
62
+ <div
63
+ data-slot="diff-viewer"
64
+ data-view={view}
65
+ className={cn("flex flex-col gap-2", className)}
66
+ {...props}
67
+ >
68
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
69
+ <span className="font-mono text-sm font-medium">{label}</span>
70
+ {/* Said in words as well as coloured, and before the diff rather than
71
+ after it: a reader deserves to know the size of the change before
72
+ they start reading it. */}
73
+ <span data-slot="diff-viewer-summary" className="text-xs text-muted-foreground">
74
+ <span className="text-success">+{counts.added}</span>{" "}
75
+ <span className="text-destructive">−{counts.removed}</span>
76
+ <span className="sr-only">
77
+ {` — ${String(counts.added)} lines added, ${String(counts.removed)} removed`}
78
+ </span>
79
+ </span>
80
+ </div>
81
+
82
+ {children}
83
+
84
+ {hunks.length === 0 ? (
85
+ <p className="rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
86
+ No changes.
87
+ </p>
88
+ ) : (
89
+ <div className="overflow-hidden rounded-lg border border-border">
90
+ {hunks.map((hunk) => (
91
+ <DiffHunkView
92
+ key={hunk.id}
93
+ hunk={hunk}
94
+ label={label}
95
+ view={view}
96
+ decision={decisions?.[hunk.id]}
97
+ onDecision={onDecision}
98
+ />
99
+ ))}
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function DiffHunkView({
107
+ hunk,
108
+ label,
109
+ view,
110
+ decision,
111
+ onDecision,
112
+ }: {
113
+ hunk: DiffHunk;
114
+ label: string;
115
+ view: "unified" | "split";
116
+ decision?: HunkDecision;
117
+ onDecision?: (hunkId: string, decision: HunkDecision) => void;
118
+ }) {
119
+ const splitRows = useMemo(
120
+ () => (view === "split" ? toSplitRows(hunk.rows) : []),
121
+ [view, hunk.rows],
122
+ );
123
+
124
+ return (
125
+ <section
126
+ data-slot="diff-hunk"
127
+ data-decision={decision}
128
+ aria-label={`${label}, hunk ${hunk.id}`}
129
+ className={cn(
130
+ "border-b border-border last:border-b-0",
131
+ decision === "rejected" && "opacity-55",
132
+ )}
133
+ >
134
+ {hunk.skippedBefore > 0 ? (
135
+ <p
136
+ data-slot="diff-hunk-skipped"
137
+ className="border-b border-border bg-muted/40 px-3 py-1 font-mono text-2xs text-muted-foreground"
138
+ >
139
+ {hunk.skippedBefore} unchanged {hunk.skippedBefore === 1 ? "line" : "lines"} hidden
140
+ </p>
141
+ ) : null}
142
+
143
+ {onDecision ? (
144
+ <div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5">
145
+ <span className="flex-1 text-xs text-muted-foreground">
146
+ {decision === "accepted"
147
+ ? "Accepted"
148
+ : decision === "rejected"
149
+ ? "Rejected"
150
+ : "Not decided"}
151
+ </span>
152
+ <HunkButton
153
+ pressed={decision === "accepted"}
154
+ onClick={() => {
155
+ onDecision(hunk.id, "accepted");
156
+ }}
157
+ >
158
+ Accept
159
+ </HunkButton>
160
+ <HunkButton
161
+ pressed={decision === "rejected"}
162
+ onClick={() => {
163
+ onDecision(hunk.id, "rejected");
164
+ }}
165
+ >
166
+ Reject
167
+ </HunkButton>
168
+ </div>
169
+ ) : null}
170
+
171
+ <div className="overflow-x-auto">
172
+ <table data-slot="diff-table" className="w-full border-collapse font-mono text-xs">
173
+ <tbody>
174
+ {view === "split"
175
+ ? splitRows.map((pair, index) => (
176
+ <tr key={index} data-slot="diff-row">
177
+ <SplitCell row={pair.left} side="before" />
178
+ <SplitCell row={pair.right} side="after" />
179
+ </tr>
180
+ ))
181
+ : hunk.rows.map((row, index) => <UnifiedRow key={index} row={row} />)}
182
+ </tbody>
183
+ </table>
184
+ </div>
185
+ </section>
186
+ );
187
+ }
188
+
189
+ const ROW_STYLES: Record<DiffRow["kind"], string> = {
190
+ added: "bg-success/10",
191
+ removed: "bg-destructive/10",
192
+ context: "",
193
+ };
194
+
195
+ function UnifiedRow({ row }: { row: DiffRow }) {
196
+ return (
197
+ <tr data-slot="diff-row" data-kind={row.kind} className={ROW_STYLES[row.kind]}>
198
+ <LineNumber value={row.before} />
199
+ <LineNumber value={row.after} />
200
+ <td className="w-4 pr-1 text-center text-muted-foreground select-none" aria-hidden="true">
201
+ {row.kind === "added" ? "+" : row.kind === "removed" ? "−" : ""}
202
+ </td>
203
+ <td className="w-full py-0.5 pr-3 break-all whitespace-pre-wrap">
204
+ {/* The kind, for anyone who cannot see the sign or the tint. Reading a
205
+ diff aloud without it is reading the same file twice. */}
206
+ <span className="sr-only">{KIND_LABEL[row.kind]}: </span>
207
+ <RowContent row={row} />
208
+ </td>
209
+ </tr>
210
+ );
211
+ }
212
+
213
+ function SplitCell({ row, side }: { row: DiffRow | null; side: "before" | "after" }) {
214
+ if (!row) {
215
+ // An empty half of a pair, not a blank line of code. Hidden from assistive
216
+ // technology so a reader is not read padding.
217
+ return (
218
+ <>
219
+ <td aria-hidden="true" className="w-10 bg-muted/40" />
220
+ <td aria-hidden="true" className="w-1/2 bg-muted/20" />
221
+ </>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <>
227
+ <LineNumber value={side === "before" ? row.before : row.after} />
228
+ <td
229
+ className={cn("w-1/2 py-0.5 pr-3 break-all whitespace-pre-wrap", ROW_STYLES[row.kind])}
230
+ >
231
+ <span className="sr-only">{KIND_LABEL[row.kind]}: </span>
232
+ <RowContent row={row} />
233
+ </td>
234
+ </>
235
+ );
236
+ }
237
+
238
+ function RowContent({ row }: { row: DiffRow }) {
239
+ if (!row.segments) return <>{row.content}</>;
240
+
241
+ return (
242
+ <>
243
+ {row.segments.map((segment, index) =>
244
+ segment.changed ? (
245
+ // A mark element, so the changed words survive as structure rather
246
+ // than existing only as a slightly stronger background.
247
+ <mark
248
+ key={index}
249
+ className={cn(
250
+ "rounded-[2px] text-inherit",
251
+ row.kind === "added" ? "bg-success/30" : "bg-destructive/30",
252
+ )}
253
+ >
254
+ {segment.text}
255
+ </mark>
256
+ ) : (
257
+ <span key={index}>{segment.text}</span>
258
+ ),
259
+ )}
260
+ </>
261
+ );
262
+ }
263
+
264
+ function LineNumber({ value }: { value?: number }) {
265
+ return (
266
+ <td
267
+ // Decorative: the line number is orientation for a sighted reader, and
268
+ // announcing two numbers before every line makes the diff unlistenable.
269
+ aria-hidden="true"
270
+ className="w-10 border-r border-border bg-muted/40 px-2 py-0.5 text-right text-muted-foreground tabular-nums select-none"
271
+ >
272
+ {value ?? ""}
273
+ </td>
274
+ );
275
+ }
276
+
277
+ function HunkButton({
278
+ className,
279
+ pressed,
280
+ ...props
281
+ }: ComponentPropsWithRef<"button"> & { pressed: boolean }) {
282
+ return (
283
+ <button
284
+ type="button"
285
+ aria-pressed={pressed}
286
+ className={cn(
287
+ "rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
288
+ pressed
289
+ ? "border-primary bg-primary text-primary-foreground"
290
+ : "border-input bg-background hover:bg-accent hover:text-accent-foreground",
291
+ focusRing,
292
+ disabledStyles,
293
+ className,
294
+ )}
295
+ {...props}
296
+ />
297
+ );
298
+ }
299
+
300
+ /** Switches between unified and side-by-side. */
301
+ export function DiffViewerToolbar({
302
+ className,
303
+ view,
304
+ onViewChange,
305
+ ...props
306
+ }: Omit<ComponentPropsWithRef<"div">, "onChange"> & {
307
+ view: "unified" | "split";
308
+ onViewChange: (view: "unified" | "split") => void;
309
+ }) {
310
+ return (
311
+ <div
312
+ data-slot="diff-viewer-toolbar"
313
+ role="group"
314
+ aria-label="Diff layout"
315
+ className={cn("flex items-center gap-1", className)}
316
+ {...props}
317
+ >
318
+ {(["unified", "split"] as const).map((option) => (
319
+ <button
320
+ key={option}
321
+ type="button"
322
+ aria-pressed={view === option}
323
+ onClick={() => {
324
+ onViewChange(option);
325
+ }}
326
+ className={cn(
327
+ "rounded-md border px-2 py-0.5 text-xs font-medium capitalize transition-colors",
328
+ view === option
329
+ ? "border-transparent bg-secondary text-secondary-foreground"
330
+ : "border-input bg-background text-muted-foreground hover:text-foreground",
331
+ focusRing,
332
+ )}
333
+ >
334
+ {option}
335
+ </button>
336
+ ))}
337
+ </div>
338
+ );
339
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ DiffViewer,
3
+ DiffViewerToolbar,
4
+ type DiffViewerProps,
5
+ type HunkDecision,
6
+ } from "./diff-viewer";
7
+ export {
8
+ buildDiff,
9
+ countChanges,
10
+ groupIntoHunks,
11
+ toSplitRows,
12
+ type BuildDiffOptions,
13
+ type DiffHunk,
14
+ type DiffRow,
15
+ type RowKind,
16
+ type WordSegment,
17
+ } from "./diff-model";
@@ -0,0 +1,20 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "diff-viewer",
5
+ title: "Diff Viewer",
6
+ description: "A diff you can style, and accept or reject hunk by hunk.",
7
+ category: "data",
8
+ status: "stable",
9
+ dependencies: ["diff"],
10
+ registryDependencies: [],
11
+ files: ["diff-model.ts", "diff-viewer.tsx"],
12
+ a11y:
13
+ 'A semantic table, not a grid. role="grid" would promise cell-by-cell arrow navigation that ' +
14
+ "does not exist here and makes no sense for reading code. Every row states added, removed or " +
15
+ "unchanged in text, because a plus sign and a green tint are not information — a diff read " +
16
+ "aloud without it is just the same file twice. Line numbers are aria-hidden: they orient a " +
17
+ "sighted reader, and announcing two numbers before every line makes the diff unlistenable. " +
18
+ "Changed words are marked with mark elements so they survive as structure, and the empty half " +
19
+ "of a split pair is hidden rather than read as a blank line of code.",
20
+ });
@@ -0,0 +1,322 @@
1
+ "use client";
2
+
3
+ import {
4
+ useId,
5
+ useRef,
6
+ useState,
7
+ type ComponentPropsWithRef,
8
+ type DragEvent,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { disabledStyles, focusRing } from "@/lib/styles";
13
+ import { cn } from "@/lib/utils";
14
+
15
+ import { formatBytes, type QueuedFile, type UploadStatus } from "./upload-queue";
16
+
17
+ /**
18
+ * The visible half of uploading. The queue is in `upload-queue.ts` and is the
19
+ * part worth owning.
20
+ *
21
+ * There is no dropzone pattern in the WAI-ARIA APG, and inventing one is the
22
+ * usual failure: a `div` with `role="button"`, a keydown handler, and a file
23
+ * picker that keyboard users can never reach. So the control here is a real
24
+ * `<input type="file">` with a real `<label>`. That is already operable by
25
+ * keyboard, already announces itself, already opens the picker on Enter and
26
+ * Space, and needs nothing added. Drag and drop is layered on top as a pointer
27
+ * convenience, and every drop can also be done from the input.
28
+ */
29
+
30
+ const STATUS_LABEL: Record<UploadStatus, string> = {
31
+ queued: "Waiting",
32
+ uploading: "Uploading",
33
+ done: "Uploaded",
34
+ failed: "Failed",
35
+ cancelled: "Cancelled",
36
+ };
37
+
38
+ export interface FileUploadProps extends Omit<ComponentPropsWithRef<"div">, "onDrop"> {
39
+ /** Names the control. */
40
+ label: string;
41
+ onFiles: (files: File[]) => void;
42
+ accept?: string;
43
+ multiple?: boolean;
44
+ disabled?: boolean;
45
+ /** Shown under the prompt: accepted types, size limit. */
46
+ hint?: ReactNode;
47
+ children?: ReactNode;
48
+ }
49
+
50
+ export function FileUpload({
51
+ className,
52
+ label,
53
+ onFiles,
54
+ accept,
55
+ multiple = true,
56
+ disabled = false,
57
+ hint,
58
+ children,
59
+ ...props
60
+ }: FileUploadProps) {
61
+ const inputId = useId();
62
+ const hintId = useId();
63
+ const [dragging, setDragging] = useState(false);
64
+ const depth = useRef(0);
65
+
66
+ function handleDrop(event: DragEvent<HTMLDivElement>) {
67
+ event.preventDefault();
68
+ depth.current = 0;
69
+ setDragging(false);
70
+ if (disabled) return;
71
+
72
+ const dropped = [...event.dataTransfer.files];
73
+ if (dropped.length > 0) onFiles(multiple ? dropped : dropped.slice(0, 1));
74
+ }
75
+
76
+ return (
77
+ <div data-slot="file-upload" className={cn("flex flex-col gap-3", className)} {...props}>
78
+ {/* dragenter/dragleave fire for every child element, so a plain boolean
79
+ flickers as the pointer crosses the prompt text. Counting depth is
80
+ what makes the highlight steady. */}
81
+ <div
82
+ data-slot="file-upload-dropzone"
83
+ data-dragging={dragging || undefined}
84
+ onDragEnter={(event) => {
85
+ event.preventDefault();
86
+ depth.current += 1;
87
+ if (!disabled) setDragging(true);
88
+ }}
89
+ onDragLeave={() => {
90
+ depth.current -= 1;
91
+ if (depth.current <= 0) setDragging(false);
92
+ }}
93
+ onDragOver={(event) => {
94
+ event.preventDefault();
95
+ }}
96
+ onDrop={handleDrop}
97
+ className={cn(
98
+ "rounded-lg border border-dashed border-border-strong bg-muted/30 px-4 py-6 text-center",
99
+ "transition-colors duration-[var(--duration-fast)]",
100
+ dragging && "border-primary bg-primary/5",
101
+ disabled && "pointer-events-none opacity-55",
102
+ )}
103
+ >
104
+ {/* The label is the control. Clicking it opens the picker, Enter and
105
+ Space activate it, and assistive technology already describes it. */}
106
+ <label
107
+ htmlFor={inputId}
108
+ className={cn(
109
+ "inline-flex cursor-pointer flex-col items-center gap-1 rounded-md px-2 py-1 text-sm",
110
+ "focus-within:ring-2 focus-within:ring-ring/55",
111
+ )}
112
+ >
113
+ <span className="font-medium">{label}</span>
114
+ <span className="text-xs text-muted-foreground">
115
+ Drop {multiple ? "files" : "a file"} here, or choose from your device
116
+ </span>
117
+ <input
118
+ id={inputId}
119
+ type="file"
120
+ accept={accept}
121
+ multiple={multiple}
122
+ disabled={disabled}
123
+ aria-describedby={hint ? hintId : undefined}
124
+ onChange={(event) => {
125
+ const chosen = [...(event.target.files ?? [])];
126
+ if (chosen.length > 0) onFiles(chosen);
127
+ // Reset, so choosing the same file twice fires change twice.
128
+ event.target.value = "";
129
+ }}
130
+ className="sr-only"
131
+ />
132
+ </label>
133
+
134
+ {hint ? (
135
+ <p id={hintId} className="mt-2 text-xs text-muted-foreground">
136
+ {hint}
137
+ </p>
138
+ ) : null}
139
+ </div>
140
+
141
+ {children}
142
+ </div>
143
+ );
144
+ }
145
+
146
+ export interface FileUploadListProps extends ComponentPropsWithRef<"ul"> {
147
+ files: QueuedFile[];
148
+ onCancel?: (id: string) => void;
149
+ onRetry?: (id: string) => void;
150
+ onRemove?: (id: string) => void;
151
+ }
152
+
153
+ export function FileUploadList({
154
+ className,
155
+ files,
156
+ onCancel,
157
+ onRetry,
158
+ onRemove,
159
+ ...props
160
+ }: FileUploadListProps) {
161
+ if (files.length === 0) return null;
162
+
163
+ return (
164
+ <ul
165
+ data-slot="file-upload-list"
166
+ className={cn("flex list-none flex-col gap-2", className)}
167
+ {...props}
168
+ >
169
+ {files.map((entry) => (
170
+ <FileUploadItem
171
+ key={entry.id}
172
+ entry={entry}
173
+ onCancel={onCancel}
174
+ onRetry={onRetry}
175
+ onRemove={onRemove}
176
+ />
177
+ ))}
178
+ </ul>
179
+ );
180
+ }
181
+
182
+ export interface FileUploadItemProps extends Omit<ComponentPropsWithRef<"li">, "children"> {
183
+ entry: QueuedFile;
184
+ onCancel?: (id: string) => void;
185
+ onRetry?: (id: string) => void;
186
+ onRemove?: (id: string) => void;
187
+ }
188
+
189
+ export function FileUploadItem({
190
+ className,
191
+ entry,
192
+ onCancel,
193
+ onRetry,
194
+ onRemove,
195
+ ...props
196
+ }: FileUploadItemProps) {
197
+ const { id, file, status, progress, error } = entry;
198
+ const percent = progress === null ? null : Math.round(progress * 100);
199
+
200
+ return (
201
+ <li
202
+ data-slot="file-upload-item"
203
+ data-status={status}
204
+ className={cn(
205
+ "flex items-center gap-3 rounded-lg border px-3 py-2 text-sm",
206
+ status === "failed"
207
+ ? "border-destructive/40 bg-destructive/5"
208
+ : "border-border bg-card",
209
+ className,
210
+ )}
211
+ {...props}
212
+ >
213
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
214
+ <div className="flex items-baseline justify-between gap-2">
215
+ <span className="truncate font-medium">{file.name}</span>
216
+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
217
+ {formatBytes(file.size)}
218
+ </span>
219
+ </div>
220
+
221
+ {/* Status in words, always. A bar at 60% with a red tint does not say
222
+ whether it is uploading, stalled or failed. */}
223
+ <p
224
+ className={cn(
225
+ "text-xs",
226
+ status === "failed" ? "text-destructive" : "text-muted-foreground",
227
+ )}
228
+ >
229
+ {STATUS_LABEL[status]}
230
+ {status === "uploading" && percent !== null ? ` · ${String(percent)}%` : null}
231
+ {error ? ` · ${error}` : null}
232
+ </p>
233
+
234
+ {status === "uploading" ? (
235
+ <div
236
+ role="progressbar"
237
+ aria-label={`Uploading ${file.name}`}
238
+ aria-valuenow={percent ?? undefined}
239
+ aria-valuemin={0}
240
+ aria-valuemax={100}
241
+ className="h-1 w-full overflow-hidden rounded-full bg-muted"
242
+ >
243
+ <div
244
+ data-slot="file-upload-progress"
245
+ className="h-full rounded-full bg-primary transition-[width] duration-[var(--duration-normal)] ease-[var(--ease-out-quint)]"
246
+ style={{ width: `${String(percent ?? 0)}%` }}
247
+ />
248
+ </div>
249
+ ) : null}
250
+ </div>
251
+
252
+ <div className="flex shrink-0 items-center gap-1">
253
+ {status === "uploading" && onCancel ? (
254
+ <ItemButton onClick={() => onCancel(id)}>Cancel</ItemButton>
255
+ ) : null}
256
+ {(status === "failed" || status === "cancelled") && onRetry ? (
257
+ <ItemButton onClick={() => onRetry(id)}>Retry</ItemButton>
258
+ ) : null}
259
+ {onRemove ? (
260
+ <ItemButton onClick={() => onRemove(id)} aria-label={`Remove ${file.name}`}>
261
+ Remove
262
+ </ItemButton>
263
+ ) : null}
264
+ </div>
265
+ </li>
266
+ );
267
+ }
268
+
269
+ function ItemButton({ className, ...props }: ComponentPropsWithRef<"button">) {
270
+ return (
271
+ <button
272
+ type="button"
273
+ className={cn(
274
+ "rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
275
+ "transition-colors hover:bg-accent hover:text-accent-foreground",
276
+ focusRing,
277
+ disabledStyles,
278
+ className,
279
+ )}
280
+ {...props}
281
+ />
282
+ );
283
+ }
284
+
285
+ /**
286
+ * One sentence covering the whole queue, announced politely.
287
+ *
288
+ * Per-file live regions would talk over each other the moment two uploads run
289
+ * at once; one summary of the set is readable where six competing ones are not.
290
+ */
291
+ export function FileUploadStatus({
292
+ className,
293
+ stats,
294
+ ...props
295
+ }: ComponentPropsWithRef<"p"> & {
296
+ stats: { total: number; active: number; done: number; failed: number };
297
+ }) {
298
+ const { total, active, done, failed } = stats;
299
+
300
+ // Progress through the set, not a count of what happens to be in flight.
301
+ // "3 of 5 uploading" is wrong the moment a concurrency limit holds two back,
302
+ // and it never tells the reader how much of the job is left.
303
+ const message =
304
+ total === 0
305
+ ? ""
306
+ : active > 0
307
+ ? `${String(done)} of ${String(total)} uploaded`
308
+ : failed > 0
309
+ ? `${String(done)} uploaded, ${String(failed)} failed`
310
+ : `${String(done)} uploaded`;
311
+
312
+ return (
313
+ <p
314
+ data-slot="file-upload-status"
315
+ aria-live="polite"
316
+ className={cn("text-xs text-muted-foreground", className)}
317
+ {...props}
318
+ >
319
+ {message}
320
+ </p>
321
+ );
322
+ }