@vitavision/ui 0.12.0 → 0.14.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/README.md +6 -3
- package/dist/index.d.ts +244 -7
- package/dist/index.js +590 -63
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Tabs.tsx +3 -10
- package/src/components/ThemeToggle.tsx +1 -5
- package/src/components/ui/Badge.tsx +1 -4
- package/src/components/ui/Breakdown.tsx +139 -0
- package/src/components/ui/Button.tsx +3 -15
- package/src/components/ui/Dialog.tsx +4 -19
- package/src/components/ui/Disclosure.tsx +2 -7
- package/src/components/ui/DropdownMenu.tsx +2 -16
- package/src/components/ui/Feedback.tsx +5 -9
- package/src/components/ui/Field.tsx +4 -15
- package/src/components/ui/HighlightedText.tsx +84 -0
- package/src/components/ui/Input.tsx +5 -43
- package/src/components/ui/Panel.tsx +4 -16
- package/src/components/ui/PoseInput.tsx +7 -5
- package/src/components/ui/Progress.tsx +80 -0
- package/src/components/ui/SegmentedControl.tsx +1 -3
- package/src/components/ui/Select.tsx +2 -10
- package/src/components/ui/Slider.tsx +2 -7
- package/src/components/ui/Table.tsx +182 -32
- package/src/components/ui/Textarea.tsx +149 -0
- package/src/components/ui/Toaster.tsx +3 -19
- package/src/components/ui/Toggle.tsx +1 -5
- package/src/components/ui/ToggleChip.tsx +1 -3
- package/src/components/ui/Tooltip.tsx +1 -5
- package/src/components/ui/VectorInput.tsx +1 -1
- package/src/components/ui/cn.ts +1 -2
- package/src/components/ui/highlightSpans.ts +110 -0
- package/src/components/ui/index.ts +18 -41
- package/src/components/ui/listboxModel.ts +1 -6
- package/src/components/ui/tableSort.ts +2 -9
- package/src/components/ui/tableWindow.ts +96 -0
- package/src/index.ts +1 -8
- package/src/styles.css +14 -0
- package/src/theme.ts +2 -7
package/package.json
CHANGED
package/src/components/Tabs.tsx
CHANGED
|
@@ -86,10 +86,7 @@ export function Tabs<Id extends string>({
|
|
|
86
86
|
aria-label={label}
|
|
87
87
|
aria-orientation="horizontal"
|
|
88
88
|
onKeyDown={onKeyDown}
|
|
89
|
-
className={cn(
|
|
90
|
-
"inline-flex max-w-full flex-wrap items-center gap-0.5 rounded-control border border-line bg-raised p-0.5",
|
|
91
|
-
className,
|
|
92
|
-
)}
|
|
89
|
+
className={cn("inline-flex max-w-full flex-wrap items-center gap-0.5 rounded-control border border-line bg-raised p-0.5", className)}
|
|
93
90
|
>
|
|
94
91
|
{items.map((item) => {
|
|
95
92
|
const selected = item.id === active;
|
|
@@ -100,9 +97,7 @@ export function Tabs<Id extends string>({
|
|
|
100
97
|
role="tab"
|
|
101
98
|
id={idPrefix === undefined ? undefined : `${idPrefix}-tab-${item.id}`}
|
|
102
99
|
aria-selected={selected}
|
|
103
|
-
aria-controls={
|
|
104
|
-
idPrefix !== undefined && selected ? `${idPrefix}-panel-${item.id}` : undefined
|
|
105
|
-
}
|
|
100
|
+
aria-controls={idPrefix !== undefined && selected ? `${idPrefix}-panel-${item.id}` : undefined}
|
|
106
101
|
tabIndex={item.id === focusable ? 0 : -1}
|
|
107
102
|
data-tab-id={item.id}
|
|
108
103
|
data-state={selected ? "active" : "inactive"}
|
|
@@ -112,9 +107,7 @@ export function Tabs<Id extends string>({
|
|
|
112
107
|
className={cn(
|
|
113
108
|
"inline-flex items-center gap-1.5 rounded-[0.3rem] px-2.5 py-1 text-xs font-medium transition-colors",
|
|
114
109
|
"disabled:cursor-not-allowed disabled:opacity-40",
|
|
115
|
-
selected
|
|
116
|
-
? "bg-surface text-fg shadow-sm"
|
|
117
|
-
: "text-fg-muted hover:text-fg",
|
|
110
|
+
selected ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg",
|
|
118
111
|
focusRing,
|
|
119
112
|
)}
|
|
120
113
|
>
|
|
@@ -81,11 +81,7 @@ export function ThemeToggle({
|
|
|
81
81
|
onClick={advance}
|
|
82
82
|
aria-label={LABEL[choice]}
|
|
83
83
|
data-theme-choice={choice}
|
|
84
|
-
className={cn(
|
|
85
|
-
"rounded-control p-1.5 text-fg-muted transition-colors hover:bg-raised hover:text-fg",
|
|
86
|
-
focusRing,
|
|
87
|
-
className,
|
|
88
|
-
)}
|
|
84
|
+
className={cn("rounded-control p-1.5 text-fg-muted transition-colors hover:bg-raised hover:text-fg", focusRing, className)}
|
|
89
85
|
>
|
|
90
86
|
<Icon className="size-4" />
|
|
91
87
|
</button>
|
|
@@ -89,10 +89,7 @@ export function StatusDot({
|
|
|
89
89
|
children?: ReactNode;
|
|
90
90
|
}) {
|
|
91
91
|
return (
|
|
92
|
-
<span
|
|
93
|
-
data-tone={tone}
|
|
94
|
-
className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}
|
|
95
|
-
>
|
|
92
|
+
<span data-tone={tone} className={cn("inline-flex items-center gap-1.5 text-xs text-fg-muted", className)}>
|
|
96
93
|
<span className={cn("size-1.5 shrink-0 rounded-full", DOT_CLASSES[tone])} aria-hidden />
|
|
97
94
|
{children}
|
|
98
95
|
</span>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Counts by category, as one thin stacked bar.
|
|
3
|
+
*
|
|
4
|
+
* The segments may use the verdict colours because each one counts things the app has already
|
|
5
|
+
* judged: strings it passed, strings it flagged, strings that failed a check. That is the only
|
|
6
|
+
* licence. A share of a continuous quantity, or one series beside another, is not a verdict and
|
|
7
|
+
* belongs on a chart.
|
|
8
|
+
*
|
|
9
|
+
* It is a `role="img"`: the bar is one picture whose meaning is its name. `progressbar` and
|
|
10
|
+
* `meter` carry a single value, and a `list` of segments would make a reader step through
|
|
11
|
+
* parts of a glyph that is meant to be taken in at once. The name is the counts in words, so
|
|
12
|
+
* colour is never the only cue.
|
|
13
|
+
*
|
|
14
|
+
* Widths are flex grow factors rather than percentages, so the one-pixel gaps that keep two
|
|
15
|
+
* neighbouring colours apart come out of the bar and not off its end. The layout is inline
|
|
16
|
+
* rather than classes, so the shares and the 2 px floor hold under any stylesheet.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ComponentProps } from "react";
|
|
20
|
+
|
|
21
|
+
import { byDensity, useDensity } from "./Density";
|
|
22
|
+
import { cn } from "./cn";
|
|
23
|
+
|
|
24
|
+
/** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
|
|
25
|
+
const count = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The colour of a `Breakdown` segment: a verdict (`normal`, `warn`, `defect`) for things the app
|
|
29
|
+
* has judged, or `neutral` for a category that is no verdict (skipped, not applicable).
|
|
30
|
+
*/
|
|
31
|
+
export type BreakdownTone = "normal" | "warn" | "defect" | "neutral";
|
|
32
|
+
|
|
33
|
+
/** One category of a `Breakdown`: how many, in which colour, and what a reader calls them. */
|
|
34
|
+
export interface BreakdownSegment {
|
|
35
|
+
/** How many. Negative and non-finite values count as 0, and a segment of 0 is left out. */
|
|
36
|
+
value: number;
|
|
37
|
+
/** The colour, by meaning. */
|
|
38
|
+
tone: BreakdownTone;
|
|
39
|
+
/** What the count is, as it reads after the number: `"done"` gives "412 done". Unique among the segments. */
|
|
40
|
+
label: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Options of `breakdownText`, the same as the `Breakdown` props of the same names. */
|
|
44
|
+
export interface BreakdownTextOptions {
|
|
45
|
+
/** The whole. Defaults to the sum of the segments; a total below the sum counts as the sum. */
|
|
46
|
+
total?: number | undefined;
|
|
47
|
+
/** What the rest of the total is, as it reads after the number: `"to do"` gives "131 to do". */
|
|
48
|
+
remainderLabel?: string | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TONE_CLASSES: Record<BreakdownTone, string> = {
|
|
52
|
+
normal: "bg-normal",
|
|
53
|
+
warn: "bg-warn",
|
|
54
|
+
defect: "bg-defect",
|
|
55
|
+
neutral: "bg-fg-subtle",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A count as drawn: 0 for anything that is not a finite, positive number. */
|
|
59
|
+
const amount = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0);
|
|
60
|
+
|
|
61
|
+
/** The segments that are drawn, the whole, and the rest of it. */
|
|
62
|
+
function measure(segments: readonly BreakdownSegment[], total: number | undefined) {
|
|
63
|
+
const shown = segments.filter((segment) => amount(segment.value) > 0);
|
|
64
|
+
const sum = shown.reduce((acc, segment) => acc + amount(segment.value), 0);
|
|
65
|
+
const whole = total !== undefined && Number.isFinite(total) && total > sum ? total : sum;
|
|
66
|
+
return { shown, sum, whole, rest: whole - sum };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The counts of a breakdown in words: `"412 done, 20 to check, 3 problems, 131 to do of 566"`.
|
|
71
|
+
*
|
|
72
|
+
* It is the default accessible name of `Breakdown`, and the text for a tooltip or a label printed
|
|
73
|
+
* beside the bar. Segments of 0 are left out; the rest of the total is named only with a
|
|
74
|
+
* `remainderLabel`. With nothing to count it reads `"0 of 566"`.
|
|
75
|
+
*
|
|
76
|
+
* @param segments - The categories, in order.
|
|
77
|
+
* @param options - The `total` and the `remainderLabel`.
|
|
78
|
+
* @returns The text.
|
|
79
|
+
*/
|
|
80
|
+
export function breakdownText(segments: readonly BreakdownSegment[], options: BreakdownTextOptions = {}): string {
|
|
81
|
+
const { shown, whole, rest } = measure(segments, options.total);
|
|
82
|
+
const parts = shown.map((segment) => `${count.format(amount(segment.value))} ${segment.label}`);
|
|
83
|
+
if (options.remainderLabel !== undefined && rest > 0) parts.push(`${count.format(rest)} ${options.remainderLabel}`);
|
|
84
|
+
return `${parts.length > 0 ? parts.join(", ") : "0"} of ${count.format(whole)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Props of `Breakdown`: the segments and their total, plus the `<div>` props. */
|
|
88
|
+
export type BreakdownProps = Omit<ComponentProps<"div">, "children" | "role"> & {
|
|
89
|
+
/** The categories, drawn left to right in this order, each as wide as its share of the total. */
|
|
90
|
+
segments: readonly BreakdownSegment[];
|
|
91
|
+
/**
|
|
92
|
+
* The whole. Defaults to the sum of the segments; the rest is drawn as empty track. A total
|
|
93
|
+
* below the sum counts as the sum.
|
|
94
|
+
*/
|
|
95
|
+
total?: number | undefined;
|
|
96
|
+
/** What the empty track counts, for the accessible name: `"to do"` gives "131 to do". */
|
|
97
|
+
remainderLabel?: string | undefined;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A thin bar of counts by category: 412 done, 20 to check and 3 problems out of 566, with the
|
|
102
|
+
* rest as empty track. Segments are drawn in the order given, each as wide as its share of the
|
|
103
|
+
* total (a non-zero count is never narrower than 2 px); segments of 0 are left out.
|
|
104
|
+
*
|
|
105
|
+
* Its colours are verdicts, so use it only for things the app has judged — never for a share of
|
|
106
|
+
* a continuous quantity or for series side by side (use `Progress` or a chart).
|
|
107
|
+
*
|
|
108
|
+
* A `role="img"` whose name lists every non-zero count, as `breakdownText` writes it: "412 done,
|
|
109
|
+
* 20 to check, 3 problems, 131 to do of 566". `aria-label` or `aria-labelledby` replaces it. The track
|
|
110
|
+
* matches `Progress` and follows the density in force. State is exposed as `data-state`
|
|
111
|
+
* (`"empty"` with nothing counted, `"partial"`, or `"complete"` when the segments fill the
|
|
112
|
+
* total), and each segment's tone as its `data-tone`. Width changes ease over 300 ms, and not at
|
|
113
|
+
* all with reduced motion.
|
|
114
|
+
*/
|
|
115
|
+
export function Breakdown({ segments, total, remainderLabel, className, "aria-label": ariaLabel, ...rest }: BreakdownProps) {
|
|
116
|
+
const density = useDensity();
|
|
117
|
+
const { shown, sum, rest: remainder } = measure(segments, total);
|
|
118
|
+
const name = ariaLabel ?? (rest["aria-labelledby"] ? undefined : breakdownText(segments, { total, remainderLabel }));
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
role="img"
|
|
122
|
+
aria-label={name}
|
|
123
|
+
data-state={sum === 0 ? "empty" : remainder > 0 ? "partial" : "complete"}
|
|
124
|
+
{...rest}
|
|
125
|
+
className={cn("w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className)}
|
|
126
|
+
style={{ display: "flex", gap: 1, ...rest.style }}
|
|
127
|
+
>
|
|
128
|
+
{shown.map((segment) => (
|
|
129
|
+
<div
|
|
130
|
+
key={segment.label}
|
|
131
|
+
data-tone={segment.tone}
|
|
132
|
+
className={cn("h-full transition-[flex-grow] duration-300", TONE_CLASSES[segment.tone])}
|
|
133
|
+
style={{ flex: `${amount(segment.value)} 0 0`, minWidth: 2 }}
|
|
134
|
+
/>
|
|
135
|
+
))}
|
|
136
|
+
{remainder > 0 && <div className="h-full transition-[flex-grow] duration-300" style={{ flex: `${remainder} 0 0` }} />}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -111,11 +111,7 @@ export function Button({
|
|
|
111
111
|
|
|
112
112
|
if (asChild) {
|
|
113
113
|
return (
|
|
114
|
-
<Slot
|
|
115
|
-
{...rest}
|
|
116
|
-
data-variant={variant}
|
|
117
|
-
className={buttonClasses({ variant, size: resolved, className })}
|
|
118
|
-
>
|
|
114
|
+
<Slot {...rest} data-variant={variant} className={buttonClasses({ variant, size: resolved, className })}>
|
|
119
115
|
{icon && <ButtonIcon icon={icon} />}
|
|
120
116
|
<Slottable>{children}</Slottable>
|
|
121
117
|
</Slot>
|
|
@@ -132,11 +128,7 @@ export function Button({
|
|
|
132
128
|
{...rest}
|
|
133
129
|
className={buttonClasses({ variant, size: resolved, className })}
|
|
134
130
|
>
|
|
135
|
-
{loading ?
|
|
136
|
-
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
|
|
137
|
-
) : (
|
|
138
|
-
icon && <ButtonIcon icon={icon} />
|
|
139
|
-
)}
|
|
131
|
+
{loading ? <Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden /> : icon && <ButtonIcon icon={icon} />}
|
|
140
132
|
{children}
|
|
141
133
|
</button>
|
|
142
134
|
);
|
|
@@ -179,11 +171,7 @@ export function ButtonLink({
|
|
|
179
171
|
const Comp = asChild ? Slot : "a";
|
|
180
172
|
|
|
181
173
|
return (
|
|
182
|
-
<Comp
|
|
183
|
-
{...rest}
|
|
184
|
-
data-variant={variant}
|
|
185
|
-
className={buttonClasses({ variant, size: resolved, className })}
|
|
186
|
-
>
|
|
174
|
+
<Comp {...rest} data-variant={variant} className={buttonClasses({ variant, size: resolved, className })}>
|
|
187
175
|
{icon && <ButtonIcon icon={icon} />}
|
|
188
176
|
<Slottable>{children}</Slottable>
|
|
189
177
|
</Comp>
|
|
@@ -20,10 +20,7 @@ import { cn, focusRingInset } from "./cn";
|
|
|
20
20
|
* resizes. A scrolling body has to be reachable by keyboard (so it can be scrolled without
|
|
21
21
|
* a pointer); a body that fits must not add a tab stop.
|
|
22
22
|
*/
|
|
23
|
-
function useOverflowing(): [
|
|
24
|
-
(node: HTMLDivElement | null) => (() => void) | undefined,
|
|
25
|
-
boolean,
|
|
26
|
-
] {
|
|
23
|
+
function useOverflowing(): [(node: HTMLDivElement | null) => (() => void) | undefined, boolean] {
|
|
27
24
|
const [overflowing, setOverflowing] = useState(false);
|
|
28
25
|
const ref = useCallback((node: HTMLDivElement | null) => {
|
|
29
26
|
if (node === null) return undefined;
|
|
@@ -89,17 +86,10 @@ export function Dialog({
|
|
|
89
86
|
className,
|
|
90
87
|
)}
|
|
91
88
|
>
|
|
92
|
-
<RadixDialog.Title className="text-sm font-semibold tracking-tight text-fg">
|
|
93
|
-
{title}
|
|
94
|
-
</RadixDialog.Title>
|
|
89
|
+
<RadixDialog.Title className="text-sm font-semibold tracking-tight text-fg">{title}</RadixDialog.Title>
|
|
95
90
|
{/* Radix warns when a dialog has no description; render an empty one rather than
|
|
96
91
|
inventing prose for a form whose fields already carry their own labels. */}
|
|
97
|
-
<RadixDialog.Description
|
|
98
|
-
className={cn(
|
|
99
|
-
"mt-2 text-sm leading-relaxed text-fg-muted",
|
|
100
|
-
description === undefined && "sr-only",
|
|
101
|
-
)}
|
|
102
|
-
>
|
|
92
|
+
<RadixDialog.Description className={cn("mt-2 text-sm leading-relaxed text-fg-muted", description === undefined && "sr-only")}>
|
|
103
93
|
{description ?? title}
|
|
104
94
|
</RadixDialog.Description>
|
|
105
95
|
<div
|
|
@@ -166,12 +156,7 @@ export function ConfirmDialog({
|
|
|
166
156
|
<RadixDialog.Close asChild>
|
|
167
157
|
<Button variant="ghost">Cancel</Button>
|
|
168
158
|
</RadixDialog.Close>
|
|
169
|
-
<Button
|
|
170
|
-
variant={destructive ? "danger" : "primary"}
|
|
171
|
-
loading={loading}
|
|
172
|
-
disabled={disabled}
|
|
173
|
-
onClick={onConfirm}
|
|
174
|
-
>
|
|
159
|
+
<Button variant={destructive ? "danger" : "primary"} loading={loading} disabled={disabled} onClick={onConfirm}>
|
|
175
160
|
{confirmLabel}
|
|
176
161
|
</Button>
|
|
177
162
|
</>
|
|
@@ -47,14 +47,9 @@ export function Disclosure({
|
|
|
47
47
|
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal",
|
|
48
48
|
)}
|
|
49
49
|
>
|
|
50
|
-
<ChevronRight
|
|
51
|
-
className="size-3.5 shrink-0 transition-transform group-open:rotate-90"
|
|
52
|
-
aria-hidden
|
|
53
|
-
/>
|
|
50
|
+
<ChevronRight className="size-3.5 shrink-0 transition-transform group-open:rotate-90" aria-hidden />
|
|
54
51
|
{summary}
|
|
55
|
-
{count !== undefined &&
|
|
56
|
-
<span className="font-mono text-fg-subtle tabular-nums">{count}</span>
|
|
57
|
-
)}
|
|
52
|
+
{count !== undefined && <span className="font-mono text-fg-subtle tabular-nums">{count}</span>}
|
|
58
53
|
</summary>
|
|
59
54
|
<div className="pt-3">{children}</div>
|
|
60
55
|
</details>
|
|
@@ -36,15 +36,7 @@ export interface DropdownMenuProps {
|
|
|
36
36
|
* page behind stays readable and operable. Toggling a `MenuCheckboxItem` keeps the menu open,
|
|
37
37
|
* so several layers can be switched in one visit.
|
|
38
38
|
*/
|
|
39
|
-
export function DropdownMenu({
|
|
40
|
-
trigger,
|
|
41
|
-
children,
|
|
42
|
-
open,
|
|
43
|
-
onOpenChange,
|
|
44
|
-
align = "start",
|
|
45
|
-
side = "bottom",
|
|
46
|
-
className,
|
|
47
|
-
}: DropdownMenuProps) {
|
|
39
|
+
export function DropdownMenu({ trigger, children, open, onOpenChange, align = "start", side = "bottom", className }: DropdownMenuProps) {
|
|
48
40
|
return (
|
|
49
41
|
<RadixMenu.Root
|
|
50
42
|
// Non-modal: a modal menu hides the rest of the page from assistive technology while
|
|
@@ -134,13 +126,7 @@ export interface MenuCheckboxItemProps {
|
|
|
134
126
|
* A toggle (`role="menuitemcheckbox"`): a layer on or off. A check marks it on. Choosing it
|
|
135
127
|
* leaves the menu open.
|
|
136
128
|
*/
|
|
137
|
-
export function MenuCheckboxItem({
|
|
138
|
-
children,
|
|
139
|
-
checked,
|
|
140
|
-
onCheckedChange,
|
|
141
|
-
shortcut,
|
|
142
|
-
disabled,
|
|
143
|
-
}: MenuCheckboxItemProps) {
|
|
129
|
+
export function MenuCheckboxItem({ children, checked, onCheckedChange, shortcut, disabled }: MenuCheckboxItemProps) {
|
|
144
130
|
return (
|
|
145
131
|
<RadixMenu.CheckboxItem
|
|
146
132
|
checked={checked}
|
|
@@ -58,11 +58,7 @@ export function Callout({
|
|
|
58
58
|
<div
|
|
59
59
|
role={tone === "error" ? "alert" : "status"}
|
|
60
60
|
data-tone={tone}
|
|
61
|
-
className={cn(
|
|
62
|
-
"flex items-start gap-2.5 rounded-control border px-3 py-2.5 text-sm leading-snug",
|
|
63
|
-
classes,
|
|
64
|
-
className,
|
|
65
|
-
)}
|
|
61
|
+
className={cn("flex items-start gap-2.5 rounded-control border px-3 py-2.5 text-sm leading-snug", classes, className)}
|
|
66
62
|
>
|
|
67
63
|
<Icon className={cn("mt-0.5 size-4 shrink-0", ICON_TONE[tone])} aria-hidden />
|
|
68
64
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
@@ -147,6 +143,9 @@ export function SkeletonRows({
|
|
|
147
143
|
* A `role="progressbar"` with `aria-valuenow` in percent. Its accessible name is
|
|
148
144
|
* `aria-label` if given, else `label`, else "Progress" — pass one that names the job when
|
|
149
145
|
* a screen shows more than one bar.
|
|
146
|
+
*
|
|
147
|
+
* @deprecated Use `Progress`: a value out of a `max` with a "412 of 2,556" value text, an
|
|
148
|
+
* indeterminate state, and a track that follows the density.
|
|
150
149
|
*/
|
|
151
150
|
export function ProgressBar({
|
|
152
151
|
fraction,
|
|
@@ -175,10 +174,7 @@ export function ProgressBar({
|
|
|
175
174
|
data-complete={percent === 100 ? "" : undefined}
|
|
176
175
|
className="h-1.5 overflow-hidden rounded-full bg-line"
|
|
177
176
|
>
|
|
178
|
-
<div
|
|
179
|
-
className="h-full rounded-full bg-signal transition-[width] duration-300"
|
|
180
|
-
style={{ width: `${percent}%` }}
|
|
181
|
-
/>
|
|
177
|
+
<div className="h-full rounded-full bg-signal transition-[width] duration-300" style={{ width: `${percent}%` }} />
|
|
182
178
|
</div>
|
|
183
179
|
{label && (
|
|
184
180
|
<span className="font-mono text-xs text-fg-muted tabular-nums">
|
|
@@ -85,10 +85,7 @@ export function Field({
|
|
|
85
85
|
const density = useDensity();
|
|
86
86
|
|
|
87
87
|
const hasError = error !== undefined && error !== null && error !== false;
|
|
88
|
-
const describedBy =
|
|
89
|
-
[description ? descriptionId : undefined, hasError ? errorId : undefined]
|
|
90
|
-
.filter(Boolean)
|
|
91
|
-
.join(" ") || undefined;
|
|
88
|
+
const describedBy = [description ? descriptionId : undefined, hasError ? errorId : undefined].filter(Boolean).join(" ") || undefined;
|
|
92
89
|
|
|
93
90
|
// A group carries the description itself; its controls would only repeat it.
|
|
94
91
|
const control = useMemo(
|
|
@@ -109,9 +106,7 @@ export function Field({
|
|
|
109
106
|
</span>
|
|
110
107
|
)}
|
|
111
108
|
</span>
|
|
112
|
-
{annotation &&
|
|
113
|
-
<span className="ml-auto font-mono text-[11px] text-fg-subtle">{annotation}</span>
|
|
114
|
-
)}
|
|
109
|
+
{annotation && <span className="ml-auto font-mono text-[11px] text-fg-subtle">{annotation}</span>}
|
|
115
110
|
</span>
|
|
116
111
|
<FieldContext value={control}>{children}</FieldContext>
|
|
117
112
|
</>
|
|
@@ -122,17 +117,11 @@ export function Field({
|
|
|
122
117
|
className={cn("flex min-w-0 flex-col", gap, className)}
|
|
123
118
|
data-invalid={hasError ? "" : undefined}
|
|
124
119
|
data-required={required ? "" : undefined}
|
|
125
|
-
{...(as === "group"
|
|
126
|
-
? { role: "group", "aria-label": label, "aria-describedby": describedBy }
|
|
127
|
-
: {})}
|
|
120
|
+
{...(as === "group" ? { role: "group", "aria-label": label, "aria-describedby": describedBy } : {})}
|
|
128
121
|
>
|
|
129
122
|
{/* The description and the error sit outside the <label>: inside it they would be read
|
|
130
123
|
as part of the control's name as well as its description. */}
|
|
131
|
-
{as === "label" ? (
|
|
132
|
-
<label className={cn("flex min-w-0 flex-col", gap)}>{labelled}</label>
|
|
133
|
-
) : (
|
|
134
|
-
labelled
|
|
135
|
-
)}
|
|
124
|
+
{as === "label" ? <label className={cn("flex min-w-0 flex-col", gap)}>{labelled}</label> : labelled}
|
|
136
125
|
|
|
137
126
|
{description && (
|
|
138
127
|
<span id={descriptionId} className="text-xs leading-snug text-fg-muted">
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A string with marked spans: the placeholders, tags and protected terms of a UI string, and
|
|
3
|
+
* where a translation does not match its source.
|
|
4
|
+
*
|
|
5
|
+
* The string is only ever text. It is cut into runs and each run is a React text child, so
|
|
6
|
+
* `<b>` in a string is shown as the four characters `<b>`, never parsed. There is no
|
|
7
|
+
* `dangerouslySetInnerHTML` here, nor anywhere in the package.
|
|
8
|
+
*
|
|
9
|
+
* Each kind differs in shape, not only in colour (a box, a mono face, an underline style),
|
|
10
|
+
* and a screen reader hears the kind after the span. That label is selection-proof: a reader
|
|
11
|
+
* copying the string gets the string.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ComponentProps } from "react";
|
|
15
|
+
|
|
16
|
+
import { cn } from "./cn";
|
|
17
|
+
import { HIGHLIGHT_KINDS, segmentText, type HighlightKind, type HighlightSpan } from "./highlightSpans";
|
|
18
|
+
|
|
19
|
+
/** What a screen reader hears after a span of each kind, by default. */
|
|
20
|
+
const LABELS: Record<HighlightKind, string> = {
|
|
21
|
+
placeholder: "placeholder",
|
|
22
|
+
tag: "markup",
|
|
23
|
+
"protected-term": "protected term",
|
|
24
|
+
mismatch: "mismatch",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The look of each kind; listed in `HIGHLIGHT_KINDS` order, so a later kind wins a clash. */
|
|
28
|
+
const STYLES: Record<HighlightKind, string> = {
|
|
29
|
+
// Code-like, in a quiet box.
|
|
30
|
+
tag: "rounded-sm bg-raised px-px font-mono text-fg-muted",
|
|
31
|
+
// Read as a name: a dotted underline, the face unchanged.
|
|
32
|
+
"protected-term": "underline decoration-fg-subtle decoration-dotted decoration-1 underline-offset-[3px]",
|
|
33
|
+
// A slot the code fills: mono, outlined.
|
|
34
|
+
placeholder: "rounded-sm bg-raised px-px font-mono text-fg ring-1 ring-line-strong ring-inset",
|
|
35
|
+
// A verdict the app made: a wavy underline and a tint, in the defect colour.
|
|
36
|
+
mismatch: "bg-defect/12 text-fg underline decoration-defect decoration-wavy decoration-1 underline-offset-[3px]",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Props of `HighlightedText`: the text, its spans, and every `<span>` prop but `children`. */
|
|
40
|
+
export type HighlightedTextProps = Omit<ComponentProps<"span">, "children"> & {
|
|
41
|
+
/** The string. Shown as text, whitespace and line breaks kept; markup in it is not interpreted. */
|
|
42
|
+
text: string;
|
|
43
|
+
/** The ranges to mark, in UTF-16 offsets, in any order; overlaps are allowed. `findSpans` builds them from patterns. */
|
|
44
|
+
spans?: readonly HighlightSpan[] | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* What a screen reader hears after a span of each kind: "placeholder", "markup",
|
|
47
|
+
* "protected term" and "mismatch" by default. `null` silences a kind. A span's own `label` wins.
|
|
48
|
+
*/
|
|
49
|
+
labels?: Partial<Record<HighlightKind, string | null>> | undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A string with highlighted spans: placeholders (`%1`, `{count}`), markup tags (`<b>`),
|
|
54
|
+
* protected terms (`ParamsManager`) and mismatches, each drawn in its own shape. The caller
|
|
55
|
+
* supplies the spans, or finds them with `findSpans`.
|
|
56
|
+
*
|
|
57
|
+
* The text is rendered as text, never as HTML. Each span is a `<mark>` with `data-kind` (the
|
|
58
|
+
* kinds covering it, space-separated), followed by its label for screen readers ("%1,
|
|
59
|
+
* placeholder"), which a copy of the text leaves out.
|
|
60
|
+
*/
|
|
61
|
+
export function HighlightedText({ text, spans = [], labels, className, ...rest }: HighlightedTextProps) {
|
|
62
|
+
const segments = segmentText(text, spans);
|
|
63
|
+
return (
|
|
64
|
+
<span {...rest} className={cn("break-words whitespace-pre-wrap", className)}>
|
|
65
|
+
{segments.map((segment) => {
|
|
66
|
+
if (segment.spans.length === 0) return segment.text;
|
|
67
|
+
const kinds = HIGHLIGHT_KINDS.filter((kind) => segment.spans.some((span) => span.kind === kind));
|
|
68
|
+
const said = segment.spans
|
|
69
|
+
.map((span) => span.label ?? (labels?.[span.kind] === undefined ? LABELS[span.kind] : labels[span.kind]))
|
|
70
|
+
.filter((label): label is string => typeof label === "string" && label !== "");
|
|
71
|
+
return (
|
|
72
|
+
<mark
|
|
73
|
+
key={segment.start}
|
|
74
|
+
data-kind={kinds.join(" ")}
|
|
75
|
+
className={cn("bg-transparent text-inherit", ...kinds.map((kind) => STYLES[kind]))}
|
|
76
|
+
>
|
|
77
|
+
{segment.text}
|
|
78
|
+
{said.length > 0 && <span className="sr-only select-none">{`, ${[...new Set(said)].join(", ")}`}</span>}
|
|
79
|
+
</mark>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</span>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -46,13 +46,7 @@ export function useControlHeight(): string {
|
|
|
46
46
|
* is described by the field's description and error.
|
|
47
47
|
*/
|
|
48
48
|
export function Input({ className, "aria-describedby": describedBy, ...rest }: ComponentProps<"input">) {
|
|
49
|
-
return (
|
|
50
|
-
<input
|
|
51
|
-
{...useFieldDescription(describedBy)}
|
|
52
|
-
{...rest}
|
|
53
|
-
className={cn(controlClasses, useControlHeight(), className)}
|
|
54
|
-
/>
|
|
55
|
-
);
|
|
49
|
+
return <input {...useFieldDescription(describedBy)} {...rest} className={cn(controlClasses, useControlHeight(), className)} />;
|
|
56
50
|
}
|
|
57
51
|
|
|
58
52
|
/**
|
|
@@ -126,11 +120,7 @@ export function NumberInput({ onValueChange, onClear, precision, ...props }: Num
|
|
|
126
120
|
const { unit, ...rest } = props;
|
|
127
121
|
// A `null` text value is an empty field, not a switch to uncontrolled.
|
|
128
122
|
const text = (rest.value === null ? { ...rest, value: "" } : rest) as TextNumberInputProps;
|
|
129
|
-
return unit === undefined || unit === "" ?
|
|
130
|
-
<PlainNumberInput {...text} />
|
|
131
|
-
) : (
|
|
132
|
-
<UnitNumberInput unit={unit} {...text} />
|
|
133
|
-
);
|
|
123
|
+
return unit === undefined || unit === "" ? <PlainNumberInput {...text} /> : <UnitNumberInput unit={unit} {...text} />;
|
|
134
124
|
}
|
|
135
125
|
|
|
136
126
|
type TextNumberInputProps = Omit<NumberInputProps, "unit" | "value" | "onValueChange" | "onClear" | "precision"> & {
|
|
@@ -153,8 +143,7 @@ function ValueNumberInput({
|
|
|
153
143
|
...rest
|
|
154
144
|
}: Omit<NumberInputProps, "onValueChange"> & { onValueChange: (value: number) => void }) {
|
|
155
145
|
const numeric = typeof value === "number" ? value : null;
|
|
156
|
-
const show = (v: number | null) =>
|
|
157
|
-
v === null ? "" : precision === undefined ? String(v) : formatNumber(v, precision);
|
|
146
|
+
const show = (v: number | null) => (v === null ? "" : precision === undefined ? String(v) : formatNumber(v, precision));
|
|
158
147
|
// Non-null while the field is being edited: the text as typed, and the value on focus.
|
|
159
148
|
const [draft, setDraft] = useState<{ text: string; initial: number | null } | null>(null);
|
|
160
149
|
|
|
@@ -214,11 +203,7 @@ function ValueNumberInput({
|
|
|
214
203
|
);
|
|
215
204
|
}
|
|
216
205
|
|
|
217
|
-
function PlainNumberInput({
|
|
218
|
-
className,
|
|
219
|
-
"aria-describedby": describedBy,
|
|
220
|
-
...rest
|
|
221
|
-
}: TextNumberInputProps) {
|
|
206
|
+
function PlainNumberInput({ className, "aria-describedby": describedBy, ...rest }: TextNumberInputProps) {
|
|
222
207
|
return (
|
|
223
208
|
<input
|
|
224
209
|
{...useFieldDescription(describedBy)}
|
|
@@ -235,12 +220,7 @@ function PlainNumberInput({
|
|
|
235
220
|
* `NumberInput` stays byte-identical to the one before `unit` existed, down to the ids React
|
|
236
221
|
* hands the elements rendered after it.
|
|
237
222
|
*/
|
|
238
|
-
function UnitNumberInput({
|
|
239
|
-
unit,
|
|
240
|
-
style,
|
|
241
|
-
"aria-describedby": describedBy,
|
|
242
|
-
...rest
|
|
243
|
-
}: TextNumberInputProps & { unit: string }) {
|
|
223
|
+
function UnitNumberInput({ unit, style, "aria-describedby": describedBy, ...rest }: TextNumberInputProps & { unit: string }) {
|
|
244
224
|
const unitId = useId();
|
|
245
225
|
return (
|
|
246
226
|
<span data-unit={unit} className="relative flex w-full min-w-0 items-center">
|
|
@@ -262,21 +242,3 @@ function UnitNumberInput({
|
|
|
262
242
|
</span>
|
|
263
243
|
);
|
|
264
244
|
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Multi-line text — set in mono, for JSON and lists. Takes every `<textarea>` prop, `ref`
|
|
268
|
-
* included.
|
|
269
|
-
*/
|
|
270
|
-
export function Textarea({
|
|
271
|
-
className,
|
|
272
|
-
"aria-describedby": describedBy,
|
|
273
|
-
...rest
|
|
274
|
-
}: ComponentProps<"textarea">) {
|
|
275
|
-
return (
|
|
276
|
-
<textarea
|
|
277
|
-
{...useFieldDescription(describedBy)}
|
|
278
|
-
{...rest}
|
|
279
|
-
className={cn(controlClasses, "py-1.5 font-mono", className)}
|
|
280
|
-
/>
|
|
281
|
-
);
|
|
282
|
-
}
|
|
@@ -47,11 +47,7 @@ export function Panel({
|
|
|
47
47
|
<h2
|
|
48
48
|
className={cn(
|
|
49
49
|
"truncate font-semibold text-fg",
|
|
50
|
-
byDensity(
|
|
51
|
-
density,
|
|
52
|
-
"text-sm tracking-tight",
|
|
53
|
-
"text-[11px] tracking-wider uppercase text-fg-muted",
|
|
54
|
-
),
|
|
50
|
+
byDensity(density, "text-sm tracking-tight", "text-[11px] tracking-wider uppercase text-fg-muted"),
|
|
55
51
|
)}
|
|
56
52
|
>
|
|
57
53
|
{title}
|
|
@@ -97,10 +93,7 @@ export function Section({
|
|
|
97
93
|
<section className={cn("flex flex-col", byDensity(density, "gap-3", "gap-1.5"), className)}>
|
|
98
94
|
<div className={cn("flex items-baseline", byDensity(density, "gap-2.5", "gap-2"))}>
|
|
99
95
|
{step !== undefined && (
|
|
100
|
-
<span
|
|
101
|
-
className="font-mono text-xs text-fg-subtle tabular-nums"
|
|
102
|
-
aria-hidden
|
|
103
|
-
>
|
|
96
|
+
<span className="font-mono text-xs text-fg-subtle tabular-nums" aria-hidden>
|
|
104
97
|
{String(step).padStart(2, "0")}
|
|
105
98
|
</span>
|
|
106
99
|
)}
|
|
@@ -212,16 +205,11 @@ export function ReadoutStrip({
|
|
|
212
205
|
if (shown.length === 0) return null;
|
|
213
206
|
|
|
214
207
|
return (
|
|
215
|
-
<ol
|
|
216
|
-
className={cn(
|
|
217
|
-
"flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-fg-muted",
|
|
218
|
-
className,
|
|
219
|
-
)}
|
|
220
|
-
>
|
|
208
|
+
<ol className={cn("flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-fg-muted", className)}>
|
|
221
209
|
{shown.map((item, index) => (
|
|
222
210
|
// The items are positional facts with no identity of their own (a label is optional
|
|
223
211
|
// and values repeat), so their place in the strip is the key.
|
|
224
|
-
//
|
|
212
|
+
// oxlint-disable-next-line react/no-array-index-key -- positional list, see above
|
|
225
213
|
<li key={index} className="flex items-center gap-2">
|
|
226
214
|
{index > 0 && (
|
|
227
215
|
<span className="text-fg-subtle" aria-hidden>
|