@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,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import type { ComponentPropsWithRef } from "react";
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A headline number with an honest change indicator.
|
|
10
|
+
*
|
|
11
|
+
* The visual is a commodity; the correctness is not. Three things go wrong in
|
|
12
|
+
* almost every hand-rolled version, and all three are handled here:
|
|
13
|
+
*
|
|
14
|
+
* 1. Direction is assumed to be good news. Rising churn, rising latency and
|
|
15
|
+
* rising cost are all bad, and painting them green is worse than showing no
|
|
16
|
+
* colour at all. `polarity` decides.
|
|
17
|
+
* 2. A zero baseline produces `+∞%` or `NaN`. There is no percentage change
|
|
18
|
+
* from nothing, so this says so in words instead of inventing one.
|
|
19
|
+
* 3. Meaning is carried by colour and an arrow alone, which fails WCAG 1.4.1
|
|
20
|
+
* and is unreadable to anyone who does not already know the convention. The
|
|
21
|
+
* direction is always stated in text.
|
|
22
|
+
*
|
|
23
|
+
* What this deliberately does NOT do is claim statistical significance. A
|
|
24
|
+
* component holding two numbers cannot know whether a change is significant —
|
|
25
|
+
* that needs the sample size and a test. `insignificantBelow` is a presentation
|
|
26
|
+
* threshold and is described as one; passing `sampleSize` only adds it to the
|
|
27
|
+
* accessible description, it does not license a stronger claim.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type MetricPolarity = "higher-is-better" | "lower-is-better" | "neutral";
|
|
31
|
+
|
|
32
|
+
const deltaVariants = cva("inline-flex items-center gap-1 text-xs font-medium tabular-nums", {
|
|
33
|
+
variants: {
|
|
34
|
+
sentiment: {
|
|
35
|
+
good: "text-success",
|
|
36
|
+
bad: "text-destructive",
|
|
37
|
+
neutral: "text-muted-foreground",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
defaultVariants: { sentiment: "neutral" },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** What the change means, once polarity is applied. */
|
|
44
|
+
function sentimentOf(
|
|
45
|
+
change: number,
|
|
46
|
+
polarity: MetricPolarity,
|
|
47
|
+
meaningful: boolean,
|
|
48
|
+
): "good" | "bad" | "neutral" {
|
|
49
|
+
if (!meaningful || change === 0 || polarity === "neutral") return "neutral";
|
|
50
|
+
const improved = polarity === "higher-is-better" ? change > 0 : change < 0;
|
|
51
|
+
return improved ? "good" : "bad";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MetricDeltaProps
|
|
55
|
+
extends Omit<ComponentPropsWithRef<"div">, "children">, VariantProps<typeof deltaVariants> {
|
|
56
|
+
/** The current value. */
|
|
57
|
+
value: number;
|
|
58
|
+
/** The value being compared against. Omit when there is nothing to compare. */
|
|
59
|
+
previous?: number;
|
|
60
|
+
/** Names the metric. */
|
|
61
|
+
label: string;
|
|
62
|
+
/**
|
|
63
|
+
* Whether a rise is good news. Churn, latency, error rate and cost are all
|
|
64
|
+
* `lower-is-better`, and getting this wrong paints bad news green.
|
|
65
|
+
*/
|
|
66
|
+
polarity?: MetricPolarity;
|
|
67
|
+
/**
|
|
68
|
+
* Relative change below which the delta is shown without sentiment, as a
|
|
69
|
+
* fraction. Presentation only — this is not a significance test.
|
|
70
|
+
*/
|
|
71
|
+
insignificantBelow?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Included in the accessible description when given, so a reader can judge
|
|
74
|
+
* the change for themselves. Never used to compute significance.
|
|
75
|
+
*/
|
|
76
|
+
sampleSize?: number;
|
|
77
|
+
/** What the previous value represents, e.g. "vs last week". */
|
|
78
|
+
comparisonLabel?: string;
|
|
79
|
+
/** Formats the headline value. */
|
|
80
|
+
format?: (value: number) => string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function defaultFormat(value: number): string {
|
|
84
|
+
return new Intl.NumberFormat().format(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const percentFormatter = new Intl.NumberFormat(undefined, {
|
|
88
|
+
style: "percent",
|
|
89
|
+
maximumFractionDigits: 1,
|
|
90
|
+
signDisplay: "exceptZero",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export function MetricDelta({
|
|
94
|
+
className,
|
|
95
|
+
value,
|
|
96
|
+
previous,
|
|
97
|
+
label,
|
|
98
|
+
polarity = "higher-is-better",
|
|
99
|
+
insignificantBelow = 0,
|
|
100
|
+
sampleSize,
|
|
101
|
+
comparisonLabel = "vs previous period",
|
|
102
|
+
format = defaultFormat,
|
|
103
|
+
...props
|
|
104
|
+
}: MetricDeltaProps) {
|
|
105
|
+
const hasComparison = previous !== undefined;
|
|
106
|
+
const absoluteChange = hasComparison ? value - previous : 0;
|
|
107
|
+
|
|
108
|
+
// A percentage change from zero is undefined, not infinite. Reporting it as
|
|
109
|
+
// a number at all is the lie; the component says "no prior value" instead.
|
|
110
|
+
const canComputeRelative = hasComparison && previous !== 0;
|
|
111
|
+
const relativeChange = canComputeRelative ? absoluteChange / Math.abs(previous) : null;
|
|
112
|
+
|
|
113
|
+
const meaningful =
|
|
114
|
+
relativeChange === null
|
|
115
|
+
? absoluteChange !== 0
|
|
116
|
+
: Math.abs(relativeChange) >= insignificantBelow;
|
|
117
|
+
|
|
118
|
+
const sentiment = hasComparison
|
|
119
|
+
? sentimentOf(absoluteChange, polarity, meaningful)
|
|
120
|
+
: "neutral";
|
|
121
|
+
|
|
122
|
+
const direction = absoluteChange > 0 ? "up" : absoluteChange < 0 ? "down" : "flat";
|
|
123
|
+
|
|
124
|
+
const changeText = !hasComparison
|
|
125
|
+
? null
|
|
126
|
+
: relativeChange !== null
|
|
127
|
+
? percentFormatter.format(relativeChange)
|
|
128
|
+
: `${absoluteChange > 0 ? "+" : ""}${format(absoluteChange)}`;
|
|
129
|
+
|
|
130
|
+
// Everything a screen reader needs, in one sentence, in the order it matters.
|
|
131
|
+
const description = [
|
|
132
|
+
`${label}: ${format(value)}`,
|
|
133
|
+
hasComparison
|
|
134
|
+
? relativeChange !== null
|
|
135
|
+
? `${direction === "flat" ? "unchanged" : direction} ${changeText ?? ""} ${comparisonLabel}`
|
|
136
|
+
: `${direction === "flat" ? "unchanged" : direction} by ${format(Math.abs(absoluteChange))} ${comparisonLabel}, no percentage available from a zero baseline`
|
|
137
|
+
: null,
|
|
138
|
+
hasComparison && !meaningful && direction !== "flat" ? "not a meaningful change" : null,
|
|
139
|
+
sampleSize !== undefined ? `sample size ${format(sampleSize)}` : null,
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join(", ");
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
data-slot="metric-delta"
|
|
147
|
+
data-direction={direction}
|
|
148
|
+
data-sentiment={sentiment}
|
|
149
|
+
className={cn("flex flex-col gap-1", className)}
|
|
150
|
+
{...props}
|
|
151
|
+
>
|
|
152
|
+
<p className="text-xs text-muted-foreground">{label}</p>
|
|
153
|
+
|
|
154
|
+
{/* One accessible sentence for the whole tile. The parts below are hidden
|
|
155
|
+
so the reader gets the meaning once, not as three fragments. */}
|
|
156
|
+
<p className="sr-only">{description}</p>
|
|
157
|
+
|
|
158
|
+
<p
|
|
159
|
+
aria-hidden="true"
|
|
160
|
+
className="text-2xl leading-tight font-semibold tracking-tight tabular-nums"
|
|
161
|
+
>
|
|
162
|
+
{format(value)}
|
|
163
|
+
</p>
|
|
164
|
+
|
|
165
|
+
{hasComparison ? (
|
|
166
|
+
<p aria-hidden="true" className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
|
167
|
+
<span className={cn(deltaVariants({ sentiment }))} data-slot="metric-delta-change">
|
|
168
|
+
<span aria-hidden="true">
|
|
169
|
+
{direction === "up" ? "↑" : direction === "down" ? "↓" : "→"}
|
|
170
|
+
</span>
|
|
171
|
+
{changeText}
|
|
172
|
+
</span>
|
|
173
|
+
<span className="text-xs text-muted-foreground">{comparisonLabel}</span>
|
|
174
|
+
</p>
|
|
175
|
+
) : null}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { deltaVariants };
|
|
@@ -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)]",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineMeta } from "@/registry/schema";
|
|
2
|
+
|
|
3
|
+
export const meta = defineMeta({
|
|
4
|
+
name: "record-diff",
|
|
5
|
+
title: "Record Diff",
|
|
6
|
+
description:
|
|
7
|
+
"Field-level before and after, for audit entries, settings history and revisions.",
|
|
8
|
+
category: "data",
|
|
9
|
+
status: "stable",
|
|
10
|
+
dependencies: [],
|
|
11
|
+
registryDependencies: [],
|
|
12
|
+
files: ["record-diff.tsx"],
|
|
13
|
+
a11y:
|
|
14
|
+
'A real table, not a grid of divs. Each field name is a th with scope="row", so a value is ' +
|
|
15
|
+
"announced with the field it belongs to rather than as a loose cell. The kind of change is " +
|
|
16
|
+
"never carried by row colour alone — every row states added, removed or changed in text for " +
|
|
17
|
+
"screen readers, satisfying WCAG 1.4.1. The unchanged-field toggle is a real button with " +
|
|
18
|
+
"aria-expanded. Redacted values never reach the DOM at all.",
|
|
19
|
+
});
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type ComponentPropsWithRef,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
import { cn } from "@/lib/utils";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What changed about a record, field by field.
|
|
15
|
+
*
|
|
16
|
+
* Every diff package on npm compares lines of text. That is the wrong shape for
|
|
17
|
+
* an audit entry, a settings revision or a config history, where the change is
|
|
18
|
+
* "role went from viewer to admin" and rendering it as two lines of JSON makes
|
|
19
|
+
* the reader do the comparison themselves.
|
|
20
|
+
*
|
|
21
|
+
* Scope is deliberately flat records. Nested objects and arrays are formatted as
|
|
22
|
+
* values rather than recursed into, because a recursive differ is a different
|
|
23
|
+
* and much larger component, and pretending otherwise would produce a shallow
|
|
24
|
+
* one that quietly lies about deep changes. `formatValue` is the escape hatch.
|
|
25
|
+
*
|
|
26
|
+
* Redaction is by key pattern and applied before formatting, so a secret is
|
|
27
|
+
* never handed to a formatter that might log it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export type FieldChange = "added" | "removed" | "changed" | "unchanged";
|
|
31
|
+
|
|
32
|
+
export interface RecordField {
|
|
33
|
+
key: string;
|
|
34
|
+
label: string;
|
|
35
|
+
change: FieldChange;
|
|
36
|
+
before: unknown;
|
|
37
|
+
after: unknown;
|
|
38
|
+
redacted: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Values that read as absent, so "" → null is not reported as a change. */
|
|
42
|
+
function isEmpty(value: unknown): boolean {
|
|
43
|
+
return value === null || value === undefined || value === "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function classify(before: unknown, after: unknown): FieldChange {
|
|
47
|
+
const hadBefore = !isEmpty(before);
|
|
48
|
+
const hasAfter = !isEmpty(after);
|
|
49
|
+
|
|
50
|
+
if (!hadBefore && hasAfter) return "added";
|
|
51
|
+
if (hadBefore && !hasAfter) return "removed";
|
|
52
|
+
// Both absent. Falling through would compare "" against null, which
|
|
53
|
+
// serialise differently and would report a change that did not happen.
|
|
54
|
+
if (!hadBefore && !hasAfter) return "unchanged";
|
|
55
|
+
// Structural comparison, so {a:1} and {a:1} are equal. Key order differences
|
|
56
|
+
// would report a false change, so keys are sorted before comparing.
|
|
57
|
+
return stableStringify(before) === stableStringify(after) ? "unchanged" : "changed";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stableStringify(value: unknown): string {
|
|
61
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "";
|
|
62
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
63
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
64
|
+
a.localeCompare(b),
|
|
65
|
+
);
|
|
66
|
+
return `{${entries.map(([k, v]) => `${k}:${stableStringify(v)}`).join(",")}}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultFormatValue(value: unknown): string {
|
|
70
|
+
if (value === null || value === undefined) return "—";
|
|
71
|
+
if (value === "") return "empty";
|
|
72
|
+
if (typeof value === "string") return value;
|
|
73
|
+
if (typeof value === "boolean") return value ? "yes" : "no";
|
|
74
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value.length === 0 ? "none" : value.map(defaultFormatValue).join(", ");
|
|
77
|
+
}
|
|
78
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
79
|
+
// Symbols and functions have no sensible representation as a field value, and
|
|
80
|
+
// String() would yield "[object Object]" or a function body. Say what it is.
|
|
81
|
+
return `[${typeof value}]`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Keys whose values must never be rendered, however the record was built. */
|
|
85
|
+
const DEFAULT_REDACT = /password|secret|token|api[-_]?key|authorization|credential/i;
|
|
86
|
+
|
|
87
|
+
export interface RecordDiffProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
88
|
+
before: Record<string, unknown>;
|
|
89
|
+
after: Record<string, unknown>;
|
|
90
|
+
/** Overrides the humanised key. */
|
|
91
|
+
labels?: Record<string, string>;
|
|
92
|
+
/** Keys to render as redacted. Defaults to common secret-shaped names. */
|
|
93
|
+
redact?: RegExp;
|
|
94
|
+
/** Only these keys, in this order. Anything else is ignored. */
|
|
95
|
+
fields?: string[];
|
|
96
|
+
/** Hides unchanged fields behind a toggle. */
|
|
97
|
+
collapseUnchanged?: boolean;
|
|
98
|
+
formatValue?: (value: unknown, key: string) => string;
|
|
99
|
+
children?: React.ReactNode;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface RecordDiffContextValue {
|
|
103
|
+
fields: RecordField[];
|
|
104
|
+
changedCount: number;
|
|
105
|
+
formatValue: (value: unknown, key: string) => string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const RecordDiffContext = createContext<RecordDiffContextValue | null>(null);
|
|
109
|
+
|
|
110
|
+
function useRecordDiffContext(component: string): RecordDiffContextValue {
|
|
111
|
+
const context = useContext(RecordDiffContext);
|
|
112
|
+
if (!context) {
|
|
113
|
+
throw new Error(`${component} must be rendered inside <RecordDiff>.`);
|
|
114
|
+
}
|
|
115
|
+
return context;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** "billingEmail" and "billing_email" both read as "Billing email". */
|
|
119
|
+
function humanise(key: string): string {
|
|
120
|
+
const spaced = key
|
|
121
|
+
.replace(/[_-]+/g, " ")
|
|
122
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
123
|
+
.trim();
|
|
124
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function diffRecords(
|
|
128
|
+
before: Record<string, unknown>,
|
|
129
|
+
after: Record<string, unknown>,
|
|
130
|
+
options: { labels?: Record<string, string>; redact?: RegExp; fields?: string[] } = {},
|
|
131
|
+
): RecordField[] {
|
|
132
|
+
const { labels = {}, redact = DEFAULT_REDACT, fields } = options;
|
|
133
|
+
|
|
134
|
+
// The union of both sides, so a removed key is still reported. Iterating only
|
|
135
|
+
// `after` is how removals go missing from audit logs.
|
|
136
|
+
const keys = fields ?? [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
137
|
+
|
|
138
|
+
return keys.map((key) => ({
|
|
139
|
+
key,
|
|
140
|
+
label: labels[key] ?? humanise(key),
|
|
141
|
+
change: classify(before[key], after[key]),
|
|
142
|
+
before: before[key],
|
|
143
|
+
after: after[key],
|
|
144
|
+
redacted: redact.test(key),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const CHANGE_STYLES: Record<FieldChange, string> = {
|
|
149
|
+
added: "text-success",
|
|
150
|
+
removed: "text-destructive",
|
|
151
|
+
changed: "text-foreground",
|
|
152
|
+
unchanged: "text-muted-foreground",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export function RecordDiff({
|
|
156
|
+
className,
|
|
157
|
+
before,
|
|
158
|
+
after,
|
|
159
|
+
labels,
|
|
160
|
+
redact = DEFAULT_REDACT,
|
|
161
|
+
fields: fieldKeys,
|
|
162
|
+
collapseUnchanged = true,
|
|
163
|
+
formatValue = defaultFormatValue,
|
|
164
|
+
children,
|
|
165
|
+
...props
|
|
166
|
+
}: RecordDiffProps) {
|
|
167
|
+
const fields = useMemo(
|
|
168
|
+
() => diffRecords(before, after, { labels, redact, fields: fieldKeys }),
|
|
169
|
+
[before, after, labels, redact, fieldKeys],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const changedCount = fields.filter((field) => field.change !== "unchanged").length;
|
|
173
|
+
|
|
174
|
+
const context = useMemo<RecordDiffContextValue>(
|
|
175
|
+
() => ({ fields, changedCount, formatValue }),
|
|
176
|
+
[fields, changedCount, formatValue],
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<RecordDiffContext.Provider value={context}>
|
|
181
|
+
<div
|
|
182
|
+
data-slot="record-diff"
|
|
183
|
+
data-changed={changedCount}
|
|
184
|
+
className={cn("flex flex-col gap-2", className)}
|
|
185
|
+
{...props}
|
|
186
|
+
>
|
|
187
|
+
{children ?? (
|
|
188
|
+
<>
|
|
189
|
+
<RecordDiffSummary />
|
|
190
|
+
<RecordDiffTable collapseUnchanged={collapseUnchanged} />
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</RecordDiffContext.Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** "3 fields changed" — the thing a reviewer scanning a log needs first. */
|
|
199
|
+
export function RecordDiffSummary({ className, ...props }: ComponentPropsWithRef<"p">) {
|
|
200
|
+
const { changedCount, fields } = useRecordDiffContext("RecordDiffSummary");
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<p
|
|
204
|
+
data-slot="record-diff-summary"
|
|
205
|
+
className={cn("text-xs text-muted-foreground", className)}
|
|
206
|
+
{...props}
|
|
207
|
+
>
|
|
208
|
+
{changedCount === 0
|
|
209
|
+
? "No fields changed"
|
|
210
|
+
: `${String(changedCount)} of ${String(fields.length)} field${fields.length === 1 ? "" : "s"} changed`}
|
|
211
|
+
</p>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface RecordDiffTableProps extends ComponentPropsWithRef<"table"> {
|
|
216
|
+
collapseUnchanged?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function RecordDiffTable({
|
|
220
|
+
className,
|
|
221
|
+
collapseUnchanged = true,
|
|
222
|
+
...props
|
|
223
|
+
}: RecordDiffTableProps) {
|
|
224
|
+
const { fields, formatValue, changedCount } = useRecordDiffContext("RecordDiffTable");
|
|
225
|
+
const [showUnchanged, setShowUnchanged] = useState(!collapseUnchanged);
|
|
226
|
+
|
|
227
|
+
const unchangedCount = fields.length - changedCount;
|
|
228
|
+
const visible = showUnchanged
|
|
229
|
+
? fields
|
|
230
|
+
: fields.filter((field) => field.change !== "unchanged");
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className="flex flex-col gap-2">
|
|
234
|
+
<div className="overflow-x-auto">
|
|
235
|
+
<table
|
|
236
|
+
data-slot="record-diff-table"
|
|
237
|
+
className={cn("w-full border-collapse text-sm", className)}
|
|
238
|
+
{...props}
|
|
239
|
+
>
|
|
240
|
+
<thead>
|
|
241
|
+
<tr className="border-b border-border">
|
|
242
|
+
<th
|
|
243
|
+
scope="col"
|
|
244
|
+
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
|
245
|
+
>
|
|
246
|
+
Field
|
|
247
|
+
</th>
|
|
248
|
+
<th
|
|
249
|
+
scope="col"
|
|
250
|
+
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
|
251
|
+
>
|
|
252
|
+
Before
|
|
253
|
+
</th>
|
|
254
|
+
<th
|
|
255
|
+
scope="col"
|
|
256
|
+
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
|
257
|
+
>
|
|
258
|
+
After
|
|
259
|
+
</th>
|
|
260
|
+
</tr>
|
|
261
|
+
</thead>
|
|
262
|
+
<tbody>
|
|
263
|
+
{visible.map((field) => (
|
|
264
|
+
<tr
|
|
265
|
+
key={field.key}
|
|
266
|
+
data-slot="record-diff-row"
|
|
267
|
+
data-change={field.change}
|
|
268
|
+
className="border-b border-border last:border-0"
|
|
269
|
+
>
|
|
270
|
+
{/* scope="row" so each value is announced with its field name
|
|
271
|
+
rather than as a loose cell in a wall of table. */}
|
|
272
|
+
<th
|
|
273
|
+
scope="row"
|
|
274
|
+
className="px-3 py-2 text-left align-top font-medium whitespace-nowrap"
|
|
275
|
+
>
|
|
276
|
+
{field.label}
|
|
277
|
+
{/* The change kind must not be carried by row colour alone. */}
|
|
278
|
+
<span className="sr-only">{`, ${field.change}`}</span>
|
|
279
|
+
</th>
|
|
280
|
+
<td className="px-3 py-2 align-top text-muted-foreground">
|
|
281
|
+
{field.redacted ? (
|
|
282
|
+
<RedactedValue />
|
|
283
|
+
) : field.change === "added" ? (
|
|
284
|
+
<span className="text-muted-foreground">—</span>
|
|
285
|
+
) : (
|
|
286
|
+
<span className="line-through decoration-muted-foreground/50">
|
|
287
|
+
{formatValue(field.before, field.key)}
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
</td>
|
|
291
|
+
<td className={cn("px-3 py-2 align-top", CHANGE_STYLES[field.change])}>
|
|
292
|
+
{field.redacted ? (
|
|
293
|
+
<RedactedValue />
|
|
294
|
+
) : field.change === "removed" ? (
|
|
295
|
+
<span className="text-muted-foreground">—</span>
|
|
296
|
+
) : (
|
|
297
|
+
formatValue(field.after, field.key)
|
|
298
|
+
)}
|
|
299
|
+
</td>
|
|
300
|
+
</tr>
|
|
301
|
+
))}
|
|
302
|
+
</tbody>
|
|
303
|
+
</table>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{collapseUnchanged && unchangedCount > 0 ? (
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
data-slot="record-diff-toggle"
|
|
310
|
+
aria-expanded={showUnchanged}
|
|
311
|
+
onClick={() => {
|
|
312
|
+
setShowUnchanged((shown) => !shown);
|
|
313
|
+
}}
|
|
314
|
+
className={cn(
|
|
315
|
+
"self-start rounded-md px-1.5 py-0.5 text-xs text-muted-foreground",
|
|
316
|
+
"transition-colors hover:text-foreground",
|
|
317
|
+
"outline-none focus-visible:ring-2 focus-visible:ring-ring/55",
|
|
318
|
+
)}
|
|
319
|
+
>
|
|
320
|
+
{showUnchanged
|
|
321
|
+
? `Hide ${String(unchangedCount)} unchanged`
|
|
322
|
+
: `Show ${String(unchangedCount)} unchanged`}
|
|
323
|
+
</button>
|
|
324
|
+
) : null}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A value that is never rendered.
|
|
331
|
+
*
|
|
332
|
+
* Redaction happens before formatting, so a secret is not passed to a formatter
|
|
333
|
+
* that might log or transform it — the value simply does not reach the DOM.
|
|
334
|
+
*/
|
|
335
|
+
function RedactedValue() {
|
|
336
|
+
return (
|
|
337
|
+
<span data-slot="record-diff-redacted" className="text-muted-foreground italic">
|
|
338
|
+
redacted
|
|
339
|
+
</span>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -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
|
+
});
|