@vitavision/ui 0.11.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 +8 -5
- package/dist/index.d.ts +255 -12
- package/dist/index.js +599 -44
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Tabs.tsx +3 -10
- package/src/components/ThemeToggle.tsx +3 -8
- 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 +331 -85
- 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 +11 -41
- package/src/components/ui/listboxModel.ts +1 -6
- package/src/components/ui/tableSort.ts +114 -0
- package/src/components/ui/tableWindow.ts +96 -0
- package/src/fonts/fonts.css +1 -1
- package/src/index.ts +1 -8
- package/src/styles.css +17 -7
- package/src/theme.ts +2 -7
|
@@ -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
|
}
|
|
@@ -4,12 +4,24 @@
|
|
|
4
4
|
* `numeric` is the column property that matters here. Quantities are set in mono with
|
|
5
5
|
* tabular figures and aligned right, so a column of scores or measurements can be compared
|
|
6
6
|
* by eye down its decimal point rather than read one row at a time.
|
|
7
|
+
*
|
|
8
|
+
* Sorting is per column and follows the sortable-table pattern of the ARIA Authoring
|
|
9
|
+
* Practices: the header's content is a button, and only the sorted header carries
|
|
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`.
|
|
7
16
|
*/
|
|
8
17
|
|
|
9
|
-
import
|
|
18
|
+
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
|
19
|
+
import { type KeyboardEvent, type MouseEvent, type ReactNode, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
20
|
|
|
11
21
|
import { byDensity, useDensity } from "./Density";
|
|
12
|
-
import { cn } from "./cn";
|
|
22
|
+
import { cn, focusRing, focusRingInset } from "./cn";
|
|
23
|
+
import { isSortable, nextSort, type SortDirection, sortOrder, type TableSort } from "./tableSort";
|
|
24
|
+
import { navigate, rowWindow, scrollTopFor } from "./tableWindow";
|
|
13
25
|
|
|
14
26
|
/** One column of a `Table`: its header and how to render a row's cell. */
|
|
15
27
|
export type Column<Row> = {
|
|
@@ -23,14 +35,76 @@ export type Column<Row> = {
|
|
|
23
35
|
width?: string;
|
|
24
36
|
/** The cell's content for a row. */
|
|
25
37
|
cell: (row: Row) => ReactNode;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the header sorts the table. Defaults to `true` when the column has a `sortValue`
|
|
40
|
+
* or a `compare`. Set it `true` with neither for a column the app sorts itself (see the
|
|
41
|
+
* table's `manualSort`), or `false` to keep a column with a `sortValue` unsortable.
|
|
42
|
+
*/
|
|
43
|
+
sortable?: boolean | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The value the column sorts by. Numbers compare by value; strings by a fixed English
|
|
46
|
+
* collation that reads digit runs as numbers ("frame 9" before "frame 10"), so a server
|
|
47
|
+
* render and the browser agree; numbers come before strings. `null`, `undefined` and `NaN`
|
|
48
|
+
* go last in both directions.
|
|
49
|
+
*/
|
|
50
|
+
sortValue?: ((row: Row) => number | string | null | undefined) | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* A full ascending comparison, for an order one value cannot express (two fields, a rank):
|
|
53
|
+
* negative when `a` comes first. Takes precedence over `sortValue`. Reversed for
|
|
54
|
+
* `"descending"`; rows that compare equal keep their order in `rows` either way.
|
|
55
|
+
*/
|
|
56
|
+
compare?: ((a: Row, b: Row) => number) | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The direction of the first click on the header. Defaults to `"ascending"`; `"descending"`
|
|
59
|
+
* suits a score, where the best comes first.
|
|
60
|
+
*/
|
|
61
|
+
firstSort?: SortDirection | undefined;
|
|
26
62
|
};
|
|
27
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
|
+
|
|
87
|
+
/** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
|
|
88
|
+
const SORT_ICONS = { ascending: ArrowUp, descending: ArrowDown, none: ArrowUpDown } as const;
|
|
89
|
+
|
|
28
90
|
/**
|
|
29
91
|
* A table of rows, with numeric columns set in mono and right-aligned.
|
|
30
92
|
*
|
|
31
93
|
* With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
|
|
32
94
|
* their table semantics. The active row carries `aria-current` and `data-state="active"`;
|
|
33
95
|
* padding follows the density in force. With no rows it renders the `empty` message instead.
|
|
96
|
+
*
|
|
97
|
+
* A column with a `sortValue` or a `compare` gets a header button: each click moves its sort
|
|
98
|
+
* from the column's `firstSort` direction to the other one, then back to the rows' own order.
|
|
99
|
+
* The sorted header carries `aria-sort` and `data-sort` (`"ascending"` or `"descending"`);
|
|
100
|
+
* other sortable headers carry `data-sort="none"`. The sort is kept by the table (from
|
|
101
|
+
* `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
|
|
102
|
+
* show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
|
|
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 `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
|
|
107
|
+
* rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
|
|
34
108
|
*/
|
|
35
109
|
export function Table<Row>({
|
|
36
110
|
columns,
|
|
@@ -42,12 +116,17 @@ export function Table<Row>({
|
|
|
42
116
|
onRowClick,
|
|
43
117
|
isRowActive,
|
|
44
118
|
onRowHover,
|
|
119
|
+
sort: controlledSort,
|
|
120
|
+
defaultSort = null,
|
|
121
|
+
onSortChange,
|
|
122
|
+
manualSort = false,
|
|
123
|
+
windowed,
|
|
45
124
|
}: {
|
|
46
125
|
/** The columns, in order. */
|
|
47
126
|
columns: Column<Row>[];
|
|
48
|
-
/** The rows, in order. */
|
|
127
|
+
/** The rows, in their own order: the order shown while the table is not sorted. */
|
|
49
128
|
rows: Row[];
|
|
50
|
-
/** A stable key per row. */
|
|
129
|
+
/** A stable key per row. `index` is the row's position in `rows`, whatever the sort. */
|
|
51
130
|
rowKey: (row: Row, index: number) => string | number;
|
|
52
131
|
/** Shown instead of the table when there are no rows. Defaults to "Nothing here.". */
|
|
53
132
|
empty?: ReactNode;
|
|
@@ -58,103 +137,270 @@ export function Table<Row>({
|
|
|
58
137
|
/** Makes rows activatable. Keyboard-reachable, so a table used as a list of
|
|
59
138
|
* destinations is not a mouse-only control. The event is passed so a caller can read
|
|
60
139
|
* modifiers — a list kept in step with a canvas needs shift-range and meta-toggle to mean
|
|
61
|
-
* the same thing in both places.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
index: number,
|
|
65
|
-
event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
|
|
66
|
-
) => void;
|
|
140
|
+
* the same thing in both places. `index` is the row's position in `rows`, whatever the
|
|
141
|
+
* sort. */
|
|
142
|
+
onRowClick?: (row: Row, index: number, event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>) => void;
|
|
67
143
|
/** Marks the row that is current — the selected match, the open frame. Rendered as
|
|
68
|
-
* `aria-current`, so it is announced rather than only tinted.
|
|
144
|
+
* `aria-current`, so it is announced rather than only tinted. `index` is the row's
|
|
145
|
+
* position in `rows`, whatever the sort. */
|
|
69
146
|
isRowActive?: (row: Row, index: number) => boolean;
|
|
70
147
|
/** Pointer enter/leave, for tables kept in step with a canvas overlay.
|
|
71
|
-
* `null` on leave. */
|
|
148
|
+
* `null` on leave. `index` is the row's position in `rows`, whatever the sort. */
|
|
72
149
|
onRowHover?: (row: Row | null, index: number | null) => void;
|
|
150
|
+
/**
|
|
151
|
+
* The sort, controlled: a column `key` and a direction, or `null` for the rows' own order.
|
|
152
|
+
* Leave it undefined and the table keeps the sort itself, starting from `defaultSort`.
|
|
153
|
+
*/
|
|
154
|
+
sort?: TableSort | null | undefined;
|
|
155
|
+
/** The sort the table starts with when `sort` is not given. Defaults to `null`, the rows' own order. */
|
|
156
|
+
defaultSort?: TableSort | null | undefined;
|
|
157
|
+
/**
|
|
158
|
+
* Called on a header click with the new sort: the column's `firstSort` direction, then the
|
|
159
|
+
* other, then `null`. A click on another column starts that column at its `firstSort`.
|
|
160
|
+
*/
|
|
161
|
+
onSortChange?: ((sort: TableSort | null) => void) | undefined;
|
|
162
|
+
/**
|
|
163
|
+
* The app orders the rows (on a server, say): the headers show the sort and report clicks,
|
|
164
|
+
* and the rows render exactly as given. Mark the columns it can sort `sortable: true`.
|
|
165
|
+
*/
|
|
166
|
+
manualSort?: boolean | undefined;
|
|
167
|
+
/**
|
|
168
|
+
* Render only the rows in view, for a long table: every row `rowHeight` tall, in a scroll
|
|
169
|
+
* area of `height` (or the container's height) under a sticky header. Sorting, row
|
|
170
|
+
* callbacks and keyboard navigation work as without it.
|
|
171
|
+
*/
|
|
172
|
+
windowed?: TableWindowing | undefined;
|
|
73
173
|
}) {
|
|
74
174
|
const density = useDensity();
|
|
175
|
+
const [ownSort, setOwnSort] = useState<TableSort | null>(defaultSort);
|
|
176
|
+
const controlled = controlledSort !== undefined;
|
|
177
|
+
const sort = controlled ? controlledSort : ownSort;
|
|
178
|
+
// Indices into `rows`, so a row keeps its own index (and key) for the callbacks below.
|
|
179
|
+
const order = useMemo(
|
|
180
|
+
() => (manualSort ? rows.map((_, index) => index) : sortOrder(rows, columns, sort)),
|
|
181
|
+
[rows, columns, sort, manualSort],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const changeSort = (column: Column<Row>) => {
|
|
185
|
+
const next = nextSort(sort, column);
|
|
186
|
+
if (!controlled) setOwnSort(next);
|
|
187
|
+
onSortChange?.(next);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const rowHeight = windowed?.rowHeight ?? 0;
|
|
191
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
192
|
+
const headRef = useRef<HTMLTableSectionElement>(null);
|
|
193
|
+
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
|
194
|
+
// The row to focus once it is rendered: a key press may scroll to a row that is not in the DOM yet.
|
|
195
|
+
const pendingFocus = useRef<number | null>(null);
|
|
196
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
197
|
+
const [measured, setMeasured] = useState<{ viewport: number; header: number } | null>(null);
|
|
198
|
+
const viewportHeight = measured?.viewport ?? windowed?.height ?? INITIAL_ROWS * rowHeight;
|
|
199
|
+
const headerHeight = measured?.header ?? 0;
|
|
200
|
+
const range = windowed
|
|
201
|
+
? rowWindow({ rowCount: order.length, rowHeight, scrollTop, viewportHeight, headerHeight, overscan: windowed.overscan ?? 8 })
|
|
202
|
+
: { start: 0, end: order.length, before: 0, after: 0 };
|
|
203
|
+
|
|
204
|
+
const isWindowed = windowed !== undefined;
|
|
205
|
+
useLayoutEffect(() => {
|
|
206
|
+
const scroller = scrollRef.current;
|
|
207
|
+
if (!isWindowed || !scroller || typeof ResizeObserver === "undefined") return;
|
|
208
|
+
const measure = () => {
|
|
209
|
+
const next = { viewport: scroller.clientHeight, header: headRef.current?.offsetHeight ?? 0 };
|
|
210
|
+
setMeasured((last) => (last?.viewport === next.viewport && last.header === next.header ? last : next));
|
|
211
|
+
};
|
|
212
|
+
measure();
|
|
213
|
+
const observer = new ResizeObserver(measure);
|
|
214
|
+
observer.observe(scroller);
|
|
215
|
+
return () => observer.disconnect();
|
|
216
|
+
}, [isWindowed]);
|
|
217
|
+
|
|
218
|
+
useLayoutEffect(() => {
|
|
219
|
+
const target = pendingFocus.current;
|
|
220
|
+
if (target === null) return;
|
|
221
|
+
const row = bodyRef.current?.querySelector<HTMLElement>(`tr[data-position="${target}"]`);
|
|
222
|
+
if (!row) return;
|
|
223
|
+
pendingFocus.current = null;
|
|
224
|
+
row.focus({ preventScroll: isWindowed });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/** Moves focus from the row at `position` for a navigation key; `true` when the key was one. */
|
|
228
|
+
const moveFocus = (key: string, position: number): boolean => {
|
|
229
|
+
const page = windowed ? Math.floor((viewportHeight - headerHeight) / rowHeight) : 10;
|
|
230
|
+
const target = navigate(key, position, order.length, page);
|
|
231
|
+
if (target === null) return false;
|
|
232
|
+
pendingFocus.current = target;
|
|
233
|
+
const scroller = scrollRef.current;
|
|
234
|
+
if (windowed && scroller) {
|
|
235
|
+
const top = scrollTopFor(target, { rowHeight, scrollTop: scroller.scrollTop, viewportHeight, headerHeight });
|
|
236
|
+
scroller.scrollTop = top;
|
|
237
|
+
// Render the target's window now rather than on the scroll event, so it can take focus.
|
|
238
|
+
setScrollTop(scroller.scrollTop);
|
|
239
|
+
}
|
|
240
|
+
const row = bodyRef.current?.querySelector<HTMLElement>(`tr[data-position="${target}"]`);
|
|
241
|
+
if (row && !windowed) {
|
|
242
|
+
pendingFocus.current = null;
|
|
243
|
+
row.focus();
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
};
|
|
75
247
|
|
|
76
248
|
if (rows.length === 0) {
|
|
77
|
-
return (
|
|
78
|
-
<p
|
|
79
|
-
className={cn(
|
|
80
|
-
"text-center text-fg-muted",
|
|
81
|
-
byDensity(density, "py-6 text-sm", "py-3 text-xs"),
|
|
82
|
-
className,
|
|
83
|
-
)}
|
|
84
|
-
>
|
|
85
|
-
{empty}
|
|
86
|
-
</p>
|
|
87
|
-
);
|
|
249
|
+
return <p className={cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className)}>{empty}</p>;
|
|
88
250
|
}
|
|
89
251
|
|
|
252
|
+
const spacer = (height: number) => (
|
|
253
|
+
// Stands in for the rows outside the window. Hidden: it is no row of the table.
|
|
254
|
+
<tr aria-hidden="true" style={{ height }}>
|
|
255
|
+
<td colSpan={columns.length} className="p-0" />
|
|
256
|
+
</tr>
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// A scroll area must be reachable from the keyboard: through its rows or header buttons when
|
|
260
|
+
// it has them, else as a tab stop of its own.
|
|
261
|
+
const scrollStop = windowed !== undefined && !onRowClick && !columns.some(isSortable);
|
|
262
|
+
|
|
90
263
|
return (
|
|
91
|
-
<div
|
|
92
|
-
|
|
264
|
+
<div
|
|
265
|
+
ref={scrollRef}
|
|
266
|
+
className={cn("w-full", windowed ? scrollStop && focusRingInset : "overflow-x-auto", className)}
|
|
267
|
+
// What the window arithmetic relies on is inline rather than a class, so it holds under
|
|
268
|
+
// any stylesheet: the scroll area, the fixed layout, the sticky header, the row height.
|
|
269
|
+
style={windowed ? { overflow: "auto", height: windowed.height ?? "100%" } : undefined}
|
|
270
|
+
onScroll={windowed ? (event) => setScrollTop(event.currentTarget.scrollTop) : undefined}
|
|
271
|
+
{...(scrollStop ? { tabIndex: 0, role: "region", "aria-label": caption ?? "Table" } : {})}
|
|
272
|
+
>
|
|
273
|
+
<table
|
|
274
|
+
className={cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]"))}
|
|
275
|
+
style={windowed ? { tableLayout: "fixed" } : undefined}
|
|
276
|
+
aria-rowcount={windowed ? order.length + 1 : undefined}
|
|
277
|
+
>
|
|
93
278
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
94
|
-
<thead>
|
|
95
|
-
<tr className="border-b border-line">
|
|
96
|
-
{columns.map((column) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"font-medium text-fg-muted",
|
|
103
|
-
byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"),
|
|
104
|
-
column.numeric && "text-right",
|
|
105
|
-
)}
|
|
106
|
-
>
|
|
107
|
-
{column.header}
|
|
108
|
-
</th>
|
|
109
|
-
))}
|
|
110
|
-
</tr>
|
|
111
|
-
</thead>
|
|
112
|
-
<tbody>
|
|
113
|
-
{rows.map((row, index) => (
|
|
114
|
-
<tr
|
|
115
|
-
key={rowKey(row, index)}
|
|
116
|
-
data-state={isRowActive?.(row, index) ? "active" : undefined}
|
|
117
|
-
className={cn(
|
|
118
|
-
"border-b border-line/60 last:border-0",
|
|
119
|
-
onRowClick && "cursor-pointer hover:bg-raised",
|
|
120
|
-
isRowActive?.(row, index) && "bg-signal/10",
|
|
121
|
-
)}
|
|
122
|
-
tabIndex={onRowClick ? 0 : undefined}
|
|
123
|
-
// Deliberately *not* `role="button"`. Overriding a `<tr>`'s implicit `row`
|
|
124
|
-
// makes its `<td>`s' implicit `cell` invalid too — the cells have no row to
|
|
125
|
-
// belong to — so a screen reader stops announcing the table as a table and a
|
|
126
|
-
// reader loses the column each value came from, which on a table of
|
|
127
|
-
// quantities is the whole content. `tabIndex` plus the Enter/Space handler
|
|
128
|
-
// below is what makes the row reachable; the role stays what the element is.
|
|
129
|
-
aria-current={isRowActive?.(row, index) ? true : undefined}
|
|
130
|
-
onClick={onRowClick ? (event) => onRowClick(row, index, event) : undefined}
|
|
131
|
-
onKeyDown={
|
|
132
|
-
onRowClick
|
|
133
|
-
? (event) => {
|
|
134
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
135
|
-
event.preventDefault();
|
|
136
|
-
onRowClick(row, index, event);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
: undefined
|
|
140
|
-
}
|
|
141
|
-
onPointerEnter={onRowHover ? () => onRowHover(row, index) : undefined}
|
|
142
|
-
onPointerLeave={onRowHover ? () => onRowHover(null, null) : undefined}
|
|
143
|
-
>
|
|
144
|
-
{columns.map((column) => (
|
|
145
|
-
<td
|
|
279
|
+
<thead ref={headRef}>
|
|
280
|
+
<tr className="border-b border-line" aria-rowindex={windowed ? 1 : undefined}>
|
|
281
|
+
{columns.map((column) => {
|
|
282
|
+
const sortable = isSortable(column);
|
|
283
|
+
const direction = sortable && sort?.key === column.key ? sort.direction : undefined;
|
|
284
|
+
const SortIcon = SORT_ICONS[direction ?? "none"];
|
|
285
|
+
return (
|
|
286
|
+
<th
|
|
146
287
|
key={column.key}
|
|
288
|
+
scope="col"
|
|
289
|
+
style={
|
|
290
|
+
windowed
|
|
291
|
+
? { position: "sticky", top: 0, zIndex: 1, ...(column.width ? { width: column.width } : {}) }
|
|
292
|
+
: column.width
|
|
293
|
+
? { width: column.width }
|
|
294
|
+
: undefined
|
|
295
|
+
}
|
|
296
|
+
// Only the sorted header: `aria-sort="none"` on every other one would be
|
|
297
|
+
// announced on each, and tells a reader nothing.
|
|
298
|
+
aria-sort={direction}
|
|
299
|
+
data-sort={sortable ? (direction ?? "none") : undefined}
|
|
147
300
|
className={cn(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
301
|
+
// `last:pr-0`, as on the body cells: the last column's header ends where
|
|
302
|
+
// its values do, so a numeric header sits flush with its numbers.
|
|
303
|
+
"font-medium text-fg-muted last:pr-0",
|
|
304
|
+
byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"),
|
|
305
|
+
column.numeric && "text-right",
|
|
306
|
+
// A sticky cell leaves its row's border behind: it draws its own rule.
|
|
307
|
+
windowed && "bg-surface shadow-[inset_0_-1px_0_var(--line)]",
|
|
151
308
|
)}
|
|
152
309
|
>
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
310
|
+
{sortable ? (
|
|
311
|
+
<button
|
|
312
|
+
type="button"
|
|
313
|
+
onClick={() => changeSort(column)}
|
|
314
|
+
className={cn(
|
|
315
|
+
// Block-level `flex`, not `inline-flex`: an inline box would sit on the
|
|
316
|
+
// cell's line box and make a sortable header row 2px taller than a plain
|
|
317
|
+
// one. The padding, cancelled by the margin, gives the focus ring room
|
|
318
|
+
// without moving the label off the column's edge.
|
|
319
|
+
"-mx-1 flex w-fit max-w-full cursor-pointer items-center gap-1 rounded-control px-1",
|
|
320
|
+
"font-medium transition-colors hover:text-fg",
|
|
321
|
+
focusRing,
|
|
322
|
+
// The arrow sits on the inner side of a right-aligned header, so the
|
|
323
|
+
// label stays flush with the numbers under it.
|
|
324
|
+
column.numeric ? "ml-auto flex-row-reverse text-right" : "text-left",
|
|
325
|
+
direction && "text-fg",
|
|
326
|
+
)}
|
|
327
|
+
>
|
|
328
|
+
{column.header}
|
|
329
|
+
<SortIcon
|
|
330
|
+
aria-hidden
|
|
331
|
+
className={cn("shrink-0", byDensity(density, "size-3", "size-2.5"), !direction && "text-fg-subtle")}
|
|
332
|
+
/>
|
|
333
|
+
</button>
|
|
334
|
+
) : (
|
|
335
|
+
column.header
|
|
336
|
+
)}
|
|
337
|
+
</th>
|
|
338
|
+
);
|
|
339
|
+
})}
|
|
340
|
+
</tr>
|
|
341
|
+
</thead>
|
|
342
|
+
<tbody ref={bodyRef}>
|
|
343
|
+
{range.before > 0 && spacer(range.before)}
|
|
344
|
+
{order.slice(range.start, range.end).map((index, offset) => {
|
|
345
|
+
const row = rows[index]!;
|
|
346
|
+
const active = isRowActive?.(row, index) ?? false;
|
|
347
|
+
const position = range.start + offset;
|
|
348
|
+
return (
|
|
349
|
+
<tr
|
|
350
|
+
key={rowKey(row, index)}
|
|
351
|
+
data-position={position}
|
|
352
|
+
aria-rowindex={windowed ? position + 2 : undefined}
|
|
353
|
+
style={windowed ? { height: rowHeight } : undefined}
|
|
354
|
+
data-state={active ? "active" : undefined}
|
|
355
|
+
className={cn(
|
|
356
|
+
// A windowed row's rule is a shadow on its cells: a collapsed border would add
|
|
357
|
+
// half a pixel to every row, and the arithmetic counts whole rows.
|
|
358
|
+
!windowed && "border-b border-line/60 last:border-0",
|
|
359
|
+
onRowClick && "cursor-pointer hover:bg-raised",
|
|
360
|
+
active && "bg-signal/10",
|
|
361
|
+
)}
|
|
362
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
363
|
+
// Deliberately *not* `role="button"`. Overriding a `<tr>`'s implicit `row`
|
|
364
|
+
// makes its `<td>`s' implicit `cell` invalid too — the cells have no row to
|
|
365
|
+
// belong to — so a screen reader stops announcing the table as a table and a
|
|
366
|
+
// reader loses the column each value came from, which on a table of
|
|
367
|
+
// quantities is the whole content. `tabIndex` plus the Enter/Space handler
|
|
368
|
+
// below is what makes the row reachable; the role stays what the element is.
|
|
369
|
+
aria-current={active ? true : undefined}
|
|
370
|
+
onClick={onRowClick ? (event) => onRowClick(row, index, event) : undefined}
|
|
371
|
+
onKeyDown={
|
|
372
|
+
onRowClick
|
|
373
|
+
? (event) => {
|
|
374
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
onRowClick(row, index, event);
|
|
377
|
+
// Navigation keys belong to a control inside the row while it has focus.
|
|
378
|
+
} else if (event.target === event.currentTarget && moveFocus(event.key, position)) event.preventDefault();
|
|
379
|
+
}
|
|
380
|
+
: undefined
|
|
381
|
+
}
|
|
382
|
+
onPointerEnter={onRowHover ? () => onRowHover(row, index) : undefined}
|
|
383
|
+
onPointerLeave={onRowHover ? () => onRowHover(null, null) : undefined}
|
|
384
|
+
>
|
|
385
|
+
{columns.map((column) => (
|
|
386
|
+
<td
|
|
387
|
+
key={column.key}
|
|
388
|
+
className={cn(
|
|
389
|
+
"align-middle text-fg last:pr-0",
|
|
390
|
+
byDensity(density, "py-2 pr-3", "py-0.5 pr-2"),
|
|
391
|
+
column.numeric && "text-right font-mono tabular-nums",
|
|
392
|
+
windowed && "shadow-[inset_0_-1px_0_color-mix(in_oklab,var(--line)_60%,transparent)]",
|
|
393
|
+
)}
|
|
394
|
+
// One line per row keeps every row `rowHeight` tall.
|
|
395
|
+
style={windowed ? { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } : undefined}
|
|
396
|
+
>
|
|
397
|
+
{column.cell(row)}
|
|
398
|
+
</td>
|
|
399
|
+
))}
|
|
400
|
+
</tr>
|
|
401
|
+
);
|
|
402
|
+
})}
|
|
403
|
+
{range.after > 0 && spacer(range.after)}
|
|
158
404
|
</tbody>
|
|
159
405
|
</table>
|
|
160
406
|
</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
|
+
}
|