@vitavision/ui 0.11.0 → 0.12.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 +2 -2
- package/dist/index.d.ts +87 -8
- package/dist/index.js +155 -29
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ThemeToggle.tsx +2 -3
- package/src/components/ui/Table.tsx +188 -65
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/tableSort.ts +121 -0
- package/src/fonts/fonts.css +1 -1
- package/src/styles.css +3 -7
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* The palette switch
|
|
2
|
+
* The palette switch.
|
|
3
3
|
*
|
|
4
4
|
* Three states, not two, and cycled by one button rather than spread across a
|
|
5
5
|
* segmented control: this sits in a header next to the thing the app is
|
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* silently stops following the OS the first time you touch the switch has taken
|
|
9
9
|
* something away.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* second app needed it, rather than being copied: two copies of a control that
|
|
11
|
+
* One shared control rather than a copy per app: two copies of a control that
|
|
13
12
|
* writes to `localStorage` is two chances to disagree about the key, the cycle
|
|
14
13
|
* order, or which icon means which state.
|
|
15
14
|
*
|
|
@@ -4,12 +4,18 @@
|
|
|
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`.
|
|
7
11
|
*/
|
|
8
12
|
|
|
9
|
-
import
|
|
13
|
+
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
|
14
|
+
import { type KeyboardEvent, type MouseEvent, type ReactNode, useMemo, useState } from "react";
|
|
10
15
|
|
|
11
16
|
import { byDensity, useDensity } from "./Density";
|
|
12
|
-
import { cn } from "./cn";
|
|
17
|
+
import { cn, focusRing } from "./cn";
|
|
18
|
+
import { isSortable, nextSort, type SortDirection, sortOrder, type TableSort } from "./tableSort";
|
|
13
19
|
|
|
14
20
|
/** One column of a `Table`: its header and how to render a row's cell. */
|
|
15
21
|
export type Column<Row> = {
|
|
@@ -23,14 +29,49 @@ export type Column<Row> = {
|
|
|
23
29
|
width?: string;
|
|
24
30
|
/** The cell's content for a row. */
|
|
25
31
|
cell: (row: Row) => ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Whether the header sorts the table. Defaults to `true` when the column has a `sortValue`
|
|
34
|
+
* or a `compare`. Set it `true` with neither for a column the app sorts itself (see the
|
|
35
|
+
* table's `manualSort`), or `false` to keep a column with a `sortValue` unsortable.
|
|
36
|
+
*/
|
|
37
|
+
sortable?: boolean | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* The value the column sorts by. Numbers compare by value; strings by a fixed English
|
|
40
|
+
* collation that reads digit runs as numbers ("frame 9" before "frame 10"), so a server
|
|
41
|
+
* render and the browser agree; numbers come before strings. `null`, `undefined` and `NaN`
|
|
42
|
+
* go last in both directions.
|
|
43
|
+
*/
|
|
44
|
+
sortValue?: ((row: Row) => number | string | null | undefined) | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* A full ascending comparison, for an order one value cannot express (two fields, a rank):
|
|
47
|
+
* negative when `a` comes first. Takes precedence over `sortValue`. Reversed for
|
|
48
|
+
* `"descending"`; rows that compare equal keep their order in `rows` either way.
|
|
49
|
+
*/
|
|
50
|
+
compare?: ((a: Row, b: Row) => number) | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* The direction of the first click on the header. Defaults to `"ascending"`; `"descending"`
|
|
53
|
+
* suits a score, where the best comes first.
|
|
54
|
+
*/
|
|
55
|
+
firstSort?: SortDirection | undefined;
|
|
26
56
|
};
|
|
27
57
|
|
|
58
|
+
/** The header icon for a sort state: the direction when sorted, both arrows when only sortable. */
|
|
59
|
+
const SORT_ICONS = { ascending: ArrowUp, descending: ArrowDown, none: ArrowUpDown } as const;
|
|
60
|
+
|
|
28
61
|
/**
|
|
29
62
|
* A table of rows, with numeric columns set in mono and right-aligned.
|
|
30
63
|
*
|
|
31
64
|
* With `onRowClick` the rows are keyboard-reachable (Tab, then Enter or Space) while keeping
|
|
32
65
|
* their table semantics. The active row carries `aria-current` and `data-state="active"`;
|
|
33
66
|
* padding follows the density in force. With no rows it renders the `empty` message instead.
|
|
67
|
+
*
|
|
68
|
+
* A column with a `sortValue` or a `compare` gets a header button: each click moves its sort
|
|
69
|
+
* from the column's `firstSort` direction to the other one, then back to the rows' own order.
|
|
70
|
+
* The sorted header carries `aria-sort` and `data-sort` (`"ascending"` or `"descending"`);
|
|
71
|
+
* other sortable headers carry `data-sort="none"`. The sort is kept by the table (from
|
|
72
|
+
* `defaultSort`) or controlled (`sort` with `onSortChange`); with `manualSort` the headers
|
|
73
|
+
* show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
|
|
74
|
+
* whatever the order on screen.
|
|
34
75
|
*/
|
|
35
76
|
export function Table<Row>({
|
|
36
77
|
columns,
|
|
@@ -42,12 +83,16 @@ export function Table<Row>({
|
|
|
42
83
|
onRowClick,
|
|
43
84
|
isRowActive,
|
|
44
85
|
onRowHover,
|
|
86
|
+
sort: controlledSort,
|
|
87
|
+
defaultSort = null,
|
|
88
|
+
onSortChange,
|
|
89
|
+
manualSort = false,
|
|
45
90
|
}: {
|
|
46
91
|
/** The columns, in order. */
|
|
47
92
|
columns: Column<Row>[];
|
|
48
|
-
/** The rows, in order. */
|
|
93
|
+
/** The rows, in their own order: the order shown while the table is not sorted. */
|
|
49
94
|
rows: Row[];
|
|
50
|
-
/** A stable key per row. */
|
|
95
|
+
/** A stable key per row. `index` is the row's position in `rows`, whatever the sort. */
|
|
51
96
|
rowKey: (row: Row, index: number) => string | number;
|
|
52
97
|
/** Shown instead of the table when there are no rows. Defaults to "Nothing here.". */
|
|
53
98
|
empty?: ReactNode;
|
|
@@ -58,20 +103,53 @@ export function Table<Row>({
|
|
|
58
103
|
/** Makes rows activatable. Keyboard-reachable, so a table used as a list of
|
|
59
104
|
* destinations is not a mouse-only control. The event is passed so a caller can read
|
|
60
105
|
* modifiers — a list kept in step with a canvas needs shift-range and meta-toggle to mean
|
|
61
|
-
* the same thing in both places.
|
|
106
|
+
* the same thing in both places. `index` is the row's position in `rows`, whatever the
|
|
107
|
+
* sort. */
|
|
62
108
|
onRowClick?: (
|
|
63
109
|
row: Row,
|
|
64
110
|
index: number,
|
|
65
111
|
event: MouseEvent<HTMLTableRowElement> | KeyboardEvent<HTMLTableRowElement>,
|
|
66
112
|
) => void;
|
|
67
113
|
/** 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.
|
|
114
|
+
* `aria-current`, so it is announced rather than only tinted. `index` is the row's
|
|
115
|
+
* position in `rows`, whatever the sort. */
|
|
69
116
|
isRowActive?: (row: Row, index: number) => boolean;
|
|
70
117
|
/** Pointer enter/leave, for tables kept in step with a canvas overlay.
|
|
71
|
-
* `null` on leave. */
|
|
118
|
+
* `null` on leave. `index` is the row's position in `rows`, whatever the sort. */
|
|
72
119
|
onRowHover?: (row: Row | null, index: number | null) => void;
|
|
120
|
+
/**
|
|
121
|
+
* The sort, controlled: a column `key` and a direction, or `null` for the rows' own order.
|
|
122
|
+
* Leave it undefined and the table keeps the sort itself, starting from `defaultSort`.
|
|
123
|
+
*/
|
|
124
|
+
sort?: TableSort | null | undefined;
|
|
125
|
+
/** The sort the table starts with when `sort` is not given. Defaults to `null`, the rows' own order. */
|
|
126
|
+
defaultSort?: TableSort | null | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* Called on a header click with the new sort: the column's `firstSort` direction, then the
|
|
129
|
+
* other, then `null`. A click on another column starts that column at its `firstSort`.
|
|
130
|
+
*/
|
|
131
|
+
onSortChange?: ((sort: TableSort | null) => void) | undefined;
|
|
132
|
+
/**
|
|
133
|
+
* The app orders the rows (on a server, say): the headers show the sort and report clicks,
|
|
134
|
+
* and the rows render exactly as given. Mark the columns it can sort `sortable: true`.
|
|
135
|
+
*/
|
|
136
|
+
manualSort?: boolean | undefined;
|
|
73
137
|
}) {
|
|
74
138
|
const density = useDensity();
|
|
139
|
+
const [ownSort, setOwnSort] = useState<TableSort | null>(defaultSort);
|
|
140
|
+
const controlled = controlledSort !== undefined;
|
|
141
|
+
const sort = controlled ? controlledSort : ownSort;
|
|
142
|
+
// Indices into `rows`, so a row keeps its own index (and key) for the callbacks below.
|
|
143
|
+
const order = useMemo(
|
|
144
|
+
() => (manualSort ? rows.map((_, index) => index) : sortOrder(rows, columns, sort)),
|
|
145
|
+
[rows, columns, sort, manualSort],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const changeSort = (column: Column<Row>) => {
|
|
149
|
+
const next = nextSort(sort, column);
|
|
150
|
+
if (!controlled) setOwnSort(next);
|
|
151
|
+
onSortChange?.(next);
|
|
152
|
+
};
|
|
75
153
|
|
|
76
154
|
if (rows.length === 0) {
|
|
77
155
|
return (
|
|
@@ -93,68 +171,113 @@ export function Table<Row>({
|
|
|
93
171
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
94
172
|
<thead>
|
|
95
173
|
<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
|
|
174
|
+
{columns.map((column) => {
|
|
175
|
+
const sortable = isSortable(column);
|
|
176
|
+
const direction = sortable && sort?.key === column.key ? sort.direction : undefined;
|
|
177
|
+
const SortIcon = SORT_ICONS[direction ?? "none"];
|
|
178
|
+
return (
|
|
179
|
+
<th
|
|
146
180
|
key={column.key}
|
|
181
|
+
scope="col"
|
|
182
|
+
style={column.width ? { width: column.width } : undefined}
|
|
183
|
+
// Only the sorted header: `aria-sort="none"` on every other one would be
|
|
184
|
+
// announced on each, and tells a reader nothing.
|
|
185
|
+
aria-sort={direction}
|
|
186
|
+
data-sort={sortable ? (direction ?? "none") : undefined}
|
|
147
187
|
className={cn(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
188
|
+
// `last:pr-0`, as on the body cells: the last column's header ends where
|
|
189
|
+
// its values do, so a numeric header sits flush with its numbers.
|
|
190
|
+
"font-medium text-fg-muted last:pr-0",
|
|
191
|
+
byDensity(density, "pb-2 pr-3 text-xs", "pb-1 pr-2 text-[10px]"),
|
|
192
|
+
column.numeric && "text-right",
|
|
151
193
|
)}
|
|
152
194
|
>
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
195
|
+
{sortable ? (
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
onClick={() => changeSort(column)}
|
|
199
|
+
className={cn(
|
|
200
|
+
// Block-level `flex`, not `inline-flex`: an inline box would sit on the
|
|
201
|
+
// cell's line box and make a sortable header row 2px taller than a plain
|
|
202
|
+
// one. The padding, cancelled by the margin, gives the focus ring room
|
|
203
|
+
// without moving the label off the column's edge.
|
|
204
|
+
"-mx-1 flex w-fit max-w-full cursor-pointer items-center gap-1 rounded-control px-1",
|
|
205
|
+
"font-medium transition-colors hover:text-fg",
|
|
206
|
+
focusRing,
|
|
207
|
+
// The arrow sits on the inner side of a right-aligned header, so the
|
|
208
|
+
// label stays flush with the numbers under it.
|
|
209
|
+
column.numeric ? "ml-auto flex-row-reverse text-right" : "text-left",
|
|
210
|
+
direction && "text-fg",
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
{column.header}
|
|
214
|
+
<SortIcon
|
|
215
|
+
aria-hidden
|
|
216
|
+
className={cn(
|
|
217
|
+
"shrink-0",
|
|
218
|
+
byDensity(density, "size-3", "size-2.5"),
|
|
219
|
+
!direction && "text-fg-subtle",
|
|
220
|
+
)}
|
|
221
|
+
/>
|
|
222
|
+
</button>
|
|
223
|
+
) : (
|
|
224
|
+
column.header
|
|
225
|
+
)}
|
|
226
|
+
</th>
|
|
227
|
+
);
|
|
228
|
+
})}
|
|
229
|
+
</tr>
|
|
230
|
+
</thead>
|
|
231
|
+
<tbody>
|
|
232
|
+
{order.map((index) => {
|
|
233
|
+
const row = rows[index]!;
|
|
234
|
+
const active = isRowActive?.(row, index) ?? false;
|
|
235
|
+
return (
|
|
236
|
+
<tr
|
|
237
|
+
key={rowKey(row, index)}
|
|
238
|
+
data-state={active ? "active" : undefined}
|
|
239
|
+
className={cn(
|
|
240
|
+
"border-b border-line/60 last:border-0",
|
|
241
|
+
onRowClick && "cursor-pointer hover:bg-raised",
|
|
242
|
+
active && "bg-signal/10",
|
|
243
|
+
)}
|
|
244
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
245
|
+
// Deliberately *not* `role="button"`. Overriding a `<tr>`'s implicit `row`
|
|
246
|
+
// makes its `<td>`s' implicit `cell` invalid too — the cells have no row to
|
|
247
|
+
// belong to — so a screen reader stops announcing the table as a table and a
|
|
248
|
+
// reader loses the column each value came from, which on a table of
|
|
249
|
+
// quantities is the whole content. `tabIndex` plus the Enter/Space handler
|
|
250
|
+
// below is what makes the row reachable; the role stays what the element is.
|
|
251
|
+
aria-current={active ? true : undefined}
|
|
252
|
+
onClick={onRowClick ? (event) => onRowClick(row, index, event) : undefined}
|
|
253
|
+
onKeyDown={
|
|
254
|
+
onRowClick
|
|
255
|
+
? (event) => {
|
|
256
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
onRowClick(row, index, event);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
: undefined
|
|
262
|
+
}
|
|
263
|
+
onPointerEnter={onRowHover ? () => onRowHover(row, index) : undefined}
|
|
264
|
+
onPointerLeave={onRowHover ? () => onRowHover(null, null) : undefined}
|
|
265
|
+
>
|
|
266
|
+
{columns.map((column) => (
|
|
267
|
+
<td
|
|
268
|
+
key={column.key}
|
|
269
|
+
className={cn(
|
|
270
|
+
"align-middle text-fg last:pr-0",
|
|
271
|
+
byDensity(density, "py-2 pr-3", "py-0.5 pr-2"),
|
|
272
|
+
column.numeric && "text-right font-mono tabular-nums",
|
|
273
|
+
)}
|
|
274
|
+
>
|
|
275
|
+
{column.cell(row)}
|
|
276
|
+
</td>
|
|
277
|
+
))}
|
|
278
|
+
</tr>
|
|
279
|
+
);
|
|
280
|
+
})}
|
|
158
281
|
</tbody>
|
|
159
282
|
</table>
|
|
160
283
|
</div>
|
|
@@ -70,6 +70,7 @@ export { SegmentedControl } from "./SegmentedControl";
|
|
|
70
70
|
export { Select, type SelectOption } from "./Select";
|
|
71
71
|
export { Slider } from "./Slider";
|
|
72
72
|
export { Table, type Column } from "./Table";
|
|
73
|
+
export { sortRows, type SortDirection, type TableSort } from "./tableSort";
|
|
73
74
|
export { Checkbox, Switch } from "./Toggle";
|
|
74
75
|
export { ToggleChip } from "./ToggleChip";
|
|
75
76
|
export { Toaster, type ToasterProps } from "./Toaster";
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The order of a sorted `Table`, as pure functions so it is tested without a DOM, and so an app
|
|
3
|
+
* can reproduce the table's order (next/previous navigation, an export, a server that sorts the
|
|
4
|
+
* same way) with `sortRows`.
|
|
5
|
+
*
|
|
6
|
+
* The order is deterministic across machines: strings go through one collator with a fixed
|
|
7
|
+
* locale, so a server render and the browser that hydrates it put the rows in the same order.
|
|
8
|
+
* A `Collator` is not DOM, so building it once at module scope keeps the package SSR-safe.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Column } from "./Table";
|
|
12
|
+
|
|
13
|
+
/** Which way a column is sorted. Named as `aria-sort` names them: `"ascending"` puts the smallest first. */
|
|
14
|
+
export type SortDirection = "ascending" | "descending";
|
|
15
|
+
|
|
16
|
+
/** The column a table is sorted by, and which way. Where a sort is expected, `null` means the rows' own order. */
|
|
17
|
+
export interface TableSort {
|
|
18
|
+
/** The sorted column's `key`. */
|
|
19
|
+
key: string;
|
|
20
|
+
/** Which way. */
|
|
21
|
+
direction: SortDirection;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The column fields that decide the order. */
|
|
25
|
+
type SortColumn<Row> = Pick<Column<Row>, "key" | "sortable" | "sortValue" | "compare">;
|
|
26
|
+
|
|
27
|
+
/** Strings by a fixed English collation that reads digit runs as numbers: "frame 9" before "frame 10". */
|
|
28
|
+
const collator = new Intl.Collator("en", { numeric: true });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a column's header sorts the table: `sortable` if given, otherwise whether the column
|
|
32
|
+
* says how to order (`sortValue` or `compare`).
|
|
33
|
+
*/
|
|
34
|
+
export function isSortable<Row>(column: Pick<Column<Row>, "sortable" | "sortValue" | "compare">): boolean {
|
|
35
|
+
return column.sortable ?? (column.sortValue !== undefined || column.compare !== undefined);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The sort after a click on `column`'s header: its first direction, then the other, then none. */
|
|
39
|
+
export function nextSort<Row>(
|
|
40
|
+
current: TableSort | null,
|
|
41
|
+
column: Pick<Column<Row>, "key" | "firstSort">,
|
|
42
|
+
): TableSort | null {
|
|
43
|
+
const first = column.firstSort ?? "ascending";
|
|
44
|
+
if (current?.key !== column.key) return { key: column.key, direction: first };
|
|
45
|
+
if (current.direction !== first) return null;
|
|
46
|
+
return { key: column.key, direction: first === "ascending" ? "descending" : "ascending" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A value with no place in the order: `null`, `undefined` or `NaN`. */
|
|
50
|
+
function missing(value: number | string | null | undefined): boolean {
|
|
51
|
+
return value === null || value === undefined || Number.isNaN(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Two present values in ascending order: numbers by value (compared, not subtracted, so two
|
|
56
|
+
* infinities tie), strings by the collator, and every number before every string.
|
|
57
|
+
*/
|
|
58
|
+
function compareValues(a: number | string, b: number | string): number {
|
|
59
|
+
if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
|
|
60
|
+
if (typeof a === "string" && typeof b === "string") return collator.compare(a, b);
|
|
61
|
+
return typeof a === "number" ? -1 : 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The display order of `rows` under `sort`, as indices into `rows`. `Table` renders through it
|
|
66
|
+
* so each row keeps its original index for the row callbacks.
|
|
67
|
+
*/
|
|
68
|
+
export function sortOrder<Row>(
|
|
69
|
+
rows: readonly Row[],
|
|
70
|
+
columns: readonly SortColumn<Row>[],
|
|
71
|
+
sort: TableSort | null | undefined,
|
|
72
|
+
): number[] {
|
|
73
|
+
const order = rows.map((_, index) => index);
|
|
74
|
+
if (!sort) return order;
|
|
75
|
+
const column = columns.find((candidate) => candidate.key === sort.key);
|
|
76
|
+
if (!column || !isSortable(column)) return order;
|
|
77
|
+
const sign = sort.direction === "descending" ? -1 : 1;
|
|
78
|
+
|
|
79
|
+
// Every comparison falls back to the original index, so equal rows keep their order in both
|
|
80
|
+
// directions: descending is not ascending reversed, it is ascending with each run of ties kept.
|
|
81
|
+
const { compare, sortValue } = column;
|
|
82
|
+
if (compare) return order.sort((a, b) => sign * compare(rows[a]!, rows[b]!) || a - b);
|
|
83
|
+
if (!sortValue) return order;
|
|
84
|
+
|
|
85
|
+
// One call per row, not two per comparison: a `sortValue` may parse or look something up.
|
|
86
|
+
const values = rows.map((row) => sortValue(row));
|
|
87
|
+
return order.sort((a, b) => {
|
|
88
|
+
const left = values[a];
|
|
89
|
+
const right = values[b];
|
|
90
|
+
const leftMissing = missing(left);
|
|
91
|
+
const rightMissing = missing(right);
|
|
92
|
+
// Missing values go last whichever way the column is sorted: a reader sorting by score in
|
|
93
|
+
// either direction wants the rows that have one.
|
|
94
|
+
if (leftMissing || rightMissing) return leftMissing === rightMissing ? a - b : leftMissing ? 1 : -1;
|
|
95
|
+
return sign * compareValues(left!, right!) || a - b;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The rows in the order a `Table` with these columns shows them under `sort`.
|
|
101
|
+
*
|
|
102
|
+
* The sort is stable in both directions: rows that compare equal keep their order in `rows`.
|
|
103
|
+
* With the column's `compare`, that function decides (reversed for `"descending"`). With its
|
|
104
|
+
* `sortValue`, numbers compare by value and strings by a fixed English collation that reads
|
|
105
|
+
* digit runs as numbers ("frame 9" before "frame 10"), the same on a server as in any
|
|
106
|
+
* browser; numbers come before strings; and `null`, `undefined` and `NaN` go last in both
|
|
107
|
+
* directions. A `null` sort, a `key` that names no column, and a column that is not sortable or
|
|
108
|
+
* has neither function leave the order as given.
|
|
109
|
+
*
|
|
110
|
+
* @param rows - The rows, in their own order. Not modified.
|
|
111
|
+
* @param columns - The table's columns; only `key`, `sortable`, `sortValue` and `compare` are read.
|
|
112
|
+
* @param sort - The column and direction, or `null` for the rows' own order.
|
|
113
|
+
* @returns A new array of the same rows, sorted.
|
|
114
|
+
*/
|
|
115
|
+
export function sortRows<Row>(
|
|
116
|
+
rows: readonly Row[],
|
|
117
|
+
columns: readonly Pick<Column<Row>, "key" | "sortable" | "sortValue" | "compare">[],
|
|
118
|
+
sort: TableSort | null | undefined,
|
|
119
|
+
): Row[] {
|
|
120
|
+
return sortOrder(rows, columns, sort).map((index) => rows[index]!);
|
|
121
|
+
}
|
package/src/fonts/fonts.css
CHANGED
package/src/styles.css
CHANGED
|
@@ -5,12 +5,8 @@
|
|
|
5
5
|
@source "./";
|
|
6
6
|
|
|
7
7
|
/*
|
|
8
|
-
* The design token layer
|
|
9
|
-
*
|
|
10
|
-
* Extracted from visual-anomaly-lab's own token layer (its ADR-0021), where it was written
|
|
11
|
-
* to close a real gap: eleven screens with every colour inlined as a raw `slate-*` utility
|
|
12
|
-
* at 250-odd call sites — the same palette restated 250 times rather than defined once.
|
|
13
|
-
* This file is that definition, shared now by every lab app rather than owned by one.
|
|
8
|
+
* The design token layer: every colour, radius and font the components use, defined once
|
|
9
|
+
* here rather than restated as raw palette utilities at each call site.
|
|
14
10
|
*
|
|
15
11
|
* The direction is *instrument*: the chrome is grey so the data can be loud. Anomaly maps,
|
|
16
12
|
* colormaps, grayscale sensor images and vector measurement overlays are what these
|
|
@@ -70,7 +66,7 @@
|
|
|
70
66
|
* Every value below is held by `contrast.test.ts` (WCAG 2.2 AA, both themes): text ≥ 4.5:1
|
|
71
67
|
* on every surface, including each verdict colour and `signal` on its own /12 tint, and the
|
|
72
68
|
* control boundary `line-strong` and `signal` (focus, checked) ≥ 3:1. `signal` and `normal`
|
|
73
|
-
* are also kept apart for a tritanope
|
|
69
|
+
* are also kept apart for a tritanope — the reason the light
|
|
74
70
|
* accent is a deep slate-teal rather than a brighter one. Change a value only with the tests.
|
|
75
71
|
*/
|
|
76
72
|
:root {
|