@vitavision/ui 0.12.0 → 0.13.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 (38) hide show
  1. package/README.md +6 -3
  2. package/dist/index.d.ts +171 -7
  3. package/dist/index.js +490 -61
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/components/Tabs.tsx +3 -10
  7. package/src/components/ThemeToggle.tsx +1 -5
  8. package/src/components/ui/Badge.tsx +1 -4
  9. package/src/components/ui/Button.tsx +3 -15
  10. package/src/components/ui/Dialog.tsx +4 -19
  11. package/src/components/ui/Disclosure.tsx +2 -7
  12. package/src/components/ui/DropdownMenu.tsx +2 -16
  13. package/src/components/ui/Feedback.tsx +5 -9
  14. package/src/components/ui/Field.tsx +4 -15
  15. package/src/components/ui/HighlightedText.tsx +84 -0
  16. package/src/components/ui/Input.tsx +5 -43
  17. package/src/components/ui/Panel.tsx +4 -16
  18. package/src/components/ui/PoseInput.tsx +7 -5
  19. package/src/components/ui/Progress.tsx +80 -0
  20. package/src/components/ui/SegmentedControl.tsx +1 -3
  21. package/src/components/ui/Select.tsx +2 -10
  22. package/src/components/ui/Slider.tsx +2 -7
  23. package/src/components/ui/Table.tsx +155 -32
  24. package/src/components/ui/Textarea.tsx +149 -0
  25. package/src/components/ui/Toaster.tsx +3 -19
  26. package/src/components/ui/Toggle.tsx +1 -5
  27. package/src/components/ui/ToggleChip.tsx +1 -3
  28. package/src/components/ui/Tooltip.tsx +1 -5
  29. package/src/components/ui/VectorInput.tsx +1 -1
  30. package/src/components/ui/cn.ts +1 -2
  31. package/src/components/ui/highlightSpans.ts +110 -0
  32. package/src/components/ui/index.ts +10 -41
  33. package/src/components/ui/listboxModel.ts +1 -6
  34. package/src/components/ui/tableSort.ts +2 -9
  35. package/src/components/ui/tableWindow.ts +96 -0
  36. package/src/index.ts +1 -8
  37. package/src/styles.css +14 -0
  38. package/src/theme.ts +2 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitavision/ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The vitavision instrument design system: Tailwind v4 tokens, theme, and React primitives.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -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>
