@dowel-ui/react 0.1.1 → 0.3.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 (142) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +193 -0
  3. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
  4. package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
  5. package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
  6. package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
  7. package/dist/components/ai-action-ledger/index.d.ts +2 -0
  8. package/dist/components/ai-action-ledger/index.js +2 -0
  9. package/dist/components/ai-action-ledger/meta.js +17 -0
  10. package/dist/components/ai-action-ledger/meta.js.map +1 -0
  11. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
  12. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
  13. package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
  14. package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
  15. package/dist/components/ai-agent-plan/index.d.ts +2 -0
  16. package/dist/components/ai-agent-plan/index.js +2 -0
  17. package/dist/components/ai-agent-plan/meta.js +17 -0
  18. package/dist/components/ai-agent-plan/meta.js.map +1 -0
  19. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  20. package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
  21. package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
  22. package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
  23. package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
  24. package/dist/components/ai-approval-request/index.d.ts +2 -0
  25. package/dist/components/ai-approval-request/index.js +2 -0
  26. package/dist/components/ai-approval-request/meta.js +17 -0
  27. package/dist/components/ai-approval-request/meta.js.map +1 -0
  28. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
  29. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
  30. package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
  31. package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
  32. package/dist/components/ai-inline-completion/index.d.ts +2 -0
  33. package/dist/components/ai-inline-completion/index.js +2 -0
  34. package/dist/components/ai-inline-completion/meta.js +17 -0
  35. package/dist/components/ai-inline-completion/meta.js.map +1 -0
  36. package/dist/components/ai-response/ai-response.d.ts.map +1 -1
  37. package/dist/components/ai-response/ai-response.js +1 -0
  38. package/dist/components/ai-response/ai-response.js.map +1 -1
  39. package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
  40. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
  41. package/dist/components/ai-structured-output/ai-structured-output.js +137 -0
  42. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
  43. package/dist/components/ai-structured-output/index.d.ts +2 -0
  44. package/dist/components/ai-structured-output/index.js +2 -0
  45. package/dist/components/ai-structured-output/meta.js +17 -0
  46. package/dist/components/ai-structured-output/meta.js.map +1 -0
  47. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  48. package/dist/components/alert/alert.d.ts +1 -1
  49. package/dist/components/badge/badge.d.ts +1 -1
  50. package/dist/components/button/button.d.ts +1 -1
  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/meter/index.d.ts +2 -0
  64. package/dist/components/meter/index.js +2 -0
  65. package/dist/components/meter/meta.js +17 -0
  66. package/dist/components/meter/meta.js.map +1 -0
  67. package/dist/components/meter/meter.d.ts +69 -0
  68. package/dist/components/meter/meter.d.ts.map +1 -0
  69. package/dist/components/meter/meter.js +144 -0
  70. package/dist/components/meter/meter.js.map +1 -0
  71. package/dist/components/metric-delta/index.d.ts +2 -0
  72. package/dist/components/metric-delta/index.js +2 -0
  73. package/dist/components/metric-delta/meta.js +17 -0
  74. package/dist/components/metric-delta/meta.js.map +1 -0
  75. package/dist/components/metric-delta/metric-delta.d.ts +59 -0
  76. package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
  77. package/dist/components/metric-delta/metric-delta.js +82 -0
  78. package/dist/components/metric-delta/metric-delta.js.map +1 -0
  79. package/dist/components/progress/progress.d.ts +1 -1
  80. package/dist/components/progress/progress.js +1 -0
  81. package/dist/components/progress/progress.js.map +1 -1
  82. package/dist/components/record-diff/index.d.ts +2 -0
  83. package/dist/components/record-diff/index.js +2 -0
  84. package/dist/components/record-diff/meta.js +17 -0
  85. package/dist/components/record-diff/meta.js.map +1 -0
  86. package/dist/components/record-diff/record-diff.d.ts +56 -0
  87. package/dist/components/record-diff/record-diff.d.ts.map +1 -0
  88. package/dist/components/record-diff/record-diff.js +199 -0
  89. package/dist/components/record-diff/record-diff.js.map +1 -0
  90. package/dist/components/spinner/spinner.js +1 -0
  91. package/dist/components/spinner/spinner.js.map +1 -1
  92. package/dist/components/tags-input/index.d.ts +2 -0
  93. package/dist/components/tags-input/index.js +2 -0
  94. package/dist/components/tags-input/meta.js +17 -0
  95. package/dist/components/tags-input/meta.js.map +1 -0
  96. package/dist/components/tags-input/tags-input.d.ts +47 -0
  97. package/dist/components/tags-input/tags-input.d.ts.map +1 -0
  98. package/dist/components/tags-input/tags-input.js +182 -0
  99. package/dist/components/tags-input/tags-input.js.map +1 -0
  100. package/dist/components/toast/toast.d.ts +1 -1
  101. package/dist/index.d.ts +22 -1
  102. package/dist/index.js +12 -1
  103. package/dist/registry/components.d.ts.map +1 -1
  104. package/dist/registry/components.js +65 -45
  105. package/dist/registry/components.js.map +1 -1
  106. package/package.json +36 -4
  107. package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
  108. package/src/components/ai-action-ledger/index.ts +14 -0
  109. package/src/components/ai-action-ledger/meta.ts +19 -0
  110. package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
  111. package/src/components/ai-agent-plan/index.ts +10 -0
  112. package/src/components/ai-agent-plan/meta.ts +19 -0
  113. package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
  114. package/src/components/ai-approval-request/index.ts +7 -0
  115. package/src/components/ai-approval-request/meta.ts +21 -0
  116. package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
  117. package/src/components/ai-inline-completion/index.ts +1 -0
  118. package/src/components/ai-inline-completion/meta.ts +20 -0
  119. package/src/components/ai-response/ai-response.tsx +1 -0
  120. package/src/components/ai-structured-output/ai-structured-output.tsx +268 -0
  121. package/src/components/ai-structured-output/index.ts +10 -0
  122. package/src/components/ai-structured-output/meta.ts +18 -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/meter/index.ts +8 -0
  128. package/src/components/meter/meta.ts +20 -0
  129. package/src/components/meter/meter.tsx +219 -0
  130. package/src/components/metric-delta/index.ts +6 -0
  131. package/src/components/metric-delta/meta.ts +18 -0
  132. package/src/components/metric-delta/metric-delta.tsx +180 -0
  133. package/src/components/progress/progress.tsx +3 -0
  134. package/src/components/record-diff/index.ts +10 -0
  135. package/src/components/record-diff/meta.ts +19 -0
  136. package/src/components/record-diff/record-diff.tsx +341 -0
  137. package/src/components/spinner/spinner.tsx +5 -0
  138. package/src/components/tags-input/index.ts +1 -0
  139. package/src/components/tags-input/meta.ts +23 -0
  140. package/src/components/tags-input/tags-input.tsx +300 -0
  141. package/src/index.ts +10 -0
  142. package/src/registry/components.ts +20 -0
