@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.
- package/README.md +6 -3
- package/dist/index.d.ts +171 -7
- package/dist/index.js +490 -61
- 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/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 +155 -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 +10 -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>
|
|
@@ -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>
|
|
@@ -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(
|
|
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>
|