@vitavision/ui 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/index.d.ts +171 -7
- package/dist/index.js +490 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Tabs.tsx +3 -10
- package/src/components/ThemeToggle.tsx +1 -5
- package/src/components/ui/Badge.tsx +1 -4
- package/src/components/ui/Button.tsx +3 -15
- package/src/components/ui/Dialog.tsx +4 -19
- package/src/components/ui/Disclosure.tsx +2 -7
- package/src/components/ui/DropdownMenu.tsx +2 -16
- package/src/components/ui/Feedback.tsx +5 -9
- package/src/components/ui/Field.tsx +4 -15
- package/src/components/ui/HighlightedText.tsx +84 -0
- package/src/components/ui/Input.tsx +5 -43
- package/src/components/ui/Panel.tsx +4 -16
- package/src/components/ui/PoseInput.tsx +7 -5
- package/src/components/ui/Progress.tsx +80 -0
- package/src/components/ui/SegmentedControl.tsx +1 -3
- package/src/components/ui/Select.tsx +2 -10
- package/src/components/ui/Slider.tsx +2 -7
- package/src/components/ui/Table.tsx +155 -32
- package/src/components/ui/Textarea.tsx +149 -0
- package/src/components/ui/Toaster.tsx +3 -19
- package/src/components/ui/Toggle.tsx +1 -5
- package/src/components/ui/ToggleChip.tsx +1 -3
- package/src/components/ui/Tooltip.tsx +1 -5
- package/src/components/ui/VectorInput.tsx +1 -1
- package/src/components/ui/cn.ts +1 -2
- package/src/components/ui/highlightSpans.ts +110 -0
- package/src/components/ui/index.ts +10 -41
- package/src/components/ui/listboxModel.ts +1 -6
- package/src/components/ui/tableSort.ts +2 -9
- package/src/components/ui/tableWindow.ts +96 -0
- package/src/index.ts +1 -8
- package/src/styles.css +14 -0
- package/src/theme.ts +2 -7
|
@@ -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
|
|
170
|
-
|
|
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={
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
package/src/components/ui/cn.ts
CHANGED
|
@@ -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
|