@dowel-ui/react 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +14 -7
  2. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts +50 -0
  3. package/dist/components/ai-agent-plan/ai-agent-plan.d.ts.map +1 -0
  4. package/dist/components/ai-agent-plan/ai-agent-plan.js +156 -0
  5. package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -0
  6. package/dist/components/ai-agent-plan/index.d.ts +2 -0
  7. package/dist/components/ai-agent-plan/index.js +2 -0
  8. package/dist/components/ai-agent-plan/meta.js +17 -0
  9. package/dist/components/ai-agent-plan/meta.js.map +1 -0
  10. package/dist/components/ai-agent-status/ai-agent-status.d.ts +1 -1
  11. package/dist/components/ai-approval-request/ai-approval-request.d.ts +67 -0
  12. package/dist/components/ai-approval-request/ai-approval-request.d.ts.map +1 -0
  13. package/dist/components/ai-approval-request/ai-approval-request.js +218 -0
  14. package/dist/components/ai-approval-request/ai-approval-request.js.map +1 -0
  15. package/dist/components/ai-approval-request/index.d.ts +2 -0
  16. package/dist/components/ai-approval-request/index.js +2 -0
  17. package/dist/components/ai-approval-request/meta.js +17 -0
  18. package/dist/components/ai-approval-request/meta.js.map +1 -0
  19. package/dist/components/ai-disclosure/ai-disclosure.d.ts +92 -0
  20. package/dist/components/ai-disclosure/ai-disclosure.d.ts.map +1 -0
  21. package/dist/components/ai-disclosure/ai-disclosure.js +122 -0
  22. package/dist/components/ai-disclosure/ai-disclosure.js.map +1 -0
  23. package/dist/components/ai-disclosure/index.d.ts +2 -0
  24. package/dist/components/ai-disclosure/index.js +2 -0
  25. package/dist/components/ai-disclosure/meta.js +17 -0
  26. package/dist/components/ai-disclosure/meta.js.map +1 -0
  27. package/dist/components/ai-response/ai-response.d.ts.map +1 -1
  28. package/dist/components/ai-response/ai-response.js +1 -0
  29. package/dist/components/ai-response/ai-response.js.map +1 -1
  30. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -1
  31. package/dist/components/ai-structured-output/ai-structured-output.js +13 -2
  32. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -1
  33. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  34. package/dist/components/alert/alert.d.ts +1 -1
  35. package/dist/components/avatar/avatar.d.ts +1 -1
  36. package/dist/components/badge/badge.d.ts +1 -1
  37. package/dist/components/button/button.d.ts +1 -1
  38. package/dist/components/date-picker/date-picker.js +1 -1
  39. package/dist/components/diff-viewer/diff-model.d.ts +69 -0
  40. package/dist/components/diff-viewer/diff-model.d.ts.map +1 -0
  41. package/dist/components/diff-viewer/diff-model.js +180 -0
  42. package/dist/components/diff-viewer/diff-model.js.map +1 -0
  43. package/dist/components/diff-viewer/diff-viewer.d.ts +42 -0
  44. package/dist/components/diff-viewer/diff-viewer.d.ts.map +1 -0
  45. package/dist/components/diff-viewer/diff-viewer.js +212 -0
  46. package/dist/components/diff-viewer/diff-viewer.js.map +1 -0
  47. package/dist/components/diff-viewer/index.d.ts +3 -0
  48. package/dist/components/diff-viewer/index.js +3 -0
  49. package/dist/components/diff-viewer/meta.js +17 -0
  50. package/dist/components/diff-viewer/meta.js.map +1 -0
  51. package/dist/components/file-upload/file-upload.d.ts +46 -0
  52. package/dist/components/file-upload/file-upload.d.ts.map +1 -0
  53. package/dist/components/file-upload/file-upload.js +204 -0
  54. package/dist/components/file-upload/file-upload.js.map +1 -0
  55. package/dist/components/file-upload/index.d.ts +3 -0
  56. package/dist/components/file-upload/index.js +3 -0
  57. package/dist/components/file-upload/meta.js +17 -0
  58. package/dist/components/file-upload/meta.js.map +1 -0
  59. package/dist/components/file-upload/upload-queue.d.ts +87 -0
  60. package/dist/components/file-upload/upload-queue.d.ts.map +1 -0
  61. package/dist/components/file-upload/upload-queue.js +249 -0
  62. package/dist/components/file-upload/upload-queue.js.map +1 -0
  63. package/dist/components/log-viewer/index.d.ts +3 -0
  64. package/dist/components/log-viewer/index.js +3 -0
  65. package/dist/components/log-viewer/log-stream.d.ts +68 -0
  66. package/dist/components/log-viewer/log-stream.d.ts.map +1 -0
  67. package/dist/components/log-viewer/log-stream.js +120 -0
  68. package/dist/components/log-viewer/log-stream.js.map +1 -0
  69. package/dist/components/log-viewer/log-viewer.d.ts +51 -0
  70. package/dist/components/log-viewer/log-viewer.d.ts.map +1 -0
  71. package/dist/components/log-viewer/log-viewer.js +284 -0
  72. package/dist/components/log-viewer/log-viewer.js.map +1 -0
  73. package/dist/components/log-viewer/meta.js +17 -0
  74. package/dist/components/log-viewer/meta.js.map +1 -0
  75. package/dist/components/progress/progress.d.ts +1 -1
  76. package/dist/components/progress/progress.js +1 -0
  77. package/dist/components/progress/progress.js.map +1 -1
  78. package/dist/components/spinner/spinner.d.ts +1 -1
  79. package/dist/components/spinner/spinner.js +1 -0
  80. package/dist/components/spinner/spinner.js.map +1 -1
  81. package/dist/components/tags-input/index.d.ts +2 -0
  82. package/dist/components/tags-input/index.js +2 -0
  83. package/dist/components/tags-input/meta.js +17 -0
  84. package/dist/components/tags-input/meta.js.map +1 -0
  85. package/dist/components/tags-input/tags-input.d.ts +47 -0
  86. package/dist/components/tags-input/tags-input.d.ts.map +1 -0
  87. package/dist/components/tags-input/tags-input.js +182 -0
  88. package/dist/components/tags-input/tags-input.js.map +1 -0
  89. package/dist/components/time-range-picker/index.d.ts +3 -0
  90. package/dist/components/time-range-picker/index.js +3 -0
  91. package/dist/components/time-range-picker/meta.js +22 -0
  92. package/dist/components/time-range-picker/meta.js.map +1 -0
  93. package/dist/components/time-range-picker/time-expression.d.ts +88 -0
  94. package/dist/components/time-range-picker/time-expression.d.ts.map +1 -0
  95. package/dist/components/time-range-picker/time-expression.js +335 -0
  96. package/dist/components/time-range-picker/time-expression.js.map +1 -0
  97. package/dist/components/time-range-picker/time-range-picker.d.ts +65 -0
  98. package/dist/components/time-range-picker/time-range-picker.d.ts.map +1 -0
  99. package/dist/components/time-range-picker/time-range-picker.js +377 -0
  100. package/dist/components/time-range-picker/time-range-picker.js.map +1 -0
  101. package/dist/components/toast/toast.d.ts +1 -1
  102. package/dist/index.d.ts +21 -1
  103. package/dist/index.js +14 -2
  104. package/dist/registry/components.d.ts.map +1 -1
  105. package/dist/registry/components.js +66 -50
  106. package/dist/registry/components.js.map +1 -1
  107. package/package.json +6 -4
  108. package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
  109. package/src/components/ai-agent-plan/index.ts +10 -0
  110. package/src/components/ai-agent-plan/meta.ts +19 -0
  111. package/src/components/ai-approval-request/ai-approval-request.tsx +368 -0
  112. package/src/components/ai-approval-request/index.ts +7 -0
  113. package/src/components/ai-approval-request/meta.ts +21 -0
  114. package/src/components/ai-disclosure/ai-disclosure.tsx +244 -0
  115. package/src/components/ai-disclosure/index.ts +9 -0
  116. package/src/components/ai-disclosure/meta.ts +23 -0
  117. package/src/components/ai-response/ai-response.tsx +1 -0
  118. package/src/components/ai-structured-output/ai-structured-output.tsx +25 -1
  119. package/src/components/diff-viewer/diff-model.ts +256 -0
  120. package/src/components/diff-viewer/diff-viewer.tsx +339 -0
  121. package/src/components/diff-viewer/index.ts +17 -0
  122. package/src/components/diff-viewer/meta.ts +20 -0
  123. package/src/components/file-upload/file-upload.tsx +322 -0
  124. package/src/components/file-upload/index.ts +20 -0
  125. package/src/components/file-upload/meta.ts +21 -0
  126. package/src/components/file-upload/upload-queue.ts +337 -0
  127. package/src/components/log-viewer/index.ts +21 -0
  128. package/src/components/log-viewer/log-stream.ts +176 -0
  129. package/src/components/log-viewer/log-viewer.tsx +424 -0
  130. package/src/components/log-viewer/meta.ts +21 -0
  131. package/src/components/progress/progress.tsx +3 -0
  132. package/src/components/spinner/spinner.tsx +5 -0
  133. package/src/components/tags-input/index.ts +1 -0
  134. package/src/components/tags-input/meta.ts +23 -0
  135. package/src/components/tags-input/tags-input.tsx +300 -0
  136. package/src/components/time-range-picker/index.ts +25 -0
  137. package/src/components/time-range-picker/meta.ts +22 -0
  138. package/src/components/time-range-picker/time-expression.ts +491 -0
  139. package/src/components/time-range-picker/time-range-picker.tsx +522 -0
  140. package/src/index.ts +8 -0
  141. package/src/registry/components.ts +16 -0
