@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.
- package/LICENSE +1 -1
- package/README.md +193 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.d.ts +77 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.d.ts.map +1 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.js +224 -0
- package/dist/components/ai-action-ledger/ai-action-ledger.js.map +1 -0
- package/dist/components/ai-action-ledger/index.d.ts +2 -0
- package/dist/components/ai-action-ledger/index.js +2 -0
- package/dist/components/ai-action-ledger/meta.js +17 -0
- package/dist/components/ai-action-ledger/meta.js.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
- package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
- package/dist/components/ai-agent-plan/index.d.ts +2 -0
- package/dist/components/ai-agent-plan/index.js +2 -0
- package/dist/components/ai-agent-plan/meta.js +17 -0
- package/dist/components/ai-agent-plan/meta.js.map +1 -0
- package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
- package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
- package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
- package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
- package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
- package/dist/components/ai-approval-request/index.d.ts +2 -0
- package/dist/components/ai-approval-request/index.js +2 -0
- package/dist/components/ai-approval-request/meta.js +17 -0
- package/dist/components/ai-approval-request/meta.js.map +1 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.d.ts +45 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
- package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
- package/dist/components/ai-inline-completion/index.d.ts +2 -0
- package/dist/components/ai-inline-completion/index.js +2 -0
- package/dist/components/ai-inline-completion/meta.js +17 -0
- package/dist/components/ai-inline-completion/meta.js.map +1 -0
- package/dist/components/ai-response/ai-response.d.ts.map +1 -1
- package/dist/components/ai-response/ai-response.js +1 -0
- package/dist/components/ai-response/ai-response.js.map +1 -1
- package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
- package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
- package/dist/components/ai-structured-output/ai-structured-output.js +137 -0
- package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
- package/dist/components/ai-structured-output/index.d.ts +2 -0
- package/dist/components/ai-structured-output/index.js +2 -0
- package/dist/components/ai-structured-output/meta.js +17 -0
- package/dist/components/ai-structured-output/meta.js.map +1 -0
- package/dist/components/ai-tool/ai-tool.d.ts +1 -1
- package/dist/components/alert/alert.d.ts +1 -1
- package/dist/components/badge/badge.d.ts +1 -1
- package/dist/components/button/button.d.ts +1 -1
- package/dist/components/file-upload/file-upload.d.ts +46 -0
- package/dist/components/file-upload/file-upload.d.ts.map +1 -0
- package/dist/components/file-upload/file-upload.js +204 -0
- package/dist/components/file-upload/file-upload.js.map +1 -0
- package/dist/components/file-upload/index.d.ts +3 -0
- package/dist/components/file-upload/index.js +3 -0
- package/dist/components/file-upload/meta.js +17 -0
- package/dist/components/file-upload/meta.js.map +1 -0
- package/dist/components/file-upload/upload-queue.d.ts +87 -0
- package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
- package/dist/components/file-upload/upload-queue.js +249 -0
- package/dist/components/file-upload/upload-queue.js.map +1 -0
- package/dist/components/meter/index.d.ts +2 -0
- package/dist/components/meter/index.js +2 -0
- package/dist/components/meter/meta.js +17 -0
- package/dist/components/meter/meta.js.map +1 -0
- package/dist/components/meter/meter.d.ts +69 -0
- package/dist/components/meter/meter.d.ts.map +1 -0
- package/dist/components/meter/meter.js +144 -0
- package/dist/components/meter/meter.js.map +1 -0
- package/dist/components/metric-delta/index.d.ts +2 -0
- package/dist/components/metric-delta/index.js +2 -0
- package/dist/components/metric-delta/meta.js +17 -0
- package/dist/components/metric-delta/meta.js.map +1 -0
- package/dist/components/metric-delta/metric-delta.d.ts +59 -0
- package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
- package/dist/components/metric-delta/metric-delta.js +82 -0
- package/dist/components/metric-delta/metric-delta.js.map +1 -0
- package/dist/components/progress/progress.d.ts +1 -1
- package/dist/components/progress/progress.js +1 -0
- package/dist/components/progress/progress.js.map +1 -1
- package/dist/components/record-diff/index.d.ts +2 -0
- package/dist/components/record-diff/index.js +2 -0
- package/dist/components/record-diff/meta.js +17 -0
- package/dist/components/record-diff/meta.js.map +1 -0
- package/dist/components/record-diff/record-diff.d.ts +56 -0
- package/dist/components/record-diff/record-diff.d.ts.map +1 -0
- package/dist/components/record-diff/record-diff.js +199 -0
- package/dist/components/record-diff/record-diff.js.map +1 -0
- package/dist/components/spinner/spinner.js +1 -0
- package/dist/components/spinner/spinner.js.map +1 -1
- package/dist/components/tags-input/index.d.ts +2 -0
- package/dist/components/tags-input/index.js +2 -0
- package/dist/components/tags-input/meta.js +17 -0
- package/dist/components/tags-input/meta.js.map +1 -0
- package/dist/components/tags-input/tags-input.d.ts +47 -0
- package/dist/components/tags-input/tags-input.d.ts.map +1 -0
- package/dist/components/tags-input/tags-input.js +182 -0
- package/dist/components/tags-input/tags-input.js.map +1 -0
- package/dist/components/toast/toast.d.ts +1 -1
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -1
- package/dist/registry/components.d.ts.map +1 -1
- package/dist/registry/components.js +65 -45
- package/dist/registry/components.js.map +1 -1
- package/package.json +36 -4
- package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
- package/src/components/ai-action-ledger/index.ts +14 -0
- package/src/components/ai-action-ledger/meta.ts +19 -0
- package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
- package/src/components/ai-agent-plan/index.ts +10 -0
- package/src/components/ai-agent-plan/meta.ts +19 -0
- package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
- package/src/components/ai-approval-request/index.ts +7 -0
- package/src/components/ai-approval-request/meta.ts +21 -0
- package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
- package/src/components/ai-inline-completion/index.ts +1 -0
- package/src/components/ai-inline-completion/meta.ts +20 -0
- package/src/components/ai-response/ai-response.tsx +1 -0
- package/src/components/ai-structured-output/ai-structured-output.tsx +268 -0
- package/src/components/ai-structured-output/index.ts +10 -0
- package/src/components/ai-structured-output/meta.ts +18 -0
- package/src/components/file-upload/file-upload.tsx +322 -0
- package/src/components/file-upload/index.ts +20 -0
- package/src/components/file-upload/meta.ts +21 -0
- package/src/components/file-upload/upload-queue.ts +337 -0
- package/src/components/meter/index.ts +8 -0
- package/src/components/meter/meta.ts +20 -0
- package/src/components/meter/meter.tsx +219 -0
- package/src/components/metric-delta/index.ts +6 -0
- package/src/components/metric-delta/meta.ts +18 -0
- package/src/components/metric-delta/metric-delta.tsx +180 -0
- package/src/components/progress/progress.tsx +3 -0
- package/src/components/record-diff/index.ts +10 -0
- package/src/components/record-diff/meta.ts +19 -0
- package/src/components/record-diff/record-diff.tsx +341 -0
- package/src/components/spinner/spinner.tsx +5 -0
- package/src/components/tags-input/index.ts +1 -0
- package/src/components/tags-input/meta.ts +23 -0
- package/src/components/tags-input/tags-input.tsx +300 -0
- package/src/index.ts +10 -0
- package/src/registry/components.ts +20 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The upload queue.
|
|
7
|
+
*
|
|
8
|
+
* The dropzone is the most duplicated component in the React ecosystem and the
|
|
9
|
+
* least valuable half: it is a styled rectangle over `<input type="file">`.
|
|
10
|
+
* What almost nobody ships is this — progress, cancel, retry with backoff, and
|
|
11
|
+
* a concurrency limit — so every team writes it again, usually twice, because
|
|
12
|
+
* the first version has no cancel and no retry.
|
|
13
|
+
*
|
|
14
|
+
* Transport is injected. This never calls `fetch` or constructs a request,
|
|
15
|
+
* because the request is the part that differs everywhere: presigned S3 PUT,
|
|
16
|
+
* multipart POST, tus, an internal gateway with its own auth. `upload` receives
|
|
17
|
+
* the file, a progress callback and an AbortSignal, and returns a promise. That
|
|
18
|
+
* is the whole contract.
|
|
19
|
+
*
|
|
20
|
+
* Progress needs XHR, not fetch. `fetch` still cannot report upload progress in
|
|
21
|
+
* any shipping browser — there is no readable stream for the request body — so
|
|
22
|
+
* a transport that wants a real progress bar has to use XMLHttpRequest. That is
|
|
23
|
+
* the consumer's choice to make, and `xhrUpload` below is a working example
|
|
24
|
+
* rather than a dependency.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type UploadStatus = "queued" | "uploading" | "done" | "failed" | "cancelled";
|
|
28
|
+
|
|
29
|
+
export interface QueuedFile {
|
|
30
|
+
/** Stable across retries, so React keys and announcements do not jump. */
|
|
31
|
+
id: string;
|
|
32
|
+
file: File;
|
|
33
|
+
status: UploadStatus;
|
|
34
|
+
/** 0–1, or null when the transport cannot report it. */
|
|
35
|
+
progress: number | null;
|
|
36
|
+
/** Why it failed or was refused. Kept so it can be read and acted on. */
|
|
37
|
+
error?: string;
|
|
38
|
+
attempts: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UploadContext {
|
|
42
|
+
onProgress: (fraction: number) => void;
|
|
43
|
+
signal: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type UploadFn = (file: File, context: UploadContext) => Promise<void>;
|
|
47
|
+
|
|
48
|
+
export interface UploadQueueOptions {
|
|
49
|
+
upload: UploadFn;
|
|
50
|
+
/** Uploads running at once. More is not faster once the link is saturated. */
|
|
51
|
+
concurrency?: number;
|
|
52
|
+
/** Automatic retries per file before it is left failed. */
|
|
53
|
+
maxAttempts?: number;
|
|
54
|
+
/** Largest accepted file, in bytes. */
|
|
55
|
+
maxSize?: number;
|
|
56
|
+
/** Accepted types, as an `accept` attribute value: ".pdf,image/*". */
|
|
57
|
+
accept?: string;
|
|
58
|
+
/** Total files the queue will hold. */
|
|
59
|
+
maxFiles?: number;
|
|
60
|
+
onComplete?: (file: QueuedFile) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Backoff between attempts. Bounded, because a person is waiting. */
|
|
64
|
+
function backoffMs(attempt: number): number {
|
|
65
|
+
return Math.min(8000, 500 * 2 ** (attempt - 1));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Matches a file against an `accept` string the same way the browser does. */
|
|
69
|
+
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
70
|
+
if (!accept) return true;
|
|
71
|
+
const patterns = accept
|
|
72
|
+
.split(",")
|
|
73
|
+
.map((part) => part.trim().toLowerCase())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
if (patterns.length === 0) return true;
|
|
76
|
+
|
|
77
|
+
const type = file.type.toLowerCase();
|
|
78
|
+
const name = file.name.toLowerCase();
|
|
79
|
+
|
|
80
|
+
return patterns.some((pattern) => {
|
|
81
|
+
if (pattern.startsWith(".")) return name.endsWith(pattern);
|
|
82
|
+
if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
|
|
83
|
+
return type === pattern;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatBytes(bytes: number): string {
|
|
88
|
+
if (bytes < 1000) return `${String(bytes)} B`;
|
|
89
|
+
const units = ["kB", "MB", "GB", "TB"];
|
|
90
|
+
let value = bytes / 1000;
|
|
91
|
+
let unit = 0;
|
|
92
|
+
while (value >= 1000 && unit < units.length - 1) {
|
|
93
|
+
value /= 1000;
|
|
94
|
+
unit += 1;
|
|
95
|
+
}
|
|
96
|
+
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit] ?? "B"}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let sequence = 0;
|
|
100
|
+
|
|
101
|
+
export function useUploadQueue(options: UploadQueueOptions) {
|
|
102
|
+
const {
|
|
103
|
+
upload,
|
|
104
|
+
concurrency = 3,
|
|
105
|
+
maxAttempts = 3,
|
|
106
|
+
maxSize,
|
|
107
|
+
accept,
|
|
108
|
+
maxFiles,
|
|
109
|
+
onComplete,
|
|
110
|
+
} = options;
|
|
111
|
+
|
|
112
|
+
const [files, setFiles] = useState<QueuedFile[]>([]);
|
|
113
|
+
|
|
114
|
+
const controllers = useRef(new Map<string, AbortController>());
|
|
115
|
+
const running = useRef(new Set<string>());
|
|
116
|
+
|
|
117
|
+
// The live option values, so an in-flight upload reads the current transport
|
|
118
|
+
// without `run` being re-created — which would churn the scheduling effect
|
|
119
|
+
// every time a consumer passes an inline `upload`. Written after commit
|
|
120
|
+
// rather than during render, because a ref write during render is unsafe
|
|
121
|
+
// under concurrent rendering; `run` only reads this once it is executing,
|
|
122
|
+
// which is always after the effect has run.
|
|
123
|
+
const latest = useRef({ upload, concurrency, maxAttempts, onComplete });
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
latest.current = { upload, concurrency, maxAttempts, onComplete };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const patch = useCallback((id: string, changes: Partial<QueuedFile>) => {
|
|
129
|
+
setFiles((current) =>
|
|
130
|
+
current.map((entry) => (entry.id === id ? { ...entry, ...changes } : entry)),
|
|
131
|
+
);
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const run = useCallback(
|
|
135
|
+
async (entry: QueuedFile) => {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
controllers.current.set(entry.id, controller);
|
|
138
|
+
|
|
139
|
+
const attempt = entry.attempts + 1;
|
|
140
|
+
patch(entry.id, {
|
|
141
|
+
status: "uploading",
|
|
142
|
+
progress: 0,
|
|
143
|
+
attempts: attempt,
|
|
144
|
+
error: undefined,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await latest.current.upload(entry.file, {
|
|
149
|
+
signal: controller.signal,
|
|
150
|
+
onProgress: (fraction) => {
|
|
151
|
+
patch(entry.id, { progress: Math.min(1, Math.max(0, fraction)) });
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
patch(entry.id, { status: "done", progress: 1 });
|
|
156
|
+
latest.current.onComplete?.({ ...entry, status: "done", progress: 1 });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (controller.signal.aborted) {
|
|
159
|
+
patch(entry.id, { status: "cancelled", progress: null });
|
|
160
|
+
} else if (attempt < latest.current.maxAttempts) {
|
|
161
|
+
// Back off, then requeue. Releasing the slot only after the delay is
|
|
162
|
+
// what makes the wait real rather than a busy retry, and the file
|
|
163
|
+
// re-enters the queue like any other so retries obey concurrency too.
|
|
164
|
+
setTimeout(() => {
|
|
165
|
+
running.current.delete(entry.id);
|
|
166
|
+
controllers.current.delete(entry.id);
|
|
167
|
+
patch(entry.id, { status: "queued", progress: null });
|
|
168
|
+
}, backoffMs(attempt));
|
|
169
|
+
return;
|
|
170
|
+
} else {
|
|
171
|
+
patch(entry.id, {
|
|
172
|
+
status: "failed",
|
|
173
|
+
progress: null,
|
|
174
|
+
error: error instanceof Error ? error.message : "Upload failed",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
controllers.current.delete(entry.id);
|
|
180
|
+
running.current.delete(entry.id);
|
|
181
|
+
// No explicit pump: the scheduling effect below reacts to the state
|
|
182
|
+
// change and starts whatever can start next.
|
|
183
|
+
setFiles((current) => [...current]);
|
|
184
|
+
},
|
|
185
|
+
[patch],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Starts whatever can start, once the state that made it startable has been
|
|
189
|
+
// committed. Doing this inside a setState updater — the obvious shortcut —
|
|
190
|
+
// means React may run it twice and upload the same file twice; the `running`
|
|
191
|
+
// set guards that, but the effect is the honest place for it.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const free = concurrency - running.current.size;
|
|
194
|
+
if (free <= 0) return;
|
|
195
|
+
|
|
196
|
+
const next = files
|
|
197
|
+
.filter((entry) => entry.status === "queued" && !running.current.has(entry.id))
|
|
198
|
+
.slice(0, free);
|
|
199
|
+
|
|
200
|
+
for (const entry of next) {
|
|
201
|
+
running.current.add(entry.id);
|
|
202
|
+
void run(entry);
|
|
203
|
+
}
|
|
204
|
+
}, [files, concurrency, run]);
|
|
205
|
+
|
|
206
|
+
/** Validates and enqueues. A rejected file is kept and told why. */
|
|
207
|
+
const add = useCallback(
|
|
208
|
+
(incoming: File[]) => {
|
|
209
|
+
setFiles((current) => {
|
|
210
|
+
const room = maxFiles === undefined ? incoming.length : maxFiles - current.length;
|
|
211
|
+
const admitted: QueuedFile[] = [];
|
|
212
|
+
|
|
213
|
+
for (const file of incoming.slice(0, Math.max(0, room))) {
|
|
214
|
+
sequence += 1;
|
|
215
|
+
const id = `upload-${String(sequence)}`;
|
|
216
|
+
|
|
217
|
+
// A rejected file becomes a failed entry rather than disappearing.
|
|
218
|
+
// Silently dropping a file the reader chose is how these components
|
|
219
|
+
// lose work without anybody noticing.
|
|
220
|
+
if (maxSize !== undefined && file.size > maxSize) {
|
|
221
|
+
admitted.push({
|
|
222
|
+
id,
|
|
223
|
+
file,
|
|
224
|
+
status: "failed",
|
|
225
|
+
progress: null,
|
|
226
|
+
attempts: 0,
|
|
227
|
+
error: `Larger than ${formatBytes(maxSize)}`,
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!matchesAccept(file, accept)) {
|
|
232
|
+
admitted.push({
|
|
233
|
+
id,
|
|
234
|
+
file,
|
|
235
|
+
status: "failed",
|
|
236
|
+
progress: null,
|
|
237
|
+
attempts: 0,
|
|
238
|
+
error: "Type not accepted",
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
admitted.push({ id, file, status: "queued", progress: null, attempts: 0 });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return [...current, ...admitted];
|
|
247
|
+
});
|
|
248
|
+
},
|
|
249
|
+
[accept, maxFiles, maxSize],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const cancel = useCallback((id: string) => {
|
|
253
|
+
controllers.current.get(id)?.abort();
|
|
254
|
+
}, []);
|
|
255
|
+
|
|
256
|
+
const retry = useCallback((id: string) => {
|
|
257
|
+
setFiles((current) =>
|
|
258
|
+
current.map((entry) =>
|
|
259
|
+
entry.id === id
|
|
260
|
+
? { ...entry, status: "queued" as const, error: undefined, attempts: 0 }
|
|
261
|
+
: entry,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}, []);
|
|
265
|
+
|
|
266
|
+
const remove = useCallback((id: string) => {
|
|
267
|
+
controllers.current.get(id)?.abort();
|
|
268
|
+
controllers.current.delete(id);
|
|
269
|
+
running.current.delete(id);
|
|
270
|
+
setFiles((current) => current.filter((entry) => entry.id !== id));
|
|
271
|
+
}, []);
|
|
272
|
+
|
|
273
|
+
const clearCompleted = useCallback(() => {
|
|
274
|
+
setFiles((current) => current.filter((entry) => entry.status !== "done"));
|
|
275
|
+
}, []);
|
|
276
|
+
|
|
277
|
+
const stats = useMemo(() => {
|
|
278
|
+
const by = (status: UploadStatus) =>
|
|
279
|
+
files.filter((entry) => entry.status === status).length;
|
|
280
|
+
return {
|
|
281
|
+
total: files.length,
|
|
282
|
+
queued: by("queued"),
|
|
283
|
+
uploading: by("uploading"),
|
|
284
|
+
done: by("done"),
|
|
285
|
+
failed: by("failed"),
|
|
286
|
+
cancelled: by("cancelled"),
|
|
287
|
+
active: by("queued") + by("uploading"),
|
|
288
|
+
};
|
|
289
|
+
}, [files]);
|
|
290
|
+
|
|
291
|
+
return { files, stats, add, cancel, retry, remove, clearCompleted };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* A working XHR transport, as an example rather than a dependency.
|
|
296
|
+
*
|
|
297
|
+
* XMLHttpRequest and not fetch, because fetch still cannot report upload
|
|
298
|
+
* progress: there is no readable stream for a request body in any shipping
|
|
299
|
+
* browser. Copy this and change the request to match your backend.
|
|
300
|
+
*/
|
|
301
|
+
export function xhrUpload(
|
|
302
|
+
url: string,
|
|
303
|
+
init: { method?: string; headers?: Record<string, string> } = {},
|
|
304
|
+
): UploadFn {
|
|
305
|
+
return (file, { onProgress, signal }) =>
|
|
306
|
+
new Promise<void>((resolve, reject) => {
|
|
307
|
+
const request = new XMLHttpRequest();
|
|
308
|
+
request.open(init.method ?? "POST", url);
|
|
309
|
+
|
|
310
|
+
for (const [header, value] of Object.entries(init.headers ?? {})) {
|
|
311
|
+
request.setRequestHeader(header, value);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
request.upload.addEventListener("progress", (event) => {
|
|
315
|
+
if (event.lengthComputable) onProgress(event.loaded / event.total);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
request.addEventListener("load", () => {
|
|
319
|
+
if (request.status >= 200 && request.status < 300) resolve();
|
|
320
|
+
else reject(new Error(`Upload failed with status ${String(request.status)}`));
|
|
321
|
+
});
|
|
322
|
+
request.addEventListener("error", () => {
|
|
323
|
+
reject(new Error("Network error"));
|
|
324
|
+
});
|
|
325
|
+
request.addEventListener("abort", () => {
|
|
326
|
+
reject(new Error("Cancelled"));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
signal.addEventListener("abort", () => {
|
|
330
|
+
request.abort();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const body = new FormData();
|
|
334
|
+
body.append("file", file);
|
|
335
|
+
request.send(body);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "meter",
|
|
5
|
+
title: "Meter",
|
|
6
|
+
description:
|
|
7
|
+
"A measurement against a capacity — quota, seats, spend — segmented by category.",
|
|
8
|
+
category: "feedback",
|
|
9
|
+
status: "stable",
|
|
10
|
+
dependencies: [],
|
|
11
|
+
registryDependencies: [],
|
|
12
|
+
files: ["meter.tsx"],
|
|
13
|
+
a11y:
|
|
14
|
+
'Uses role="meter", not role="progressbar": a meter reports a level with no expectation of ' +
|
|
15
|
+
"reaching the end, and screen readers phrase the two differently. The bar is one meter and " +
|
|
16
|
+
"the segments inside it are decoration — making each its own widget would put several " +
|
|
17
|
+
"unlabelled meters in the tab order to describe one quantity. aria-valuetext carries the " +
|
|
18
|
+
'unit and the capacity, because a bare "4" says nothing about 4 of what. Per-segment detail ' +
|
|
19
|
+
"lives in MeterLegend as text, where it can be read.",
|
|
20
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, type ComponentPropsWithRef } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A measurement against a known capacity: storage against a quota, seats
|
|
9
|
+
* against a plan, spend against a budget.
|
|
10
|
+
*
|
|
11
|
+
* Not a Progress. Progress reports how far along a task is and is expected to
|
|
12
|
+
* reach the end; a meter reports a level that has no reason to move at all, and
|
|
13
|
+
* where being near the top is the thing worth noticing. They carry different
|
|
14
|
+
* ARIA roles for exactly that reason, and screen readers phrase them
|
|
15
|
+
* differently.
|
|
16
|
+
*
|
|
17
|
+
* The bar is a single `role="meter"` and the segments inside it are decoration.
|
|
18
|
+
* Making each segment its own widget would put five unlabelled meters in the
|
|
19
|
+
* tab order to describe one quantity; the legend carries the per-segment detail
|
|
20
|
+
* as text instead, where it can actually be read.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const TONES = {
|
|
24
|
+
primary: "bg-primary",
|
|
25
|
+
info: "bg-info",
|
|
26
|
+
success: "bg-success",
|
|
27
|
+
warning: "bg-warning",
|
|
28
|
+
destructive: "bg-destructive",
|
|
29
|
+
neutral: "bg-border-strong",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
export type MeterTone = keyof typeof TONES;
|
|
33
|
+
|
|
34
|
+
export interface MeterSegment {
|
|
35
|
+
/** Stable identity, so a re-order does not re-animate every segment. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Named in the legend and in the accessible summary. */
|
|
38
|
+
label: string;
|
|
39
|
+
value: number;
|
|
40
|
+
tone?: MeterTone;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface MeterContextValue {
|
|
44
|
+
segments: MeterSegment[];
|
|
45
|
+
total: number;
|
|
46
|
+
max: number;
|
|
47
|
+
format: (value: number) => string;
|
|
48
|
+
over: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MeterContext = createContext<MeterContextValue | null>(null);
|
|
52
|
+
|
|
53
|
+
function useMeterContext(component: string): MeterContextValue {
|
|
54
|
+
const context = useContext(MeterContext);
|
|
55
|
+
if (!context) {
|
|
56
|
+
throw new Error(`${component} must be rendered inside <Meter>.`);
|
|
57
|
+
}
|
|
58
|
+
return context;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function defaultFormat(value: number): string {
|
|
62
|
+
return new Intl.NumberFormat().format(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MeterProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
66
|
+
/** Parts making up the measurement. One segment is the ordinary case. */
|
|
67
|
+
segments: MeterSegment[];
|
|
68
|
+
/** The capacity being measured against. */
|
|
69
|
+
max: number;
|
|
70
|
+
/** Names the meter. Required — an unnamed measurement is unreadable. */
|
|
71
|
+
label: string;
|
|
72
|
+
/**
|
|
73
|
+
* Fraction of `max` past which the meter reports strain, between 0 and 1.
|
|
74
|
+
* Presentation only: it never changes the reported value.
|
|
75
|
+
*/
|
|
76
|
+
warnAt?: number;
|
|
77
|
+
/** Optional marker on the track, for a soft limit or an alert threshold. */
|
|
78
|
+
threshold?: { value: number; label: string };
|
|
79
|
+
/** Formats every number that reaches the reader. */
|
|
80
|
+
format?: (value: number) => string;
|
|
81
|
+
/** Shown after the bar. Compose `MeterLegend` here, or your own summary. */
|
|
82
|
+
children?: React.ReactNode;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Meter({
|
|
86
|
+
className,
|
|
87
|
+
segments,
|
|
88
|
+
max,
|
|
89
|
+
label,
|
|
90
|
+
warnAt = 0.9,
|
|
91
|
+
threshold,
|
|
92
|
+
format = defaultFormat,
|
|
93
|
+
children,
|
|
94
|
+
...props
|
|
95
|
+
}: MeterProps) {
|
|
96
|
+
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
|
|
97
|
+
const over = total > max;
|
|
98
|
+
const strained = max > 0 && total / max >= warnAt;
|
|
99
|
+
|
|
100
|
+
// Widths are a share of capacity, not of the total, so a half-full meter
|
|
101
|
+
// renders half full. Dividing by the total instead would make every meter
|
|
102
|
+
// look completely full, which is the usual bug in hand-rolled versions.
|
|
103
|
+
const denominator = over ? total : max;
|
|
104
|
+
|
|
105
|
+
const context = useMemo<MeterContextValue>(
|
|
106
|
+
() => ({ segments, total, max, format, over }),
|
|
107
|
+
[segments, total, max, format, over],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<MeterContext.Provider value={context}>
|
|
112
|
+
<div
|
|
113
|
+
data-slot="meter"
|
|
114
|
+
data-over={over || undefined}
|
|
115
|
+
data-strained={strained || undefined}
|
|
116
|
+
className={cn("flex w-full flex-col gap-1.5", className)}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
<div
|
|
120
|
+
role="meter"
|
|
121
|
+
aria-label={label}
|
|
122
|
+
aria-valuenow={total}
|
|
123
|
+
aria-valuemin={0}
|
|
124
|
+
aria-valuemax={max}
|
|
125
|
+
// Without this a screen reader announces a bare number with no unit
|
|
126
|
+
// and no capacity — "4" tells the reader nothing about 4 of what.
|
|
127
|
+
aria-valuetext={`${format(total)} of ${format(max)}${over ? ", over capacity" : ""}`}
|
|
128
|
+
data-slot="meter-track"
|
|
129
|
+
className="relative h-2 w-full overflow-hidden rounded-full bg-muted"
|
|
130
|
+
>
|
|
131
|
+
<div className="flex h-full w-full">
|
|
132
|
+
{segments.map((segment) => {
|
|
133
|
+
const value = Math.max(0, segment.value);
|
|
134
|
+
// A zero segment renders nothing at all. A one-pixel sliver reads
|
|
135
|
+
// as a small amount rather than as none.
|
|
136
|
+
if (value === 0 || denominator === 0) return null;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div
|
|
140
|
+
key={segment.id}
|
|
141
|
+
data-slot="meter-segment"
|
|
142
|
+
data-tone={segment.tone ?? "primary"}
|
|
143
|
+
className={cn(
|
|
144
|
+
"h-full first:rounded-l-full last:rounded-r-full",
|
|
145
|
+
"transition-[width] duration-[var(--duration-slow)] ease-[var(--ease-out-quint)]",
|
|
146
|
+
TONES[segment.tone ?? "primary"],
|
|
147
|
+
)}
|
|
148
|
+
style={{ width: `${String((value / denominator) * 100)}%` }}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{threshold && max > 0 && threshold.value > 0 && threshold.value < max ? (
|
|
155
|
+
<div
|
|
156
|
+
data-slot="meter-threshold"
|
|
157
|
+
// The marker repeats what the legend states in words, so it is
|
|
158
|
+
// decoration. Announcing it would interrupt the value.
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
className="absolute inset-y-0 w-px bg-foreground/45"
|
|
161
|
+
style={{ left: `${String((threshold.value / denominator) * 100)}%` }}
|
|
162
|
+
/>
|
|
163
|
+
) : null}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{children}
|
|
167
|
+
</div>
|
|
168
|
+
</MeterContext.Provider>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The per-segment breakdown, as text.
|
|
174
|
+
*
|
|
175
|
+
* This is where the detail lives. The bar communicates one number; anyone who
|
|
176
|
+
* needs to know that images account for most of it reads it here.
|
|
177
|
+
*/
|
|
178
|
+
export function MeterLegend({ className, ...props }: ComponentPropsWithRef<"ul">) {
|
|
179
|
+
const { segments, format } = useMeterContext("MeterLegend");
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<ul
|
|
183
|
+
data-slot="meter-legend"
|
|
184
|
+
className={cn("flex flex-wrap gap-x-4 gap-y-1 text-xs", className)}
|
|
185
|
+
{...props}
|
|
186
|
+
>
|
|
187
|
+
{segments.map((segment) => (
|
|
188
|
+
<li key={segment.id} className="flex items-center gap-1.5">
|
|
189
|
+
<span
|
|
190
|
+
aria-hidden="true"
|
|
191
|
+
className={cn("size-2 shrink-0 rounded-full", TONES[segment.tone ?? "primary"])}
|
|
192
|
+
/>
|
|
193
|
+
<span className="text-muted-foreground">{segment.label}</span>
|
|
194
|
+
<span className="tabular-nums">{format(segment.value)}</span>
|
|
195
|
+
</li>
|
|
196
|
+
))}
|
|
197
|
+
</ul>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The headline "4.2 GB of 10 GB" line, with an over-capacity state. */
|
|
202
|
+
export function MeterValue({ className, ...props }: ComponentPropsWithRef<"p">) {
|
|
203
|
+
const { total, max, format, over } = useMeterContext("MeterValue");
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<p
|
|
207
|
+
data-slot="meter-value"
|
|
208
|
+
className={cn(
|
|
209
|
+
"text-xs tabular-nums",
|
|
210
|
+
over ? "text-destructive" : "text-muted-foreground",
|
|
211
|
+
className,
|
|
212
|
+
)}
|
|
213
|
+
{...props}
|
|
214
|
+
>
|
|
215
|
+
{format(total)} of {format(max)}
|
|
216
|
+
{over ? " — over capacity" : null}
|
|
217
|
+
</p>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "metric-delta",
|
|
5
|
+
title: "Metric Delta",
|
|
6
|
+
description: "A headline number with a change indicator that knows which direction is good.",
|
|
7
|
+
category: "data",
|
|
8
|
+
status: "stable",
|
|
9
|
+
dependencies: ["class-variance-authority"],
|
|
10
|
+
registryDependencies: [],
|
|
11
|
+
files: ["metric-delta.tsx"],
|
|
12
|
+
a11y:
|
|
13
|
+
"The meaning is never carried by colour and an arrow alone, which fails WCAG 1.4.1 — the " +
|
|
14
|
+
"direction and the comparison are always stated in text. The tile reads as one sentence " +
|
|
15
|
+
"rather than three fragments: the visible number and delta are aria-hidden and a single " +
|
|
16
|
+
"screen-reader description carries value, direction, comparison and, where given, sample " +
|
|
17
|
+
"size. A zero baseline is announced as having no percentage rather than as an infinite one.",
|
|
18
|
+
});
|