@@ -0,0 +1,268 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useMemo,
7
+ useState,
8
+ type ComponentPropsWithRef,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ import { cn } from "@/lib/utils";
13
+
14
+ /**
15
+ * An object arriving field by field from the model.
16
+ *
17
+ * Most AI features inside real software are not chatbots. They are invoice
18
+ * extraction, CRM enrichment, resume parsing, form autofill — a schema goes in
19
+ * and a filled object comes back. Every one of them streams a partial object
20
+ * into a layout, and almost every one reflows on each token because the fields
21
+ * appear as they arrive.
22
+ *
23
+ * The honest limitation, stated here rather than discovered later: there is no
24
+ * per-field completion signal anywhere in the stack. A streaming API hands you
25
+ * successive partial snapshots, and a string that has stopped growing is
26
+ * indistinguishable from one still arriving. So this component does not pretend
27
+ * to know. It takes the field list up front, reserves the layout from it, and
28
+ * lets the caller say which fields are settled — defaulting to the rule that a
29
+ * field is settled once a later field has appeared, which is true of
30
+ * depth-first JSON streaming and is documented rather than assumed silently.
31
+ */
32
+
33
+ export type FieldState = "pending" | "streaming" | "settled" | "error";
34
+
35
+ export interface OutputField {
36
+ /** Key in the streamed object. */
37
+ name: string;
38
+ label: string;
39
+ /** Rough height reserved before the value arrives, in lines. */
40
+ lines?: number;
41
+ }
42
+
43
+ interface StructuredOutputContextValue {
44
+ fields: OutputField[];
45
+ value: Record<string, unknown>;
46
+ stateOf: (name: string) => FieldState;
47
+ formatValue: (value: unknown, name: string) => string;
48
+ }
49
+
50
+ const StructuredOutputContext = createContext<StructuredOutputContextValue | null>(null);
51
+
52
+ function useStructuredOutputContext(component: string): StructuredOutputContextValue {
53
+ const context = useContext(StructuredOutputContext);
54
+ if (!context) {
55
+ throw new Error(`${component} must be rendered inside <StructuredOutput>.`);
56
+ }
57
+ return context;
58
+ }
59
+
60
+ function defaultFormat(value: unknown): string {
61
+ if (value === null || value === undefined) return "";
62
+ if (typeof value === "string") return value;
63
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
64
+ if (typeof value === "boolean") return value ? "Yes" : "No";
65
+ if (Array.isArray(value)) return value.map(defaultFormat).join(", ");
66
+ return JSON.stringify(value);
67
+ }
68
+
69
+ export interface StructuredOutputProps extends Omit<ComponentPropsWithRef<"dl">, "children"> {
70
+ /** Declared up front, so layout is reserved before anything arrives. */
71
+ fields: OutputField[];
72
+ /** The latest partial snapshot. */
73
+ value: Record<string, unknown>;
74
+ /**
75
+ * True while more of the object is still expected. When false every present
76
+ * field is settled, which is the only completion signal that is ever certain.
77
+ */
78
+ streaming?: boolean;
79
+ /**
80
+ * Fields the caller knows are finished. Supply this when the API gives a
81
+ * real signal; otherwise the default rule applies.
82
+ */
83
+ settled?: string[];
84
+ /** Fields that failed to extract, with the reason shown in place. */
85
+ errors?: Record<string, string>;
86
+ formatValue?: (value: unknown, name: string) => string;
87
+ children?: ReactNode;
88
+ }
89
+
90
+ export function StructuredOutput({
91
+ className,
92
+ fields,
93
+ value,
94
+ streaming = false,
95
+ settled,
96
+ errors,
97
+ formatValue = defaultFormat,
98
+ children,
99
+ ...props
100
+ }: StructuredOutputProps) {
101
+ const stateOf = useMemo(() => {
102
+ const explicit = settled ? new Set(settled) : null;
103
+ const lastPresentIndex = fields.reduce(
104
+ (last, field, index) => (value[field.name] !== undefined ? index : last),
105
+ -1,
106
+ );
107
+
108
+ return (name: string): FieldState => {
109
+ if (errors?.[name]) return "error";
110
+
111
+ const index = fields.findIndex((field) => field.name === name);
112
+ const present = value[name] !== undefined;
113
+
114
+ if (!present) return "pending";
115
+ if (explicit) return explicit.has(name) ? "settled" : "streaming";
116
+ // No explicit signal. Once a later field has appeared this one cannot
117
+ // still be growing, which holds for depth-first JSON streaming. The last
118
+ // present field stays "streaming" until the caller says streaming ended.
119
+ if (!streaming) return "settled";
120
+ return index < lastPresentIndex ? "settled" : "streaming";
121
+ };
122
+ }, [fields, value, streaming, settled, errors]);
123
+
124
+ const context = useMemo<StructuredOutputContextValue>(
125
+ () => ({ fields, value, stateOf, formatValue }),
126
+ [fields, value, stateOf, formatValue],
127
+ );
128
+
129
+ return (
130
+ <StructuredOutputContext.Provider value={context}>
131
+ <dl
132
+ data-slot="structured-output"
133
+ data-streaming={streaming || undefined}
134
+ // Fields arrive one at a time and each is a small, self-contained fact,
135
+ // so the region is polite and additive rather than re-reading the whole
136
+ // object on every token.
137
+ aria-live="polite"
138
+ aria-busy={streaming}
139
+ className={cn("flex flex-col gap-3", className)}
140
+ {...props}
141
+ >
142
+ {children ??
143
+ fields.map((field) => <StructuredField key={field.name} name={field.name} />)}
144
+ </dl>
145
+ </StructuredOutputContext.Provider>
146
+ );
147
+ }
148
+
149
+ export interface StructuredFieldProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
150
+ name: string;
151
+ children?: ReactNode;
152
+ }
153
+
154
+ /**
155
+ * One field: its label, its value, and the space it will occupy before it has
156
+ * one.
157
+ *
158
+ * The reserved height is what stops the layout jumping as the object fills in.
159
+ */
160
+ export function StructuredField({ className, name, children, ...props }: StructuredFieldProps) {
161
+ const { fields, value, stateOf, formatValue } = useStructuredOutputContext("StructuredField");
162
+
163
+ const field = fields.find((candidate) => candidate.name === name);
164
+ if (!field) {
165
+ throw new Error(`StructuredField "${name}" is not declared in the fields prop.`);
166
+ }
167
+
168
+ const state = stateOf(name);
169
+ const raw = value[name];
170
+
171
+ // "A token arrived" and "this field is final" are different facts, and in a
172
+ // streamed object they look identical. The settle makes that distinction
173
+ // visible — so it plays on the transition into settled, and never on a field
174
+ // that was already final when the component mounted.
175
+ //
176
+ // State adjusted during render rather than tracked in a ref: reading a ref
177
+ // while rendering is unsafe under concurrent rendering, and an effect would
178
+ // paint the plain value first and animate a frame later. React re-renders
179
+ // immediately here and discards the intermediate pass.
180
+ const [seenState, setSeenState] = useState<FieldState>(state);
181
+ const [hasSettled, setHasSettled] = useState(false);
182
+
183
+ if (seenState !== state) {
184
+ setSeenState(state);
185
+ if (state === "settled" && seenState !== "settled") setHasSettled(true);
186
+ }
187
+
188
+ return (
189
+ <div
190
+ data-slot="structured-field"
191
+ data-state={state}
192
+ data-settled={hasSettled || undefined}
193
+ data-field={name}
194
+ className={cn("flex flex-col gap-1", className)}
195
+ {...props}
196
+ >
197
+ <dt className="text-xs text-muted-foreground">{field.label}</dt>
198
+ <dd
199
+ className={cn(
200
+ "m-0 text-sm",
201
+ state === "pending" && "text-muted-foreground",
202
+ state === "error" && "text-destructive",
203
+ )}
204
+ // Height is reserved from the declared line count so the value lands in
205
+ // space already set aside for it rather than pushing the page down.
206
+ // Computed rather than left to calc() so the reserved height is a plain
207
+ // value that can be asserted and reasoned about.
208
+ style={{ minHeight: `${String((field.lines ?? 1) * 1.25)}rem` }}
209
+ >
210
+ {state === "pending" ? (
211
+ <span
212
+ data-slot="structured-field-placeholder"
213
+ aria-hidden="true"
214
+ className="block h-4 w-24 animate-pulse-soft rounded bg-muted"
215
+ />
216
+ ) : (
217
+ <span
218
+ data-slot="structured-field-value"
219
+ className={cn(hasSettled && "inline-block animate-settle")}
220
+ >
221
+ {children ?? formatValue(raw, name)}
222
+ </span>
223
+ )}
224
+ </dd>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ /**
230
+ * A confidence score for one field.
231
+ *
232
+ * Rendered as text with the number, never as a bare colour, because "how much
233
+ * should I trust this" is exactly the thing a colour cannot answer.
234
+ */
235
+ export function StructuredConfidence({
236
+ className,
237
+ value: confidence,
238
+ lowBelow = 0.7,
239
+ ...props
240
+ }: ComponentPropsWithRef<"p"> & { value: number; lowBelow?: number }) {
241
+ const low = confidence < lowBelow;
242
+ const percent = new Intl.NumberFormat(undefined, {
243
+ style: "percent",
244
+ maximumFractionDigits: 0,
245
+ }).format(confidence);
246
+
247
+ return (
248
+ <p
249
+ data-slot="structured-confidence"
250
+ data-low={low || undefined}
251
+ className={cn("text-xs", low ? "text-warning" : "text-muted-foreground", className)}
252
+ {...props}
253
+ >
254
+ {low ? `Low confidence, ${percent} — worth checking` : `${percent} confidence`}
255
+ </p>
256
+ );
257
+ }
258
+
259
+ /** The reason a field could not be extracted, shown where the value would be. */
260
+ export function StructuredFieldError({ className, ...props }: ComponentPropsWithRef<"p">) {
261
+ return (
262
+ <p
263
+ data-slot="structured-field-error"
264
+ className={cn("text-xs text-destructive", className)}
265
+ {...props}
266
+ />
267
+ );
268
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ StructuredConfidence,
3
+ StructuredField,
4
+ StructuredFieldError,
5
+ StructuredOutput,
6
+ type FieldState,
7
+ type OutputField,
8
+ type StructuredFieldProps,
9
+ type StructuredOutputProps,
10
+ } from "./ai-structured-output";
@@ -0,0 +1,18 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "ai-structured-output",
5
+ title: "AI Structured Output",
6
+ description: "An object arriving field by field from the model, without the layout jumping.",
7
+ category: "ai",
8
+ status: "stable",
9
+ dependencies: [],
10
+ registryDependencies: [],
11
+ files: ["ai-structured-output.tsx"],
12
+ a11y:
13
+ "A description list, so each value is associated with its label rather than floating beside " +
14
+ 'it. The region is aria-live="polite" with aria-busy while streaming, so fields are ' +
15
+ "announced as they settle instead of re-reading the whole object on every token. Height is " +
16
+ "reserved from the declared field list, which keeps focus and reading position stable as " +
17
+ "values arrive. Confidence is stated as a number in words, never as a colour alone.",
18
+ });
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ export {
2
+ FileUpload,
3
+ FileUploadItem,
4
+ FileUploadList,
5
+ FileUploadStatus,
6
+ type FileUploadItemProps,
7
+ type FileUploadListProps,
8
+ type FileUploadProps,
9
+ } from "./file-upload";
10
+ export {
11
+ formatBytes,
12
+ matchesAccept,
13
+ useUploadQueue,
14
+ xhrUpload,
15
+ type QueuedFile,
16
+ type UploadContext,
17
+ type UploadFn,
18
+ type UploadQueueOptions,
19
+ type UploadStatus,
20
+ } from "./upload-queue";
@@ -0,0 +1,21 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "file-upload",
5
+ title: "File Upload",
6
+ description: "A dropzone over a real file input, plus the upload queue nobody ships.",
7
+ category: "form",
8
+ status: "stable",
9
+ dependencies: [],
10
+ registryDependencies: [],
11
+ files: ["upload-queue.ts", "file-upload.tsx"],
12
+ a11y:
13
+ "The APG has no dropzone pattern, and inventing one is the usual failure — a div with " +
14
+ 'role="button", a keydown handler, and a picker keyboard users never reach. The control here ' +
15
+ "is a real input[type=file] with a real label, which is already operable, already announced, " +
16
+ "and opens the picker on Enter and Space with nothing added. Drag and drop is a pointer " +
17
+ "convenience layered on top; every drop can also be done from the input. Each uploading file " +
18
+ "gets a progressbar named after it, status is always stated in words as well as drawn, and " +
19
+ "one polite live region summarises the whole queue rather than six per-file regions talking " +
20
+ "over each other.",
21
+ });