@@ -0,0 +1,424 @@
1
+ "use client";
2
+
3
+ import { useVirtualizer } from "@tanstack/react-virtual";
4
+ import {
5
+ useEffect,
6
+ useId,
7
+ useRef,
8
+ useState,
9
+ type ComponentPropsWithRef,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ import { disabledStyles, focusRing } from "@/lib/styles";
14
+ import { cn } from "@/lib/utils";
15
+
16
+ import { LOG_LEVELS, segment, type LogLevel, type VisibleLine } from "./log-stream";
17
+
18
+ /**
19
+ * A streaming console: filter, follow, expand.
20
+ *
21
+ * The incumbent is react-lazylog, which was last published in 2022, is built on
22
+ * react-virtualized, and cannot run on React 19 — while still taking about
23
+ * 15,000 downloads a week. This is for those people.
24
+ *
25
+ * Two accessibility decisions worth stating rather than discovering.
26
+ *
27
+ * `role="log"` implies `aria-live="polite"`, which is right for a handful of
28
+ * events and catastrophic for a console: a screen reader would read every line
29
+ * of a build as it scrolls past, and nothing else would be audible. Announcing
30
+ * is therefore off by default and opt-in through `announce`. The role stays,
31
+ * because it still describes what the region is.
32
+ *
33
+ * And virtualization means most rows are not in the DOM. That is a real trade,
34
+ * not an implementation detail: assistive technology cannot reach what is not
35
+ * rendered, so a reader who needs the whole log needs an escape — which is what
36
+ * `onDownload` is for. A log viewer with no way out of the virtual window is
37
+ * not accessible however good its ARIA is.
38
+ */
39
+
40
+ const LEVEL_STYLES: Record<LogLevel, string> = {
41
+ trace: "text-muted-foreground",
42
+ debug: "text-muted-foreground",
43
+ info: "text-foreground",
44
+ warn: "text-warning",
45
+ error: "text-destructive",
46
+ fatal: "text-destructive font-semibold",
47
+ };
48
+
49
+ /** Distance from the bottom, in pixels, that still counts as "at the end". */
50
+ const FOLLOW_THRESHOLD = 24;
51
+
52
+ export interface LogViewerProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
53
+ lines: VisibleLine[];
54
+ /** Names the region. */
55
+ label: string;
56
+ /** Row height in pixels. Rows are one line; expansion is measured. */
57
+ rowHeight?: number;
58
+ height?: number | string;
59
+ /**
60
+ * Read new lines aloud. Off by default — a console that announces every line
61
+ * makes a screen reader useless for anything else.
62
+ */
63
+ announce?: boolean;
64
+ /** The way out of the virtual window, for anyone who needs the whole log. */
65
+ onDownload?: () => void;
66
+ children?: ReactNode;
67
+ }
68
+
69
+ export function LogViewer({
70
+ className,
71
+ lines,
72
+ label,
73
+ rowHeight = 22,
74
+ height = 360,
75
+ announce = false,
76
+ onDownload,
77
+ children,
78
+ ...props
79
+ }: LogViewerProps) {
80
+ const scrollRef = useRef<HTMLDivElement | null>(null);
81
+ const [following, setFollowing] = useState(true);
82
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
83
+ const labelId = useId();
84
+
85
+ const virtualizer = useVirtualizer({
86
+ count: lines.length,
87
+ getScrollElement: () => scrollRef.current,
88
+ estimateSize: () => rowHeight,
89
+ overscan: 12,
90
+ // Without a starting rect the virtualizer renders nothing until a
91
+ // ResizeObserver fires, so the first paint is an empty box. Seeding it from
92
+ // the declared height means rows are there immediately, and it is measured
93
+ // properly a moment later.
94
+ initialRect: { width: 0, height: typeof height === "number" ? height : 360 },
95
+ });
96
+
97
+ // Follow mode, hand-written rather than delegated. A spring-anchoring library
98
+ // wants to own the scroll container and so does the virtualizer, and the two
99
+ // fight; this is scrollToIndex on append plus a proximity check.
100
+ useEffect(() => {
101
+ if (!following || lines.length === 0) return;
102
+ virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
103
+ }, [following, lines.length, virtualizer]);
104
+
105
+ function handleScroll() {
106
+ const element = scrollRef.current;
107
+ if (!element) return;
108
+
109
+ const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
110
+ // Scrolling up detaches, because the reader is looking at something and
111
+ // yanking them back to the tail would lose it. Returning to the bottom
112
+ // re-attaches, which is the gesture people already expect.
113
+ setFollowing(distance <= FOLLOW_THRESHOLD);
114
+ }
115
+
116
+ function toggleExpanded(id: string) {
117
+ setExpanded((current) => {
118
+ const next = new Set(current);
119
+ if (next.has(id)) next.delete(id);
120
+ else next.add(id);
121
+ return next;
122
+ });
123
+ }
124
+
125
+ const items = virtualizer.getVirtualItems();
126
+
127
+ return (
128
+ <div data-slot="log-viewer" className={cn("flex flex-col gap-2", className)} {...props}>
129
+ <div className="flex flex-wrap items-center justify-between gap-2">
130
+ <span id={labelId} className="text-sm font-medium">
131
+ {label}
132
+ </span>
133
+ <div className="flex items-center gap-2">
134
+ {!following ? (
135
+ <button
136
+ type="button"
137
+ data-slot="log-viewer-jump"
138
+ onClick={() => {
139
+ setFollowing(true);
140
+ }}
141
+ className={cn(
142
+ "rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
143
+ "transition-colors hover:bg-accent hover:text-accent-foreground",
144
+ focusRing,
145
+ )}
146
+ >
147
+ Jump to latest
148
+ </button>
149
+ ) : (
150
+ <span className="text-xs text-muted-foreground">Following</span>
151
+ )}
152
+ {onDownload ? (
153
+ <button
154
+ type="button"
155
+ data-slot="log-viewer-download"
156
+ onClick={onDownload}
157
+ className={cn(
158
+ "rounded-md border border-input bg-background px-2 py-0.5 text-xs font-medium",
159
+ "transition-colors hover:bg-accent hover:text-accent-foreground",
160
+ focusRing,
161
+ )}
162
+ >
163
+ Download full log
164
+ </button>
165
+ ) : null}
166
+ </div>
167
+ </div>
168
+
169
+ {children}
170
+
171
+ <div
172
+ ref={scrollRef}
173
+ onScroll={handleScroll}
174
+ role="log"
175
+ aria-labelledby={labelId}
176
+ // Explicitly off unless asked for: role="log" implies polite, and a
177
+ // console that reads every line aloud drowns out everything else.
178
+ aria-live={announce ? "polite" : "off"}
179
+ // A scrollable region must be focusable, or its content is unreachable
180
+ // without a pointer — WCAG 2.1.1, and axe's scrollable-region-focusable
181
+ // requires exactly this. jsx-a11y's heuristic cannot see that this
182
+ // element scrolls, so here the requirement outranks the rule.
183
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
184
+ tabIndex={0}
185
+ style={{ height }}
186
+ className={cn(
187
+ "overflow-auto rounded-lg border border-border bg-muted/30 font-mono text-xs",
188
+ focusRing,
189
+ )}
190
+ >
191
+ {lines.length === 0 ? (
192
+ <p className="p-3 text-muted-foreground">No lines match the current filter.</p>
193
+ ) : (
194
+ <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
195
+ {items.map((item) => {
196
+ const line = lines[item.index];
197
+ if (!line) return null;
198
+
199
+ return (
200
+ <div
201
+ key={line.id}
202
+ ref={virtualizer.measureElement}
203
+ data-index={item.index}
204
+ data-slot="log-viewer-row"
205
+ data-level={line.level}
206
+ className="absolute top-0 left-0 w-full"
207
+ style={{ transform: `translateY(${String(item.start)}px)` }}
208
+ >
209
+ <LogViewerRow
210
+ line={line}
211
+ expanded={expanded.has(line.id)}
212
+ onToggle={() => {
213
+ toggleExpanded(line.id);
214
+ }}
215
+ />
216
+ </div>
217
+ );
218
+ })}
219
+ </div>
220
+ )}
221
+ </div>
222
+ </div>
223
+ );
224
+ }
225
+
226
+ export interface LogViewerRowProps {
227
+ line: VisibleLine;
228
+ expanded?: boolean;
229
+ onToggle?: () => void;
230
+ }
231
+
232
+ /**
233
+ * One line.
234
+ *
235
+ * Exported because the virtualized container cannot be measured outside a real
236
+ * browser — jsdom reports every box as zero — so the row's behaviour would
237
+ * otherwise be untestable. It is also genuinely useful on its own for anyone
238
+ * rendering a short log without virtualization.
239
+ */
240
+ export function LogViewerRow({ line, expanded = false, onToggle }: LogViewerRowProps) {
241
+ const hasFields = line.fields !== undefined && Object.keys(line.fields).length > 0;
242
+ const parts = segment(line.message, line.matches);
243
+
244
+ return (
245
+ <div className="px-3 py-0.5 hover:bg-accent/40">
246
+ <div className="flex items-baseline gap-2">
247
+ {line.timestamp ? (
248
+ <time className="shrink-0 text-muted-foreground tabular-nums">{line.timestamp}</time>
249
+ ) : null}
250
+
251
+ {line.level ? (
252
+ // The level in text, not a coloured bar. Colour alone cannot
253
+ // distinguish warn from error for everyone reading.
254
+ <span className={cn("w-11 shrink-0 uppercase", LEVEL_STYLES[line.level])}>
255
+ {line.level}
256
+ </span>
257
+ ) : null}
258
+
259
+ <span
260
+ className={cn(
261
+ "min-w-0 flex-1 break-all whitespace-pre-wrap",
262
+ line.level && LEVEL_STYLES[line.level],
263
+ )}
264
+ >
265
+ {parts.map((part, index) =>
266
+ part.match ? (
267
+ // A mark element, so the match is conveyed structurally rather
268
+ // than only as a background colour.
269
+ <mark key={index} className="rounded-[2px] bg-warning/35 text-inherit">
270
+ {part.text}
271
+ </mark>
272
+ ) : (
273
+ <span key={index}>{part.text}</span>
274
+ ),
275
+ )}
276
+ </span>
277
+
278
+ {hasFields ? (
279
+ <button
280
+ type="button"
281
+ data-slot="log-viewer-expand"
282
+ aria-expanded={expanded}
283
+ onClick={onToggle}
284
+ className={cn(
285
+ "shrink-0 rounded px-1 text-2xs text-muted-foreground",
286
+ "transition-colors hover:text-foreground",
287
+ focusRing,
288
+ disabledStyles,
289
+ )}
290
+ >
291
+ {expanded ? "Hide fields" : "Fields"}
292
+ </button>
293
+ ) : null}
294
+ </div>
295
+
296
+ {expanded && line.fields ? (
297
+ <dl
298
+ data-slot="log-viewer-fields"
299
+ className="mt-1 mb-1 ml-4 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 border-l border-border pl-3"
300
+ >
301
+ {Object.entries(line.fields).map(([key, value]) => (
302
+ <div key={key} className="contents">
303
+ <dt className="text-muted-foreground">{key}</dt>
304
+ <dd className="m-0 break-all">
305
+ {typeof value === "string" ? value : JSON.stringify(value)}
306
+ </dd>
307
+ </div>
308
+ ))}
309
+ </dl>
310
+ ) : null}
311
+ </div>
312
+ );
313
+ }
314
+
315
+ export interface LogViewerToolbarProps extends Omit<ComponentPropsWithRef<"div">, "onChange"> {
316
+ query: string;
317
+ onQueryChange: (query: string) => void;
318
+ regex: boolean;
319
+ onRegexChange: (regex: boolean) => void;
320
+ levels: Set<LogLevel>;
321
+ onToggleLevel: (level: LogLevel) => void;
322
+ counts: Map<LogLevel, number>;
323
+ invalidPattern?: boolean;
324
+ /** Shown as "N of M lines". */
325
+ showing: number;
326
+ total: number;
327
+ }
328
+
329
+ export function LogViewerToolbar({
330
+ className,
331
+ query,
332
+ onQueryChange,
333
+ regex,
334
+ onRegexChange,
335
+ levels,
336
+ onToggleLevel,
337
+ counts,
338
+ invalidPattern = false,
339
+ showing,
340
+ total,
341
+ ...props
342
+ }: LogViewerToolbarProps) {
343
+ const filterId = useId();
344
+ const errorId = useId();
345
+
346
+ return (
347
+ <div
348
+ data-slot="log-viewer-toolbar"
349
+ className={cn("flex flex-wrap items-center gap-2", className)}
350
+ {...props}
351
+ >
352
+ <div className="flex min-w-0 flex-1 items-center gap-2">
353
+ <label htmlFor={filterId} className="sr-only">
354
+ Filter log
355
+ </label>
356
+ <input
357
+ id={filterId}
358
+ type="search"
359
+ value={query}
360
+ placeholder={regex ? "Pattern…" : "Filter…"}
361
+ aria-invalid={invalidPattern || undefined}
362
+ aria-describedby={invalidPattern ? errorId : undefined}
363
+ onChange={(event) => {
364
+ onQueryChange(event.target.value);
365
+ }}
366
+ className={cn(
367
+ "min-w-32 flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm",
368
+ invalidPattern && "border-destructive",
369
+ focusRing,
370
+ )}
371
+ />
372
+ <label className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
373
+ <input
374
+ type="checkbox"
375
+ checked={regex}
376
+ onChange={(event) => {
377
+ onRegexChange(event.target.checked);
378
+ }}
379
+ className={cn("size-3.5 rounded border-input", focusRing)}
380
+ />
381
+ Regex
382
+ </label>
383
+ </div>
384
+
385
+ <div className="flex flex-wrap items-center gap-1">
386
+ {LOG_LEVELS.filter((level) => counts.has(level)).map((level) => {
387
+ const on = levels.size === 0 || levels.has(level);
388
+ return (
389
+ <button
390
+ key={level}
391
+ type="button"
392
+ data-slot="log-viewer-facet"
393
+ aria-pressed={on}
394
+ onClick={() => {
395
+ onToggleLevel(level);
396
+ }}
397
+ className={cn(
398
+ "rounded-md border px-1.5 py-0.5 font-mono text-2xs uppercase transition-colors",
399
+ on
400
+ ? "border-transparent bg-secondary text-secondary-foreground"
401
+ : "border-input bg-background text-muted-foreground line-through",
402
+ focusRing,
403
+ )}
404
+ >
405
+ {level} {counts.get(level) ?? 0}
406
+ </button>
407
+ );
408
+ })}
409
+ </div>
410
+
411
+ <p className="w-full text-xs text-muted-foreground tabular-nums">
412
+ {invalidPattern ? (
413
+ // Said out loud rather than shown as an empty log, which reads as
414
+ // "nothing matched" and sends the reader looking for the wrong thing.
415
+ <span id={errorId} className="text-destructive">
416
+ Incomplete pattern — showing everything until it is valid
417
+ </span>
418
+ ) : (
419
+ `${String(showing)} of ${String(total)} lines`
420
+ )}
421
+ </p>
422
+ </div>
423
+ );
424
+ }
@@ -0,0 +1,21 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "log-viewer",
5
+ title: "Log Viewer",
6
+ description: "A streaming console with filtering, level facets and follow mode.",
7
+ category: "data",
8
+ status: "stable",
9
+ dependencies: ["@tanstack/react-virtual"],
10
+ registryDependencies: [],
11
+ files: ["log-stream.ts", "log-viewer.tsx"],
12
+ a11y:
13
+ 'role="log" implies aria-live="polite", which is right for a few events and unusable for a ' +
14
+ "console — a screen reader would read every line of a build and nothing else would be " +
15
+ "audible. Announcing is off by default and opt-in. Virtualization is the harder trade: most " +
16
+ "rows are not in the DOM, so assistive technology cannot reach them, which is why the " +
17
+ "component takes an onDownload escape rather than pretending the virtual window is the whole " +
18
+ "log. Levels are written in text as well as coloured, matches use mark elements rather than " +
19
+ "a background colour, and an invalid pattern is announced instead of silently showing an " +
20
+ 'empty log that reads as "nothing matched".',
21
+ });
@@ -52,6 +52,9 @@ export function Progress({ className, size, tone, value, ...props }: ProgressPro
52
52
  >
53
53
  <ProgressPrimitive.Indicator
54
54
  data-slot="progress-indicator"
55
+ // Only while indeterminate: the sweep is the signal that work is
56
+ // ongoing, and freezing it reads as a stalled zero percent.
57
+ data-motion={indeterminate ? "indicator" : undefined}
55
58
  className={cn(
56
59
  "h-full w-full flex-1 rounded-full bg-[var(--progress-fill)]",
57
60
  "transition-transform duration-[var(--duration-slow)] ease-[var(--ease-out-quint)]",
@@ -38,6 +38,11 @@ export function Spinner({ className, size, label, ...props }: SpinnerProps) {
38
38
  viewBox="0 0 24 24"
39
39
  fill="none"
40
40
  aria-hidden="true"
41
+ // Exempt from the reduced-motion blanket: this is not decoration. A
42
+ // spinner that stops turning reports that the application has hung,
43
+ // which is a worse experience than the motion it was avoiding. It is
44
+ // slowed instead — see --motion-scale-indicator.
45
+ data-motion="indicator"
41
46
  className={cn(spinnerVariants({ size }), className)}
42
47
  {...props}
43
48
  >
@@ -0,0 +1 @@
1
+ export { TagsInput, type TagValidator, type TagsInputProps } from "./tags-input";
@@ -0,0 +1,23 @@
1
+ import { defineMeta } from "@/registry/schema";
2
+
3
+ export const meta = defineMeta({
4
+ name: "tags-input",
5
+ title: "Tags Input",
6
+ description:
7
+ "A list of short values, where invalid entries stay visible instead of vanishing.",
8
+ category: "form",
9
+ status: "stable",
10
+ dependencies: [],
11
+ registryDependencies: [],
12
+ files: ["tags-input.tsx"],
13
+ a11y:
14
+ "No WAI-ARIA pattern covers a token field, and the near misses misdescribe it — these are " +
15
+ "committed values, not options being chosen, so calling the container a listbox would promise " +
16
+ "navigation that does not exist. It is a labelled group holding a list, with a remove button " +
17
+ "per token carrying the tag in its accessible name. An invalid token's reason is inside the " +
18
+ "token, so it is announced with it rather than as a detached error. Additions, removals and " +
19
+ "refusals all reach a polite live region, because a refusal that looks like nothing happening " +
20
+ "is the failure this component exists to prevent. Remove buttons sit in the natural tab order " +
21
+ "rather than behind a roving tabindex: it costs a tab stop per token, and buys a pattern every " +
22
+ "assistive technology already understands.",
23
+ });