@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.
Files changed (39) hide show
  1. package/README.md +8 -5
  2. package/dist/index.d.ts +255 -12
  3. package/dist/index.js +599 -44
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/components/Tabs.tsx +3 -10
  7. package/src/components/ThemeToggle.tsx +3 -8
  8. package/src/components/ui/Badge.tsx +1 -4
  9. package/src/components/ui/Button.tsx +3 -15
  10. package/src/components/ui/Dialog.tsx +4 -19
  11. package/src/components/ui/Disclosure.tsx +2 -7
  12. package/src/components/ui/DropdownMenu.tsx +2 -16
  13. package/src/components/ui/Feedback.tsx +5 -9
  14. package/src/components/ui/Field.tsx +4 -15
  15. package/src/components/ui/HighlightedText.tsx +84 -0
  16. package/src/components/ui/Input.tsx +5 -43
  17. package/src/components/ui/Panel.tsx +4 -16
  18. package/src/components/ui/PoseInput.tsx +7 -5
  19. package/src/components/ui/Progress.tsx +80 -0
  20. package/src/components/ui/SegmentedControl.tsx +1 -3
  21. package/src/components/ui/Select.tsx +2 -10
  22. package/src/components/ui/Slider.tsx +2 -7
  23. package/src/components/ui/Table.tsx +331 -85
  24. package/src/components/ui/Textarea.tsx +149 -0
  25. package/src/components/ui/Toaster.tsx +3 -19
  26. package/src/components/ui/Toggle.tsx +1 -5
  27. package/src/components/ui/ToggleChip.tsx +1 -3
  28. package/src/components/ui/Tooltip.tsx +1 -5
  29. package/src/components/ui/VectorInput.tsx +1 -1
  30. package/src/components/ui/cn.ts +1 -2
  31. package/src/components/ui/highlightSpans.ts +110 -0
  32. package/src/components/ui/index.ts +11 -41
  33. package/src/components/ui/listboxModel.ts +1 -6
  34. package/src/components/ui/tableSort.ts +114 -0
  35. package/src/components/ui/tableWindow.ts +96 -0
  36. package/src/fonts/fonts.css +1 -1
  37. package/src/index.ts +1 -8
  38. package/src/styles.css +17 -7
  39. 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 type { KeyboardEvent, MouseEvent, ReactNode } from "react";
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
- onRowClick?: (
63
- row: Row,
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 className={cn("w-full overflow-x-auto", className)}>
92
- <table className={cn("w-full text-left", byDensity(density, "text-sm", "text-[11px]"))}>
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
- <th
98
- key={column.key}
99
- scope="col"
100
- style={column.width ? { width: column.width } : undefined}
101
- className={cn(
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
- "align-middle text-fg last:pr-0",
149
- byDensity(density, "py-2 pr-3", "py-0.5 pr-2"),
150
- column.numeric && "text-right font-mono tabular-nums",
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
- {column.cell(row)}
154
- </td>
155
- ))}
156
- </tr>
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
+ }