@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,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,8 @@
1
+ export {
2
+ Meter,
3
+ MeterLegend,
4
+ MeterValue,
5
+ type MeterProps,
6
+ type MeterSegment,
7
+ type MeterTone,
8
+ } from "./meter";
@@ -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,6 @@
1
+ export {
2
+ MetricDelta,
3
+ deltaVariants,
4
+ type MetricDeltaProps,
5
+ type MetricPolarity,
6
+ } from "./metric-delta";
@@ -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
+ });