@@ -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
- // eslint-disable-next-line @eslint-react/no-array-index-key -- positional list, see above
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>
@@ -110,14 +110,12 @@ export function PoseInput({
110
110
  const scale = translationUnit === "mm" ? 1000 : 1;
111
111
  const tPrecision = translationPrecision ?? (translationUnit === "mm" ? 1 : 4);
112
112
  const translation = value.translation.map((component) => component * scale);
113
- const radians =
114
- typed !== null && sameQuaternion(typed.rotation, value.rotation) ? typed.angles : rotationView.toEuler(value.rotation);
113
+ const radians = typed !== null && sameQuaternion(typed.rotation, value.rotation) ? typed.angles : rotationView.toEuler(value.rotation);
115
114
  const degrees = radians.map((angle) => angle * DEGREES);
116
115
  const angleLabels = rotationView.labels ?? DEFAULT_ANGLE_LABELS;
117
116
 
118
117
  if (readOnly) {
119
- const join = (values: readonly number[], precision: number) =>
120
- values.map((component) => formatNumber(component, precision)).join(" ");
118
+ const join = (values: readonly number[], precision: number) => values.map((component) => formatNumber(component, precision)).join(" ");
121
119
  return (
122
120
  <div role="group" aria-label={ariaLabel} data-readonly="" className={cn("min-w-0", className)}>
123
121
  <ReadoutStrip
@@ -149,7 +147,11 @@ export function PoseInput({
149
147
  <div
150
148
  role="group"
151
149
  aria-label={ariaLabel}
152
- className={cn("grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2", byDensity(density, "gap-y-2", "gap-y-1"), className)}
150
+ className={cn(
151
+ "grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-x-2",
152
+ byDensity(density, "gap-y-2", "gap-y-1"),
153
+ className,
154
+ )}
153
155
  >
154
156
  <span aria-hidden className={rowLabel}>
155
157
  Translation
@@ -0,0 +1,80 @@
1
+ /*
2
+ * How far a long operation has got.
3
+ *
4
+ * The bar is in `signal`, not a verdict colour: progress is not a pass or a fail, and a bar
5
+ * that turned green at 100 % would claim a verdict nobody made. The value text is "412 of
6
+ * 2,556" rather than a bare percentage, because the count is what a reader checks against
7
+ * the work ("2,556 strings"), and a screen reader says it as written.
8
+ */
9
+
10
+ import type { ComponentProps } from "react";
11
+
12
+ import { byDensity, useDensity } from "./Density";
13
+ import { cn } from "./cn";
14
+
15
+ /** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
16
+ const count = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
17
+
18
+ /**
19
+ * The accessible value text of a progress: `"412 of 2,556"`, or `null` while indeterminate.
20
+ *
21
+ * @param value - Done so far; `null` or `undefined` for an unknown amount.
22
+ * @param max - The whole. Defaults to 100.
23
+ * @returns The text, with the value clamped to `[0, max]`.
24
+ */
25
+ export function progressText(value: number | null | undefined, max = 100): string | null {
26
+ if (value === null || value === undefined || !Number.isFinite(value)) return null;
27
+ const whole = max > 0 ? max : 100;
28
+ return `${count.format(Math.min(Math.max(value, 0), whole))} of ${count.format(whole)}`;
29
+ }
30
+
31
+ /** Props of `Progress`: a value out of a `max`, or none for an unknown amount, plus the `<div>` props. */
32
+ export type ProgressProps = Omit<ComponentProps<"div">, "children" | "role"> & {
33
+ /** Done so far, from 0 to `max`; clamped. `null` or left out: indeterminate, the amount unknown. */
34
+ value?: number | null | undefined;
35
+ /** The whole. Defaults to 100; a value ≤ 0 counts as 100. */
36
+ max?: number | undefined;
37
+ /**
38
+ * What a screen reader says for the value. Defaults to `"412 of 2,556"`; give your own when
39
+ * the unit matters ("412 of 2,556 strings").
40
+ */
41
+ valueText?: string | undefined;
42
+ };
43
+
44
+ /**
45
+ * A progress bar: determinate when it has a `value` (a `max`, 100 by default), indeterminate
46
+ * without one. Name it with `aria-label` or `aria-labelledby`.
47
+ *
48
+ * A `progressbar` with `aria-valuenow`, `aria-valuemax` and `aria-valuetext` ("412 of 2,556",
49
+ * or your `valueText`); indeterminate, it has none of them, as the ARIA pattern asks.
50
+ * The track follows the density in force. State is exposed as `data-state`
51
+ * (`"indeterminate"`, `"loading"` or `"complete"`). With reduced motion the indeterminate bar
52
+ * holds still.
53
+ */
54
+ export function Progress({ value, max = 100, valueText, className, ...rest }: ProgressProps) {
55
+ const density = useDensity();
56
+ const whole = max > 0 ? max : 100;
57
+ const text = progressText(value, whole);
58
+ const done = text === null ? null : Math.min(Math.max(value as number, 0), whole);
59
+ const state = done === null ? "indeterminate" : done >= whole ? "complete" : "loading";
60
+ return (
61
+ <div
62
+ role="progressbar"
63
+ aria-valuemin={done === null ? undefined : 0}
64
+ aria-valuemax={done === null ? undefined : whole}
65
+ aria-valuenow={done ?? undefined}
66
+ aria-valuetext={done === null ? undefined : (valueText ?? text ?? undefined)}
67
+ data-state={state}
68
+ {...rest}
69
+ className={cn("relative w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className)}
70
+ >
71
+ {done === null ? (
72
+ // A third of the track sweeping across it. Its resting place (reduced motion, a
73
+ // screenshot) is the left third, still visibly "in progress".
74
+ <div className="absolute inset-y-0 left-0 w-1/3 animate-progress-sweep rounded-full bg-signal" />
75
+ ) : (
76
+ <div className="h-full rounded-full bg-signal transition-[width] duration-300" style={{ width: `${(done / whole) * 100}%` }} />
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -110,9 +110,7 @@ export function SegmentedControl({
110
110
  SEGMENT_SIZE_CLASSES[size],
111
111
  "font-medium transition-colors",
112
112
  "has-focus-visible:outline-2 has-focus-visible:outline-offset-1 has-focus-visible:outline-signal",
113
- checked
114
- ? "bg-surface text-fg shadow-sm"
115
- : "text-fg-muted hover:text-fg",
113
+ checked ? "bg-surface text-fg shadow-sm" : "text-fg-muted hover:text-fg",
116
114
  disabled && "cursor-not-allowed",
117
115
  )}
118
116
  >
@@ -119,17 +119,9 @@ export function Select({
119
119
  )}
120
120
  >
121
121
  <RadixSelect.Viewport className="p-1">
122
- {unsetLabel !== undefined && (
123
- <SelectItem value={UNSET_SENTINEL} label={unsetLabel} muted />
124
- )}
122
+ {unsetLabel !== undefined && <SelectItem value={UNSET_SENTINEL} label={unsetLabel} muted />}
125
123
  {options.map((option) => (
126
- <SelectItem
127
- key={option.value}
128
- value={option.value}
129
- label={option.label}
130
- note={option.note}
131
- disabled={option.disabled}
132
- />
124
+ <SelectItem key={option.value} value={option.value} label={option.label} note={option.note} disabled={option.disabled} />
133
125
  ))}
134
126
  </RadixSelect.Viewport>
135
127
  </RadixSelect.Content>