@dowel-ui/react 0.1.1 → 0.2.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 (82) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +187 -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-inline-completion/ai-inline-completion.d.ts +45 -0
  12. package/dist/components/ai-inline-completion/ai-inline-completion.d.ts.map +1 -0
  13. package/dist/components/ai-inline-completion/ai-inline-completion.js +110 -0
  14. package/dist/components/ai-inline-completion/ai-inline-completion.js.map +1 -0
  15. package/dist/components/ai-inline-completion/index.d.ts +2 -0
  16. package/dist/components/ai-inline-completion/index.js +2 -0
  17. package/dist/components/ai-inline-completion/meta.js +17 -0
  18. package/dist/components/ai-inline-completion/meta.js.map +1 -0
  19. package/dist/components/ai-structured-output/ai-structured-output.d.ts +75 -0
  20. package/dist/components/ai-structured-output/ai-structured-output.d.ts.map +1 -0
  21. package/dist/components/ai-structured-output/ai-structured-output.js +126 -0
  22. package/dist/components/ai-structured-output/ai-structured-output.js.map +1 -0
  23. package/dist/components/ai-structured-output/index.d.ts +2 -0
  24. package/dist/components/ai-structured-output/index.js +2 -0
  25. package/dist/components/ai-structured-output/meta.js +17 -0
  26. package/dist/components/ai-structured-output/meta.js.map +1 -0
  27. package/dist/components/ai-tool/ai-tool.d.ts +1 -1
  28. package/dist/components/alert/alert.d.ts +1 -1
  29. package/dist/components/badge/badge.d.ts +1 -1
  30. package/dist/components/button/button.d.ts +1 -1
  31. package/dist/components/meter/index.d.ts +2 -0
  32. package/dist/components/meter/index.js +2 -0
  33. package/dist/components/meter/meta.js +17 -0
  34. package/dist/components/meter/meta.js.map +1 -0
  35. package/dist/components/meter/meter.d.ts +69 -0
  36. package/dist/components/meter/meter.d.ts.map +1 -0
  37. package/dist/components/meter/meter.js +144 -0
  38. package/dist/components/meter/meter.js.map +1 -0
  39. package/dist/components/metric-delta/index.d.ts +2 -0
  40. package/dist/components/metric-delta/index.js +2 -0
  41. package/dist/components/metric-delta/meta.js +17 -0
  42. package/dist/components/metric-delta/meta.js.map +1 -0
  43. package/dist/components/metric-delta/metric-delta.d.ts +59 -0
  44. package/dist/components/metric-delta/metric-delta.d.ts.map +1 -0
  45. package/dist/components/metric-delta/metric-delta.js +82 -0
  46. package/dist/components/metric-delta/metric-delta.js.map +1 -0
  47. package/dist/components/progress/progress.d.ts +1 -1
  48. package/dist/components/record-diff/index.d.ts +2 -0
  49. package/dist/components/record-diff/index.js +2 -0
  50. package/dist/components/record-diff/meta.js +17 -0
  51. package/dist/components/record-diff/meta.js.map +1 -0
  52. package/dist/components/record-diff/record-diff.d.ts +56 -0
  53. package/dist/components/record-diff/record-diff.d.ts.map +1 -0
  54. package/dist/components/record-diff/record-diff.js +199 -0
  55. package/dist/components/record-diff/record-diff.js.map +1 -0
  56. package/dist/components/toast/toast.d.ts +1 -1
  57. package/dist/index.d.ts +13 -1
  58. package/dist/index.js +7 -1
  59. package/dist/registry/components.d.ts.map +1 -1
  60. package/dist/registry/components.js +57 -45
  61. package/dist/registry/components.js.map +1 -1
  62. package/package.json +36 -4
  63. package/src/components/ai-action-ledger/ai-action-ledger.tsx +381 -0
  64. package/src/components/ai-action-ledger/index.ts +14 -0
  65. package/src/components/ai-action-ledger/meta.ts +19 -0
  66. package/src/components/ai-inline-completion/ai-inline-completion.tsx +214 -0
  67. package/src/components/ai-inline-completion/index.ts +1 -0
  68. package/src/components/ai-inline-completion/meta.ts +20 -0
  69. package/src/components/ai-structured-output/ai-structured-output.tsx +244 -0
  70. package/src/components/ai-structured-output/index.ts +10 -0
  71. package/src/components/ai-structured-output/meta.ts +18 -0
  72. package/src/components/meter/index.ts +8 -0
  73. package/src/components/meter/meta.ts +20 -0
  74. package/src/components/meter/meter.tsx +219 -0
  75. package/src/components/metric-delta/index.ts +6 -0
  76. package/src/components/metric-delta/meta.ts +18 -0
  77. package/src/components/metric-delta/metric-delta.tsx +180 -0
  78. package/src/components/record-diff/index.ts +10 -0
  79. package/src/components/record-diff/meta.ts +19 -0
  80. package/src/components/record-diff/record-diff.tsx +341 -0
  81. package/src/index.ts +6 -0
  82. package/src/registry/components.ts +12 -0
