@vitavision/ui 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +6 -3
  2. package/dist/index.d.ts +171 -7
  3. package/dist/index.js +490 -61
  4. package/dist/index.js.map +1 -1
  5. package/package.json +1 -1
  6. package/src/components/Tabs.tsx +3 -10
  7. package/src/components/ThemeToggle.tsx +1 -5
  8. package/src/components/ui/Badge.tsx +1 -4
  9. package/src/components/ui/Button.tsx +3 -15
  10. package/src/components/ui/Dialog.tsx +4 -19
  11. package/src/components/ui/Disclosure.tsx +2 -7
  12. package/src/components/ui/DropdownMenu.tsx +2 -16
  13. package/src/components/ui/Feedback.tsx +5 -9
  14. package/src/components/ui/Field.tsx +4 -15
  15. package/src/components/ui/HighlightedText.tsx +84 -0
  16. package/src/components/ui/Input.tsx +5 -43
  17. package/src/components/ui/Panel.tsx +4 -16
  18. package/src/components/ui/PoseInput.tsx +7 -5
  19. package/src/components/ui/Progress.tsx +80 -0
  20. package/src/components/ui/SegmentedControl.tsx +1 -3
  21. package/src/components/ui/Select.tsx +2 -10
  22. package/src/components/ui/Slider.tsx +2 -7
  23. package/src/components/ui/Table.tsx +155 -32
  24. package/src/components/ui/Textarea.tsx +149 -0
  25. package/src/components/ui/Toaster.tsx +3 -19
  26. package/src/components/ui/Toggle.tsx +1 -5
  27. package/src/components/ui/ToggleChip.tsx +1 -3
  28. package/src/components/ui/Tooltip.tsx +1 -5
  29. package/src/components/ui/VectorInput.tsx +1 -1
  30. package/src/components/ui/cn.ts +1 -2
  31. package/src/components/ui/highlightSpans.ts +110 -0
  32. package/src/components/ui/index.ts +10 -41
  33. package/src/components/ui/listboxModel.ts +1 -6
  34. package/src/components/ui/tableSort.ts +2 -9
  35. package/src/components/ui/tableWindow.ts +96 -0
  36. package/src/index.ts +1 -8
  37. package/src/styles.css +14 -0
  38. package/src/theme.ts +2 -7
