@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
|
@@ -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>
|
|
@@ -69,10 +69,7 @@ export function Slider({
|
|
|
69
69
|
max={max}
|
|
70
70
|
step={step}
|
|
71
71
|
disabled={disabled}
|
|
72
|
-
className={cn(
|
|
73
|
-
"relative flex h-5 min-w-0 flex-1 touch-none items-center select-none",
|
|
74
|
-
"data-disabled:opacity-50",
|
|
75
|
-
)}
|
|
72
|
+
className={cn("relative flex h-5 min-w-0 flex-1 touch-none items-center select-none", "data-disabled:opacity-50")}
|
|
76
73
|
>
|
|
77
74
|
<RadixSlider.Track className="relative h-1 grow overflow-hidden rounded-full bg-line">
|
|
78
75
|
<RadixSlider.Range className="absolute h-full bg-signal" />
|
|
@@ -88,9 +85,7 @@ export function Slider({
|
|
|
88
85
|
)}
|
|
89
86
|
/>
|
|
90
87
|
</RadixSlider.Root>
|
|
91
|
-
{readout &&
|
|
92
|
-
<span className="shrink-0 font-mono text-xs text-fg tabular-nums">{readout}</span>
|
|
93
|
-
)}
|
|
88
|
+
{readout && <span className="shrink-0 font-mono text-xs text-fg tabular-nums">{readout}</span>}
|
|
94
89
|
</div>
|
|
95
90
|
);
|
|
96
91
|
}
|
|
@@ -8,14 +8,20 @@
|
|
|
8
8
|
* Sorting is per column and follows the sortable-table pattern of the ARIA Authoring
|
|
9
9
|
* Practices: the header's content is a button, and only the sorted header carries
|
|
10
10
|
* `aria-sort`. The order itself lives in `tableSort.ts`.
|
|
11
|
+
*
|
|
12
|
+
* `windowed` renders only the rows in view, between two spacer rows that keep the scroll
|
|
13
|
+
* height, so a table of thousands of rows scrolls as fast as one of thirty. It stays a real
|
|
14
|
+
* `<table>`: the rows that are rendered carry `aria-rowindex` and the table `aria-rowcount`, so
|
|
15
|
+
* a reader still hears "row 2,041 of 5,001". The arithmetic lives in `tableWindow.ts`.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
|
14
|
-
import { type KeyboardEvent, type MouseEvent, type ReactNode, useMemo, useState } from "react";
|
|
19
|
+
import { type KeyboardEvent, type MouseEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
20
|
|
|
16
21
|
import { byDensity, useDensity } from "./Density";
|
|
17
|
-
import { cn, focusRing } from "./cn";
|
|
22
|
+
import { cn, focusRing, focusRingInset } from "./cn";
|
|
18
23
|
import { isSortable, nextSort, type SortDirection, sortOrder, type TableSort } from "./tableSort";
|
|
24
|
+
import { navigate, rowWindow, scrollTopFor } from "./tableWindow";
|
|
19
25
|
|
|
20
26
|
/** One column of a `Table`: its header and how to render a row's cell. */
|
|
21
27
|
export type Column<Row> = {
|
|
@@ -55,6 +61,29 @@ export type Column<Row> = {
|
|
|
55
61
|
firstSort?: SortDirection | undefined;
|
|
56
62
|
};
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Windowed rendering for a long table: only the rows in view, plus `overscan` on each side,
|
|
66
|
+
* are in the DOM. The rows scroll under a sticky header drawn on `surface`, the panel colour.
|
|
67
|
+
*/
|
|
68
|
+
export interface TableWindowing {
|
|
69
|
+
/**
|
|
70
|
+
* Every body row's height in px. Rows are drawn exactly this tall and cells keep to one line
|
|
71
|
+
* (longer content is cut with an ellipsis), so it must be at least the density's own row
|
|
72
|
+
* height: 36 px comfortable, 22 px compact.
|
|
73
|
+
*/
|
|
74
|
+
rowHeight: number;
|
|
75
|
+
/**
|
|
76
|
+
* The scroll area's height in px, header included. Leave it out to fill the height of the
|
|
77
|
+
* container, which must then have one (a flex child with `min-h-0`, a fixed-height panel).
|
|
78
|
+
*/
|
|
79
|
+
height?: number | undefined;
|
|
80
|
+
/** Rows rendered beyond each edge of the view, so a fast scroll shows no gap. Defaults to 8. */
|
|
81
|
+
overscan?: number | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Rows per screen before a windowed table has measured itself (a server render, the first paint). */
|
|
85
|
+
const INITIAL_ROWS = 20;
|
|
86
|
+
|
|
58
87
|
/** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
|
|
59
88
|
const SORT_ICONS = { ascending: ArrowUp, descending: ArrowDown, none: ArrowUpDown } as const;
|
|
60
89
|
|
|
@@ -72,6 +101,11 @@ const SORT_ICONS = { ascending: ArrowUp, descending: ArrowDown, none: ArrowUpDow
|
|
|
72
101
|
* `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
|
|
73
102
|
* show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
|
|
74
103
|
* whatever the order on screen.
|
|
104
|
+
*
|
|
105
|
+
* Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End;
|
|
106
|
+
* with `selectionFollowsFocus` each of those moves also activates the row it lands on.
|
|
107
|
+
* With `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
|
|
108
|
+
* rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
|
|
75
109
|
*/
|
|
76
110
|
export function Table<Row>({
|
|
77
111
|
columns,
|
|
@@ -87,6 +121,8 @@ export function Table<Row>({
|
|
|
87
121
|
defaultSort = null,
|
|
88
122
|
onSortChange,
|
|
89
123
|
manualSort = false,
|
|
124
|
+
windowed,
|
|
125
|
+
selectionFollowsFocus = false,
|
|
90
126
|
}: {
|
|
91
127
|
/** The columns, in order. */
|
|
92
128
|
columns: Column<Row>[];
|
|
@@ -105,11 +141,7 @@ export function Table<Row>({
|
|
|
105
141
|
* modifiers — a list kept in step with a canvas needs shift-range and meta-toggle to mean
|
|
106
142
|
* the same thing in both places. `index` is the row's position in `rows`, whatever the
|
|
107
143
|
* sort. */
|
|
108
|
-
onRowClick?: (
|
|
109
|
-
row: Row,
|
|
110
|
-
index: number,
|
|
111
|
-
event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
|
|
112
|
-
) => void;
|
|
144
|
+
onRowClick?: (row: Row, index: number, event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>) => void;
|
|
113
145
|
/** Marks the row that is current — the selected match, the open frame. Rendered as
|
|
114
146
|
* `aria-current`, so it is announced rather than only tinted. `index` is the row's
|
|
115
147
|
* position in `rows`, whatever the sort. */
|
|
@@ -134,6 +166,21 @@ export function Table<Row>({
|
|
|
134
166
|
* and the rows render exactly as given. Mark the columns it can sort `sortable: true`.
|
|
135
167
|
*/
|
|
136
168
|
manualSort?: boolean | undefined;
|
|
169
|
+
/**
|
|
170
|
+
* Render only the rows in view, for a long table: every row `rowHeight` tall, in a scroll
|
|
171
|
+
* area of `height` (or the container's height) under a sticky header. Sorting, row
|
|
172
|
+
* callbacks and keyboard navigation work as without it.
|
|
173
|
+
*/
|
|
174
|
+
windowed?: TableWindowing | undefined;
|
|
175
|
+
/**
|
|
176
|
+
* Activate a row as soon as the keyboard moves to it: each arrow key, Page Up / Page Down,
|
|
177
|
+
* Home or End that lands on another row also calls `onRowClick` for that row, with the key
|
|
178
|
+
* press as the event — selection follows focus, as in a list where the selected item shows
|
|
179
|
+
* its detail beside the list. Clicks, Enter and Space work as without it. Leave it off when
|
|
180
|
+
* activating a row is costly or navigates away: then a reader can move through the rows and
|
|
181
|
+
* choose one. Needs `onRowClick`.
|
|
182
|
+
*/
|
|
183
|
+
selectionFollowsFocus?: boolean | undefined;
|
|
137
184
|
}) {
|
|
138
185
|
const density = useDensity();
|
|
139
186
|
const [ownSort, setOwnSort] = useState<TableSort | null>(defaultSort);
|
|
@@ -151,26 +198,113 @@ export function Table<Row>({
|
|
|
151
198
|
onSortChange?.(next);
|
|
152
199
|
};
|
|
153
200
|
|
|
201
|
+
const rowHeight = windowed?.rowHeight ?? 0;
|
|
202
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
203
|
+
const headRef = useRef<HTMLTableSectionElement>(null);
|
|
204
|
+
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
205
|
+
// The row to focus once it is rendered: a key press may scroll to a row that is not in the DOM yet.
|
|
206
|
+
const pendingFocus = useRef<number | null>(null);
|
|
207
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
208
|
+
const [measured, setMeasured] = useState<{ viewport: number; header: number } | null>(null);
|
|
209
|
+
const viewportHeight = measured?.viewport ?? windowed?.height ?? INITIAL_ROWS * rowHeight;
|
|
210
|
+
const headerHeight = measured?.header ?? 0;
|
|
211
|
+
const range = windowed
|
|
212
|
+
? rowWindow({ rowCount: order.length, rowHeight, scrollTop, viewportHeight, headerHeight, overscan: windowed.overscan ?? 8 })
|
|
213
|
+
: { start: 0, end: order.length, before: 0, after: 0 };
|
|
214
|
+
|
|
215
|
+
const isWindowed = windowed !== undefined;
|
|
216
|
+
// With no rows the scroll area is replaced by the `empty` message: measure the new one when
|
|
217
|
+
// the rows come back, rather than keep observing the one that went away.
|
|
218
|
+
const hasRows = rows.length > 0;
|
|
219
|
+
useLayoutEffect(() => {
|
|
220
|
+
const scroller = scrollRef.current;
|
|
221
|
+
if (!isWindowed || !hasRows || !scroller || typeof ResizeObserver === "undefined") return;
|
|
222
|
+
const measure = () => {
|
|
223
|
+
// A detached element measures 0, which would shrink the window to a row or two.
|
|
224
|
+
if (!scroller.isConnected) return;
|
|
225
|
+
const next = { viewport: scroller.clientHeight, header: headRef.current?.offsetHeight ?? 0 };
|
|
226
|
+
setMeasured((last) => (last?.viewport === next.viewport && last.header === next.header ? last : next));
|
|
227
|
+
};
|
|
228
|
+
measure();
|
|
229
|
+
// A new scroll area starts at the top, whatever the last one was scrolled to.
|
|
230
|
+
setScrollTop(scroller.scrollTop);
|
|
231
|
+
const observer = new ResizeObserver(measure);
|
|
232
|
+
observer.observe(scroller);
|
|
233
|
+
return () => observer.disconnect();
|
|
234
|
+
}, [isWindowed, hasRows]);
|
|
235
|
+
|
|
236
|
+
useLayoutEffect(() => {
|
|
237
|
+
const target = pendingFocus.current;
|
|
238
|
+
if (target === null) return;
|
|
239
|
+
const row = bodyRef.current?.querySelector<HTMLElement>(`tr[data-position="${target}"]`);
|
|
240
|
+
if (!row) return;
|
|
241
|
+
pendingFocus.current = null;
|
|
242
|
+
row.focus({ preventScroll: isWindowed });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Moves focus from the row at `position` for a navigation key, activating the new row when
|
|
247
|
+
* selection follows focus; `true` when the key was one.
|
|
248
|
+
*/
|
|
249
|
+
const moveFocus = (event: KeyboardEvent<HTMLTableRowElement>, position: number): boolean => {
|
|
250
|
+
const page = windowed ? Math.floor((viewportHeight - headerHeight) / rowHeight) : 10;
|
|
251
|
+
const target = navigate(event.key, position, order.length, page);
|
|
252
|
+
if (target === null) return false;
|
|
253
|
+
if (selectionFollowsFocus && onRowClick && target !== position) {
|
|
254
|
+
const index = order[target]!;
|
|
255
|
+
onRowClick(rows[index]!, index, event);
|
|
256
|
+
}
|
|
257
|
+
pendingFocus.current = target;
|
|
258
|
+
const scroller = scrollRef.current;
|
|
259
|
+
if (windowed && scroller) {
|
|
260
|
+
const top = scrollTopFor(target, { rowHeight, scrollTop: scroller.scrollTop, viewportHeight, headerHeight });
|
|
261
|
+
scroller.scrollTop = top;
|
|
262
|
+
// Render the target's window now rather than on the scroll event, so it can take focus.
|
|
263
|
+
setScrollTop(scroller.scrollTop);
|
|
264
|
+
}
|
|
265
|
+
// A row already in the DOM takes focus now: when nothing scrolled, nothing re-renders, and
|
|
266
|
+
// the pending focus would wait for a render that never comes.
|
|
267
|
+
const row = bodyRef.current?.querySelector<HTMLElement>(`tr[data-position="${target}"]`);
|
|
268
|
+
if (row) {
|
|
269
|
+
pendingFocus.current = null;
|
|
270
|
+
row.focus({ preventScroll: windowed !== undefined });
|
|
271
|
+
}
|
|
272
|
+
return true;
|
|
273
|
+
};
|
|
274
|
+
|
|
154
275
|
if (rows.length === 0) {
|
|
155
|
-
return (
|
|
156
|
-
<p
|
|
157
|
-
className={cn(
|
|
158
|
-
"text-center text-fg-muted",
|
|
159
|
-
byDensity(density, "py-6 text-sm", "py-3 text-xs"),
|
|
160
|
-
className,
|
|
161
|
-
)}
|
|
162
|
-
>
|
|
163
|
-
{empty}
|
|
164
|
-
</p>
|
|
165
|
-
);
|
|
276
|
+
return <p className={cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className)}>{empty}</p>;
|
|
166
277
|
}
|
|
167
278
|
|
|
279
|
+
const spacer = (height: number) => (
|
|
280
|
+
// Stands in for the rows outside the window. Hidden: it is no row of the table.
|
|
281
|
+
<tr aria-hidden="true" style={{ height }}>
|
|
282
|
+
<td colSpan={columns.length} className="p-0" />
|
|
283
|
+
</tr>
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// A scroll area must be reachable from the keyboard: through its rows or header buttons when
|
|
287
|
+
// it has them, else as a tab stop of its own.
|
|
288
|
+
const scrollStop = windowed !== undefined && !onRowClick && !columns.some(isSortable);
|
|
289
|
+
|
|
168
290
|
return (
|
|
169
|
-
<div
|
|
170
|
-
|
|
291
|
+
<div
|
|
292
|
+
ref={scrollRef}
|
|
293
|
+
className={cn("w-full", windowed ? scrollStop && focusRingInset : "overflow-x-auto", className)}
|
|
294
|
+
// What the window arithmetic relies on is inline rather than a class, so it holds under
|
|
295
|
+
// any stylesheet: the scroll area, the fixed layout, the sticky header, the row height.
|
|
296
|
+
style={windowed ? { overflow: "auto", height: windowed.height ?? "100%" } : undefined}
|
|
297
|
+
onScroll={windowed ? (event) => setScrollTop(event.currentTarget.scrollTop) : undefined}
|
|
298
|
+
{...(scrollStop ? { tabIndex: 0, role: "region", "aria-label": caption ?? "Table" } : {})}
|
|
299
|
+
>
|
|
300
|
+
<table
|
|
301
|
+
className={cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]"))}
|
|
302
|
+
style={windowed ? { tableLayout: "fixed" } : undefined}
|
|
303
|
+
aria-rowcount={windowed ? order.length + 1 : undefined}
|
|
304
|
+
>
|
|
171
305
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
172
|
-
<thead>
|
|
173
|
-
<tr className="border-b border-line">
|
|
306
|
+
<thead ref={headRef}>
|
|
307
|
+
<tr className="border-b border-line" aria-rowindex={windowed ? 1 : undefined}>
|
|
174
308
|
{columns.map((column) => {
|
|
175
309
|
const sortable = isSortable(column);
|
|
176
310
|
const direction = sortable && sort?.key === column.key ? sort.direction : undefined;
|
|
@@ -179,7 +313,13 @@ export function Table<Row>({
|
|
|
179
313
|
<th
|
|
180
314
|
key={column.key}
|
|
181
315
|
scope="col"
|
|
182
|
-
style={
|
|
316
|
+
style={
|
|
317
|
+
windowed
|
|
318
|
+
? { position: "sticky", top: 0, zIndex: 1, ...(column.width ? { width: column.width } : {}) }
|
|
319
|
+
: column.width
|
|
320
|
+
? { width: column.width }
|
|
321
|
+
: undefined
|
|
322
|
+
}
|
|
183
323
|
// Only the sorted header: `aria-sort="none"` on every other one would be
|
|
184
324
|
// announced on each, and tells a reader nothing.
|
|
185
325
|
aria-sort={direction}
|
|
@@ -190,6 +330,8 @@ export function Table<Row>({
|
|
|
190
330
|
"font-medium text-fg-muted last:pr-0",
|
|
191
331
|
byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"),
|
|
192
332
|
column.numeric && "text-right",
|
|
333
|
+
// A sticky cell leaves its row's border behind: it draws its own rule.
|
|
334
|
+
windowed && "bg-surface shadow-[inset_0_-1px_0_var(--line)]",
|
|
193
335
|
)}
|
|
194
336
|
>
|
|
195
337
|
{sortable ? (
|
|
@@ -213,11 +355,7 @@ export function Table<Row>({
|
|
|
213
355
|
{column.header}
|
|
214
356
|
<SortIcon
|
|
215
357
|
aria-hidden
|
|
216
|
-
className={cn(
|
|
217
|
-
"shrink-0",
|
|
218
|
-
byDensity(density, "size-3", "size-2.5"),
|
|
219
|
-
!direction && "text-fg-subtle",
|
|
220
|
-
)}
|
|
358
|
+
className={cn("shrink-0", byDensity(density, "size-3", "size-2.5"), !direction && "text-fg-subtle")}
|
|
221
359
|
/>
|
|
222
360
|
</button>
|
|
223
361
|
) : (
|
|
@@ -228,16 +366,23 @@ export function Table<Row>({
|
|
|
228
366
|
})}
|
|
229
367
|
</tr>
|
|
230
368
|
</thead>
|
|
231
|
-
<tbody>
|
|
232
|
-
{
|
|
369
|
+
<tbody ref={bodyRef}>
|
|
370
|
+
{range.before > 0 && spacer(range.before)}
|
|
371
|
+
{order.slice(range.start, range.end).map((index, offset) => {
|
|
233
372
|
const row = rows[index]!;
|
|
234
373
|
const active = isRowActive?.(row, index) ?? false;
|
|
374
|
+
const position = range.start + offset;
|
|
235
375
|
return (
|
|
236
376
|
<tr
|
|
237
377
|
key={rowKey(row, index)}
|
|
378
|
+
data-position={position}
|
|
379
|
+
aria-rowindex={windowed ? position + 2 : undefined}
|
|
380
|
+
style={windowed ? { height: rowHeight } : undefined}
|
|
238
381
|
data-state={active ? "active" : undefined}
|
|
239
382
|
className={cn(
|
|
240
|
-
|
|
383
|
+
// A windowed row's rule is a shadow on its cells: a collapsed border would add
|
|
384
|
+
// half a pixel to every row, and the arithmetic counts whole rows.
|
|
385
|
+
!windowed && "border-b border-line/60 last:border-0",
|
|
241
386
|
onRowClick && "cursor-pointer hover:bg-raised",
|
|
242
387
|
active && "bg-signal/10",
|
|
243
388
|
)}
|
|
@@ -256,7 +401,8 @@ export function Table<Row>({
|
|
|
256
401
|
if (event.key === "Enter" || event.key === " ") {
|
|
257
402
|
event.preventDefault();
|
|
258
403
|
onRowClick(row, index, event);
|
|
259
|
-
|
|
404
|
+
// Navigation keys belong to a control inside the row while it has focus.
|
|
405
|
+
} else if (event.target === event.currentTarget && moveFocus(event, position)) event.preventDefault();
|
|
260
406
|
}
|
|
261
407
|
: undefined
|
|
262
408
|
}
|
|
@@ -270,7 +416,10 @@ export function Table<Row>({
|
|
|
270
416
|
"align-middle text-fg last:pr-0",
|
|
271
417
|
byDensity(density, "py-2 pr-3", "py-0.5 pr-2"),
|
|
272
418
|
column.numeric && "text-right font-mono tabular-nums",
|
|
419
|
+
windowed && "shadow-[inset_0_-1px_0_color-mix(in_oklab,var(--line)_60%,transparent)]",
|
|
273
420
|
)}
|
|
421
|
+
// One line per row keeps every row `rowHeight` tall.
|
|
422
|
+
style={windowed ? { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } : undefined}
|
|
274
423
|
>
|
|
275
424
|
{column.cell(row)}
|
|
276
425
|
</td>
|
|
@@ -278,6 +427,7 @@ export function Table<Row>({
|
|
|
278
427
|
</tr>
|
|
279
428
|
);
|
|
280
429
|
})}
|
|
430
|
+
{range.after > 0 && spacer(range.after)}
|
|
281
431
|
</tbody>
|
|
282
432
|
</table>
|
|
283
433
|
</div>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Multi-line text entry.
|
|
3
|
+
*
|
|
4
|
+
* The auto-grow is measured, not CSS: `field-sizing: content` would need no script, but it
|
|
5
|
+
* is not in every engine the packages run in (the desktop shells embed WebKit), and it
|
|
6
|
+
* cannot stop at a row count. The textarea renders `minRows` tall on the server, so the
|
|
7
|
+
* first paint already has the right floor; after mount its height follows the content
|
|
8
|
+
* between the two bounds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { type ChangeEvent, type ComponentProps, type Ref, useCallback, useLayoutEffect, useRef } from "react";
|
|
12
|
+
|
|
13
|
+
import { useFieldDescription } from "./Field";
|
|
14
|
+
import { controlClasses } from "./Input";
|
|
15
|
+
import { cn } from "./cn";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Props of `Textarea`: every `<textarea>` prop, `ref` included, plus an invalid state, the
|
|
19
|
+
* mono face, and growing with the content between `minRows` and `maxRows`.
|
|
20
|
+
*/
|
|
21
|
+
export type TextareaProps = ComponentProps<"textarea"> & {
|
|
22
|
+
/** Called with the new text on every edit, alongside the native `onChange`. */
|
|
23
|
+
onValueChange?: ((value: string) => void) | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* Marks the value as wrong: `aria-invalid`, `data-invalid` and a border in the defect
|
|
26
|
+
* colour. Inside a `Field` with an `error` it is on without this. Say what is wrong in
|
|
27
|
+
* text too (the field's `error`): the border alone is a colour.
|
|
28
|
+
*/
|
|
29
|
+
invalid?: boolean | undefined;
|
|
30
|
+
/** Sets the text in IBM Plex Mono, for code, JSON, lists, or strings whose characters all matter. */
|
|
31
|
+
mono?: boolean | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Grows with the content from this many lines. Set it or `maxRows` to turn growing on;
|
|
34
|
+
* `rows` is then ignored.
|
|
35
|
+
*/
|
|
36
|
+
minRows?: number | undefined;
|
|
37
|
+
/** Stops growing at this many lines and scrolls beyond them. Set it or `minRows` to turn growing on. */
|
|
38
|
+
maxRows?: number | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Sets a ref of either kind. */
|
|
42
|
+
function assign<T>(ref: Ref<T> | undefined, value: T | null): void {
|
|
43
|
+
if (typeof ref === "function") ref(value);
|
|
44
|
+
else if (ref) ref.current = value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fits a textarea's height to its content between `minRows` and `maxRows` lines, and scrolls
|
|
49
|
+
* only past the cap.
|
|
50
|
+
*/
|
|
51
|
+
function fit(node: HTMLTextAreaElement, minRows: number, maxRows: number | undefined): void {
|
|
52
|
+
const style = getComputedStyle(node);
|
|
53
|
+
// `normal` is no length: take the usual 1.2 of the font size.
|
|
54
|
+
const line = Number.parseFloat(style.lineHeight) || Number.parseFloat(style.fontSize) * 1.2 || 20;
|
|
55
|
+
const border = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
|
|
56
|
+
const chrome = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom) + border;
|
|
57
|
+
const min = minRows * line + chrome;
|
|
58
|
+
const max = maxRows === undefined ? Number.POSITIVE_INFINITY : Math.max(min, maxRows * line + chrome);
|
|
59
|
+
// Measure the content at the smallest height, so the box can shrink as well as grow.
|
|
60
|
+
node.style.height = `${min}px`;
|
|
61
|
+
const content = node.scrollHeight + border;
|
|
62
|
+
node.style.height = `${Math.min(Math.max(content, min), max)}px`;
|
|
63
|
+
node.style.overflowY = content > max + 0.5 ? "auto" : "hidden";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Multi-line text, on the shared control palette. Takes every `<textarea>` prop, `ref`
|
|
68
|
+
* included; inside a `Field` it is described by the field's description and error, and
|
|
69
|
+
* shown invalid while the field has an error.
|
|
70
|
+
*
|
|
71
|
+
* With `minRows` or `maxRows` it grows with its content between the two (and scrolls past
|
|
72
|
+
* `maxRows`); without them it is `rows` tall and the reader can resize it. State is exposed
|
|
73
|
+
* as `data-invalid`.
|
|
74
|
+
*/
|
|
75
|
+
export function Textarea({
|
|
76
|
+
className,
|
|
77
|
+
"aria-describedby": describedBy,
|
|
78
|
+
invalid,
|
|
79
|
+
mono = false,
|
|
80
|
+
minRows,
|
|
81
|
+
maxRows,
|
|
82
|
+
rows,
|
|
83
|
+
value,
|
|
84
|
+
onChange,
|
|
85
|
+
onValueChange,
|
|
86
|
+
ref,
|
|
87
|
+
...rest
|
|
88
|
+
}: TextareaProps) {
|
|
89
|
+
const field = useFieldDescription(describedBy);
|
|
90
|
+
const isInvalid = invalid === true || field["aria-invalid"] === true;
|
|
91
|
+
const grows = minRows !== undefined || maxRows !== undefined;
|
|
92
|
+
const floor = Math.max(1, Math.floor(minRows ?? 1));
|
|
93
|
+
const cap = maxRows === undefined ? undefined : Math.max(floor, Math.floor(maxRows));
|
|
94
|
+
|
|
95
|
+
const node = useRef<HTMLTextAreaElement | null>(null);
|
|
96
|
+
const setNode = useCallback(
|
|
97
|
+
(element: HTMLTextAreaElement | null) => {
|
|
98
|
+
node.current = element;
|
|
99
|
+
assign(ref, element);
|
|
100
|
+
},
|
|
101
|
+
[ref],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// On mount, and when a controlled value changes from outside (typing refits in `change`).
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
if (grows && node.current) fit(node.current, floor, cap);
|
|
107
|
+
}, [grows, floor, cap, mono, value]);
|
|
108
|
+
|
|
109
|
+
// A change of width rewraps the text.
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
const element = node.current;
|
|
112
|
+
if (!grows || !element || typeof ResizeObserver === "undefined") return undefined;
|
|
113
|
+
let width = element.clientWidth;
|
|
114
|
+
const observer = new ResizeObserver(() => {
|
|
115
|
+
if (element.clientWidth === width) return;
|
|
116
|
+
width = element.clientWidth;
|
|
117
|
+
fit(element, floor, cap);
|
|
118
|
+
});
|
|
119
|
+
observer.observe(element);
|
|
120
|
+
return () => observer.disconnect();
|
|
121
|
+
}, [grows, floor, cap]);
|
|
122
|
+
|
|
123
|
+
const change = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
124
|
+
onChange?.(event);
|
|
125
|
+
onValueChange?.(event.currentTarget.value);
|
|
126
|
+
if (grows) fit(event.currentTarget, floor, cap);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<textarea
|
|
131
|
+
{...field}
|
|
132
|
+
aria-invalid={isInvalid || undefined}
|
|
133
|
+
data-invalid={isInvalid ? "" : undefined}
|
|
134
|
+
{...rest}
|
|
135
|
+
{...(value === undefined ? {} : { value })}
|
|
136
|
+
ref={setNode}
|
|
137
|
+
rows={grows ? floor : rows}
|
|
138
|
+
onChange={change}
|
|
139
|
+
className={cn(
|
|
140
|
+
controlClasses,
|
|
141
|
+
"py-1.5",
|
|
142
|
+
grows && "resize-none",
|
|
143
|
+
mono && "font-mono",
|
|
144
|
+
"data-invalid:border-defect data-invalid:hover:border-defect",
|
|
145
|
+
className,
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|