@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitavision/ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "The vitavision instrument design system: Tailwind v4 tokens, theme, and React primitives.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  /*
2
- * The palette switch, shared by every lab app.
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
- * Lived in visual-anomaly-lab's own `App.tsx` first. It moved here the moment a
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 type { KeyboardEvent, MouseEvent, ReactNode } from "react";
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
- <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
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
- "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",
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
- {column.cell(row)}
154
- </td>
155
- ))}
156
- </tr>
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
+ }
@@ -1,5 +1,5 @@
1
1
  /*
2
- * IBM Plex for the @vitavision packages (ADR-0003): import once, next to styles.css.
2
+ * IBM Plex for the @vitavision packages: import once, next to styles.css.
3
3
  *
4
4
  * @import "@vitavision/ui/fonts.css";
5
5
  *
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 (docs/visual-language.md §1) — the reason the light
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 {