@@ -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,10 @@ 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 `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.
75
108
  */
76
109
  export function Table<Row>({
77
110
  columns,
@@ -87,6 +120,7 @@ export function Table<Row>({
87
120
  defaultSort = null,
88
121
  onSortChange,
89
122
  manualSort = false,
123
+ windowed,
90
124
  }: {
91
125
  /** The columns, in order. */
92
126
  columns: Column<Row>[];
@@ -105,11 +139,7 @@ export function Table<Row>({
105
139
  * modifiers — a list kept in step with a canvas needs shift-range and meta-toggle to mean
106
140
  * the same thing in both places. `index` is the row's position in `rows`, whatever the
107
141
  * sort. */
108
- onRowClick?: (
109
- row: Row,
110
- index: number,
111
- event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
112
- ) => void;
142
+ onRowClick?: (row: Row, index: number, event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>) => void;
113
143
  /** Marks the row that is current — the selected match, the open frame. Rendered as
114
144
  * `aria-current`, so it is announced rather than only tinted. `index` is the row's
115
145
  * position in `rows`, whatever the sort. */
@@ -134,6 +164,12 @@ export function Table<Row>({
134
164
  * and the rows render exactly as given. Mark the columns it can sort `sortable: true`.
135
165
  */
136
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;
137
173
  }) {
138
174
  const density = useDensity();
139
175
  const [ownSort, setOwnSort] = useState<TableSort | null>(defaultSort);
@@ -151,26 +187,97 @@ export function Table<Row>({
151
187
  onSortChange?.(next);
152
188
  };
153
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
+ };
247
+
154
248
  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
- );
249
+ return <p className={cn("text-center text-fg-muted", byDensity(density, "py-6 text-sm", "py-3 text-xs"), className)}>{empty}</p>;
166
250
  }
167
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
+
168
263
  return (
169
- <div className={cn("w-full overflow-x-auto", className)}>
170
- <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
+ >
171
278
  {caption && <caption className="sr-only">{caption}</caption>}
172
- <thead>
173
- <tr className="border-b border-line">
279
+ <thead ref={headRef}>
280
+ <tr className="border-b border-line" aria-rowindex={windowed ? 1 : undefined}>
174
281
  {columns.map((column) => {
175
282
  const sortable = isSortable(column);
176
283
  const direction = sortable && sort?.key === column.key ? sort.direction : undefined;
@@ -179,7 +286,13 @@ export function Table<Row>({
179
286
  <th
180
287
  key={column.key}
181
288
  scope="col"
182
- style={column.width ? { width: column.width } : undefined}
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
+ }
183
296
  // Only the sorted header: `aria-sort="none"` on every other one would be
184
297
  // announced on each, and tells a reader nothing.
185
298
  aria-sort={direction}
@@ -190,6 +303,8 @@ export function Table<Row>({
190
303
  "font-medium text-fg-muted last:pr-0",
191
304
  byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"),
192
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)]",
193
308
  )}
194
309
  >
195
310
  {sortable ? (
@@ -213,11 +328,7 @@ export function Table<Row>({
213
328
  {column.header}
214
329
  <SortIcon
215
330
  aria-hidden
216
- className={cn(
217
- "shrink-0",
218
- byDensity(density, "size-3", "size-2.5"),
219
- !direction && "text-fg-subtle",
220
- )}
331
+ className={cn("shrink-0", byDensity(density, "size-3", "size-2.5"), !direction && "text-fg-subtle")}
221
332
  />
222
333
  </button>
223
334
  ) : (
@@ -228,16 +339,23 @@ export function Table<Row>({
228
339
  })}
229
340
  </tr>
230
341
  </thead>
231
- <tbody>
232
- {order.map((index) => {
342
+ <tbody ref={bodyRef}>
343
+ {range.before > 0 && spacer(range.before)}
344
+ {order.slice(range.start, range.end).map((index, offset) => {
233
345
  const row = rows[index]!;
234
346
  const active = isRowActive?.(row, index) ?? false;
347
+ const position = range.start + offset;
235
348
  return (
236
349
  <tr
237
350
  key={rowKey(row, index)}
351
+ data-position={position}
352
+ aria-rowindex={windowed ? position + 2 : undefined}
353
+ style={windowed ? { height: rowHeight } : undefined}
238
354
  data-state={active ? "active" : undefined}
239
355
  className={cn(
240
- "border-b border-line/60 last:border-0",
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",
241
359
  onRowClick && "cursor-pointer hover:bg-raised",
242
360
  active && "bg-signal/10",
243
361
  )}
@@ -256,7 +374,8 @@ export function Table<Row>({
256
374
  if (event.key === "Enter" || event.key === " ") {
257
375
  event.preventDefault();
258
376
  onRowClick(row, index, event);
259
- }
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();
260
379
  }
261
380
  : undefined
262
381
  }
@@ -270,7 +389,10 @@ export function Table<Row>({
270
389
  "align-middle text-fg last:pr-0",
271
390
  byDensity(density, "py-2 pr-3", "py-0.5 pr-2"),
272
391
  column.numeric && "text-right font-mono tabular-nums",
392
+ windowed && "shadow-[inset_0_-1px_0_color-mix(in_oklab,var(--line)_60%,transparent)]",
273
393
  )}
394
+ // One line per row keeps every row `rowHeight` tall.
395
+ style={windowed ? { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } : undefined}
274
396
  >
275
397
  {column.cell(row)}
276
398
  </td>
@@ -278,6 +400,7 @@ export function Table<Row>({
278
400
  </tr>
279
401
  );
280
402
  })}
403
+ {range.after > 0 && spacer(range.after)}
281
404
  </tbody>
282
405
  </table>
283
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
+ }
@@ -64,34 +64,18 @@ export function Toaster({ store = defaultToastStore, limit = 5, className }: Toa
64
64
  onPointerLeave={() => setHovered(false)}
65
65
  onFocus={() => setFocused(true)}
66
66
  onBlur={onBlur}
67
- className={cn(
68
- "pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]",
69
- className,
70
- )}
67
+ className={cn("pointer-events-none fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)]", className)}
71
68
  >
72
69
  <ol aria-live="polite" className="flex flex-col gap-2">
73
70
  {shown.map((record) => (
74
- <ToastItem
75
- key={record.id}
76
- record={record}
77
- paused={hovered || focused}
78
- onDismiss={() => store.dismiss(record.id)}
79
- />
71
+ <ToastItem key={record.id} record={record} paused={hovered || focused} onDismiss={() => store.dismiss(record.id)} />
80
72
  ))}
81
73
  </ol>
82
74
  </section>
83
75
  );
84
76
  }
85
77
 
86
- function ToastItem({
87
- record,
88
- paused,
89
- onDismiss,
90
- }: {
91
- record: ToastRecord;
92
- paused: boolean;
93
- onDismiss: () => void;
94
- }) {
78
+ function ToastItem({ record, paused, onDismiss }: { record: ToastRecord; paused: boolean; onDismiss: () => void }) {
95
79
  const { border, icon, Icon } = TONE[record.tone];
96
80
  // What is left of the countdown, carried across pauses; restarted when the toast is
97
81
  // replaced in place (a new record under the same id).
@@ -136,11 +136,7 @@ export function Checkbox({
136
136
  )}
137
137
  >
138
138
  <RadixCheckbox.Indicator className="text-signal-fg">
139
- {checked === "indeterminate" ? (
140
- <Minus className="size-3" strokeWidth={3} />
141
- ) : (
142
- <Check className="size-3" strokeWidth={3} />
143
- )}
139
+ {checked === "indeterminate" ? <Minus className="size-3" strokeWidth={3} /> : <Check className="size-3" strokeWidth={3} />}
144
140
  </RadixCheckbox.Indicator>
145
141
  </RadixCheckbox.Root>
146
142
  );
@@ -87,9 +87,7 @@ export function ToggleChip({
87
87
  "inline-flex items-center gap-1.5 rounded-control border",
88
88
  SIZE_CLASSES[size],
89
89
  "whitespace-nowrap transition-colors",
90
- checked
91
- ? "border-line-strong bg-raised text-fg"
92
- : "border-line bg-transparent text-fg-muted hover:text-fg",
90
+ checked ? "border-line-strong bg-raised text-fg" : "border-line bg-transparent text-fg-muted hover:text-fg",
93
91
  "disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:text-fg-muted",
94
92
  focusRing,
95
93
  className,
@@ -87,11 +87,7 @@ export function InfoHint({
87
87
  }) {
88
88
  return (
89
89
  <Tooltip content={children}>
90
- <button
91
- type="button"
92
- aria-label={label}
93
- className={cn("rounded-full text-fg-subtle transition-colors hover:text-fg", focusRing)}
94
- >
90
+ <button type="button" aria-label={label} className={cn("rounded-full text-fg-subtle transition-colors hover:text-fg", focusRing)}>
95
91
  <Icon className="size-3.5" />
96
92
  </button>
97
93
  </Tooltip>
@@ -102,7 +102,7 @@ export function VectorInput({
102
102
  >
103
103
  {value.map((component, index) => (
104
104
  // The components are positional: index is their identity.
105
- // eslint-disable-next-line @eslint-react/no-array-index-key -- see above
105
+ // oxlint-disable-next-line react/no-array-index-key -- see above
106
106
  <label key={index} className="flex min-w-0 flex-1 items-center gap-1">
107
107
  <span className="shrink-0 font-mono text-[11px] text-fg-subtle">{labelOf(index)}</span>
108
108
  <ComponentField
@@ -31,8 +31,7 @@ export function cn(...inputs: ClassValue[]): string {
31
31
  *
32
32
  * `focus-visible` rather than `focus`, so a mouse click does not leave a ring behind.
33
33
  */
34
- export const focusRing =
35
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal";
34
+ export const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal";
36
35
 
37
36
  /**
38
37
  * The focus treatment for controls whose own box is the hit target, where an offset ring