@@ -0,0 +1,244 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useMemo,
7
+ type ComponentPropsWithRef,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ import { cn } from "@/lib/utils";
12
+
13
+ /**
14
+ * An object arriving field by field from the model.
15
+ *
16
+ * Most AI features inside real software are not chatbots. They are invoice
17
+ * extraction, CRM enrichment, resume parsing, form autofill — a schema goes in
18
+ * and a filled object comes back. Every one of them streams a partial object
19
+ * into a layout, and almost every one reflows on each token because the fields
20
+ * appear as they arrive.
21
+ *
22
+ * The honest limitation, stated here rather than discovered later: there is no
23
+ * per-field completion signal anywhere in the stack. A streaming API hands you
24
+ * successive partial snapshots, and a string that has stopped growing is
25
+ * indistinguishable from one still arriving. So this component does not pretend
26
+ * to know. It takes the field list up front, reserves the layout from it, and
27
+ * lets the caller say which fields are settled — defaulting to the rule that a
28
+ * field is settled once a later field has appeared, which is true of
29
+ * depth-first JSON streaming and is documented rather than assumed silently.
30
+ */
31
+
32
+ export type FieldState = "pending" | "streaming" | "settled" | "error";
33
+
34
+ export interface OutputField {
35
+ /** Key in the streamed object. */
36
+ name: string;
37
+ label: string;
38
+ /** Rough height reserved before the value arrives, in lines. */
39
+ lines?: number;
40
+ }
41
+
42
+ interface StructuredOutputContextValue {
43
+ fields: OutputField[];
44
+ value: Record<string, unknown>;
45
+ stateOf: (name: string) => FieldState;
46
+ formatValue: (value: unknown, name: string) => string;
47
+ }
48
+
49
+ const StructuredOutputContext = createContext<StructuredOutputContextValue | null>(null);
50
+
51
+ function useStructuredOutputContext(component: string): StructuredOutputContextValue {
52
+ const context = useContext(StructuredOutputContext);
53
+ if (!context) {
54
+ throw new Error(`${component} must be rendered inside <StructuredOutput>.`);
55
+ }
56
+ return context;
57
+ }
58
+
59
+ function defaultFormat(value: unknown): string {
60
+ if (value === null || value === undefined) return "";
61
+ if (typeof value === "string") return value;
62
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
63
+ if (typeof value === "boolean") return value ? "Yes" : "No";
64
+ if (Array.isArray(value)) return value.map(defaultFormat).join(", ");
65
+ return JSON.stringify(value);
66
+ }
67
+
68
+ export interface StructuredOutputProps extends Omit<ComponentPropsWithRef<"dl">, "children"> {
69
+ /** Declared up front, so layout is reserved before anything arrives. */
70
+ fields: OutputField[];
71
+ /** The latest partial snapshot. */
72
+ value: Record<string, unknown>;
73
+ /**
74
+ * True while more of the object is still expected. When false every present
75
+ * field is settled, which is the only completion signal that is ever certain.
76
+ */
77
+ streaming?: boolean;
78
+ /**
79
+ * Fields the caller knows are finished. Supply this when the API gives a
80
+ * real signal; otherwise the default rule applies.
81
+ */
82
+ settled?: string[];
83
+ /** Fields that failed to extract, with the reason shown in place. */
84
+ errors?: Record<string, string>;
85
+ formatValue?: (value: unknown, name: string) => string;
86
+ children?: ReactNode;
87
+ }
88
+
89
+ export function StructuredOutput({
90
+ className,
91
+ fields,
92
+ value,
93
+ streaming = false,
94
+ settled,
95
+ errors,
96
+ formatValue = defaultFormat,
97
+ children,
98
+ ...props
99
+ }: StructuredOutputProps) {
100
+ const stateOf = useMemo(() => {
101
+ const explicit = settled ? new Set(settled) : null;
102
+ const lastPresentIndex = fields.reduce(
103
+ (last, field, index) => (value[field.name] !== undefined ? index : last),
104
+ -1,
105
+ );
106
+
107
+ return (name: string): FieldState => {
108
+ if (errors?.[name]) return "error";
109
+
110
+ const index = fields.findIndex((field) => field.name === name);
111
+ const present = value[name] !== undefined;
112
+
113
+ if (!present) return "pending";
114
+ if (explicit) return explicit.has(name) ? "settled" : "streaming";
115
+ // No explicit signal. Once a later field has appeared this one cannot
116
+ // still be growing, which holds for depth-first JSON streaming. The last
117
+ // present field stays "streaming" until the caller says streaming ended.
118
+ if (!streaming) return "settled";
119
+ return index < lastPresentIndex ? "settled" : "streaming";
120
+ };
121
+ }, [fields, value, streaming, settled, errors]);
122
+
123
+ const context = useMemo<StructuredOutputContextValue>(
124
+ () => ({ fields, value, stateOf, formatValue }),
125
+ [fields, value, stateOf, formatValue],
126
+ );
127
+
128
+ return (
129
+ <StructuredOutputContext.Provider value={context}>
130
+ <dl
131
+ data-slot="structured-output"
132
+ data-streaming={streaming || undefined}
133
+ // Fields arrive one at a time and each is a small, self-contained fact,
134
+ // so the region is polite and additive rather than re-reading the whole
135
+ // object on every token.
136
+ aria-live="polite"
137
+ aria-busy={streaming}
138
+ className={cn("flex flex-col gap-3", className)}
139
+ {...props}
140
+ >
141
+ {children ??
142
+ fields.map((field) => <StructuredField key={field.name} name={field.name} />)}
143
+ </dl>
144
+ </StructuredOutputContext.Provider>
145
+ );
146
+ }
147
+
148
+ export interface StructuredFieldProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
149
+ name: string;
150
+ children?: ReactNode;
151
+ }
152
+
153
+ /**
154
+ * One field: its label, its value, and the space it will occupy before it has
155
+ * one.
156
+ *
157
+ * The reserved height is what stops the layout jumping as the object fills in.
158
+ */
159
+ export function StructuredField({ className, name, children, ...props }: StructuredFieldProps) {
160
+ const { fields, value, stateOf, formatValue } = useStructuredOutputContext("StructuredField");
161
+
162
+ const field = fields.find((candidate) => candidate.name === name);
163
+ if (!field) {
164
+ throw new Error(`StructuredField "${name}" is not declared in the fields prop.`);
165
+ }
166
+
167
+ const state = stateOf(name);
168
+ const raw = value[name];
169
+
170
+ return (
171
+ <div
172
+ data-slot="structured-field"
173
+ data-state={state}
174
+ data-field={name}
175
+ className={cn("flex flex-col gap-1", className)}
176
+ {...props}
177
+ >
178
+ <dt className="text-xs text-muted-foreground">{field.label}</dt>
179
+ <dd
180
+ className={cn(
181
+ "m-0 text-sm",
182
+ state === "pending" && "text-muted-foreground",
183
+ state === "error" && "text-destructive",
184
+ )}
185
+ // Height is reserved from the declared line count so the value lands in
186
+ // space already set aside for it rather than pushing the page down.
187
+ // Computed rather than left to calc() so the reserved height is a plain
188
+ // value that can be asserted and reasoned about.
189
+ style={{ minHeight: `${String((field.lines ?? 1) * 1.25)}rem` }}
190
+ >
191
+ {state === "pending" ? (
192
+ <span
193
+ data-slot="structured-field-placeholder"
194
+ aria-hidden="true"
195
+ className="block h-4 w-24 animate-pulse-soft rounded bg-muted"
196
+ />
197
+ ) : (
198
+ (children ?? formatValue(raw, name))
199
+ )}
200
+ </dd>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ /**
206
+ * A confidence score for one field.
207
+ *
208
+ * Rendered as text with the number, never as a bare colour, because "how much
209
+ * should I trust this" is exactly the thing a colour cannot answer.
210
+ */
211
+ export function StructuredConfidence({
212
+ className,
213
+ value: confidence,
214
+ lowBelow = 0.7,
215
+ ...props
216
+ }: ComponentPropsWithRef<"p"> & { value: number; lowBelow?: number }) {
217
+ const low = confidence < lowBelow;
218
+ const percent = new Intl.NumberFormat(undefined, {
219
+ style: "percent",
220
+ maximumFractionDigits: 0,
221
+ }).format(confidence);
222
+
223
+ return (
224
+ <p
225
+ data-slot="structured-confidence"
226
+ data-low={low || undefined}
227
+ className={cn("text-xs", low ? "text-warning" : "text-muted-foreground", className)}
228
+ {...props}
229
+ >
230
+ {low ? `Low confidence, ${percent} — worth checking` : `${percent} confidence`}
231
+ </p>
232
+ );
233
+ }
234
+
235
+ /** The reason a field could not be extracted, shown where the value would be. */
236
+ export function StructuredFieldError({ className, ...props }: ComponentPropsWithRef<"p">) {
237
+ return (
238
+ <p
239
+ data-slot="structured-field-error"
240
+ className={cn("text-xs text-destructive", className)}
241
+ {...props}
242
+ />
243
+ );
244
+ }
@@ -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,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
+ });