@vitavision/ui 0.13.0 → 0.14.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.13.0",
3
+ "version": "0.14.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": {
@@ -0,0 +1,139 @@
1
+ /*
2
+ * Counts by category, as one thin stacked bar.
3
+ *
4
+ * The segments may use the verdict colours because each one counts things the app has already
5
+ * judged: strings it passed, strings it flagged, strings that failed a check. That is the only
6
+ * licence. A share of a continuous quantity, or one series beside another, is not a verdict and
7
+ * belongs on a chart.
8
+ *
9
+ * It is a `role="img"`: the bar is one picture whose meaning is its name. `progressbar` and
10
+ * `meter` carry a single value, and a `list` of segments would make a reader step through
11
+ * parts of a glyph that is meant to be taken in at once. The name is the counts in words, so
12
+ * colour is never the only cue.
13
+ *
14
+ * Widths are flex grow factors rather than percentages, so the one-pixel gaps that keep two
15
+ * neighbouring colours apart come out of the bar and not off its end. The layout is inline
16
+ * rather than classes, so the shares and the 2 px floor hold under any stylesheet.
17
+ */
18
+
19
+ import type { ComponentProps } from "react";
20
+
21
+ import { byDensity, useDensity } from "./Density";
22
+ import { cn } from "./cn";
23
+
24
+ /** Counts written the same on the server and in every browser: `2,556`, `0.5`. */
25
+ const count = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
26
+
27
+ /**
28
+ * The colour of a `Breakdown` segment: a verdict (`normal`, `warn`, `defect`) for things the app
29
+ * has judged, or `neutral` for a category that is no verdict (skipped, not applicable).
30
+ */
31
+ export type BreakdownTone = "normal" | "warn" | "defect" | "neutral";
32
+
33
+ /** One category of a `Breakdown`: how many, in which colour, and what a reader calls them. */
34
+ export interface BreakdownSegment {
35
+ /** How many. Negative and non-finite values count as 0, and a segment of 0 is left out. */
36
+ value: number;
37
+ /** The colour, by meaning. */
38
+ tone: BreakdownTone;
39
+ /** What the count is, as it reads after the number: `"done"` gives "412 done". Unique among the segments. */
40
+ label: string;
41
+ }
42
+
43
+ /** Options of `breakdownText`, the same as the `Breakdown` props of the same names. */
44
+ export interface BreakdownTextOptions {
45
+ /** The whole. Defaults to the sum of the segments; a total below the sum counts as the sum. */
46
+ total?: number | undefined;
47
+ /** What the rest of the total is, as it reads after the number: `"to do"` gives "131 to do". */
48
+ remainderLabel?: string | undefined;
49
+ }
50
+
51
+ const TONE_CLASSES: Record<BreakdownTone, string> = {
52
+ normal: "bg-normal",
53
+ warn: "bg-warn",
54
+ defect: "bg-defect",
55
+ neutral: "bg-fg-subtle",
56
+ };
57
+
58
+ /** A count as drawn: 0 for anything that is not a finite, positive number. */
59
+ const amount = (value: number) => (Number.isFinite(value) && value > 0 ? value : 0);
60
+
61
+ /** The segments that are drawn, the whole, and the rest of it. */
62
+ function measure(segments: readonly BreakdownSegment[], total: number | undefined) {
63
+ const shown = segments.filter((segment) => amount(segment.value) > 0);
64
+ const sum = shown.reduce((acc, segment) => acc + amount(segment.value), 0);
65
+ const whole = total !== undefined && Number.isFinite(total) && total > sum ? total : sum;
66
+ return { shown, sum, whole, rest: whole - sum };
67
+ }
68
+
69
+ /**
70
+ * The counts of a breakdown in words: `"412 done, 20 to check, 3 problems, 131 to do of 566"`.
71
+ *
72
+ * It is the default accessible name of `Breakdown`, and the text for a tooltip or a label printed
73
+ * beside the bar. Segments of 0 are left out; the rest of the total is named only with a
74
+ * `remainderLabel`. With nothing to count it reads `"0 of 566"`.
75
+ *
76
+ * @param segments - The categories, in order.
77
+ * @param options - The `total` and the `remainderLabel`.
78
+ * @returns The text.
79
+ */
80
+ export function breakdownText(segments: readonly BreakdownSegment[], options: BreakdownTextOptions = {}): string {
81
+ const { shown, whole, rest } = measure(segments, options.total);
82
+ const parts = shown.map((segment) => `${count.format(amount(segment.value))} ${segment.label}`);
83
+ if (options.remainderLabel !== undefined && rest > 0) parts.push(`${count.format(rest)} ${options.remainderLabel}`);
84
+ return `${parts.length > 0 ? parts.join(", ") : "0"} of ${count.format(whole)}`;
85
+ }
86
+
87
+ /** Props of `Breakdown`: the segments and their total, plus the `<div>` props. */
88
+ export type BreakdownProps = Omit<ComponentProps<"div">, "children" | "role"> & {
89
+ /** The categories, drawn left to right in this order, each as wide as its share of the total. */
90
+ segments: readonly BreakdownSegment[];
91
+ /**
92
+ * The whole. Defaults to the sum of the segments; the rest is drawn as empty track. A total
93
+ * below the sum counts as the sum.
94
+ */
95
+ total?: number | undefined;
96
+ /** What the empty track counts, for the accessible name: `"to do"` gives "131 to do". */
97
+ remainderLabel?: string | undefined;
98
+ };
99
+
100
+ /**
101
+ * A thin bar of counts by category: 412 done, 20 to check and 3 problems out of 566, with the
102
+ * rest as empty track. Segments are drawn in the order given, each as wide as its share of the
103
+ * total (a non-zero count is never narrower than 2 px); segments of 0 are left out.
104
+ *
105
+ * Its colours are verdicts, so use it only for things the app has judged — never for a share of
106
+ * a continuous quantity or for series side by side (use `Progress` or a chart).
107
+ *
108
+ * A `role="img"` whose name lists every non-zero count, as `breakdownText` writes it: "412 done,
109
+ * 20 to check, 3 problems, 131 to do of 566". `aria-label` or `aria-labelledby` replaces it. The track
110
+ * matches `Progress` and follows the density in force. State is exposed as `data-state`
111
+ * (`"empty"` with nothing counted, `"partial"`, or `"complete"` when the segments fill the
112
+ * total), and each segment's tone as its `data-tone`. Width changes ease over 300 ms, and not at
113
+ * all with reduced motion.
114
+ */
115
+ export function Breakdown({ segments, total, remainderLabel, className, "aria-label": ariaLabel, ...rest }: BreakdownProps) {
116
+ const density = useDensity();
117
+ const { shown, sum, rest: remainder } = measure(segments, total);
118
+ const name = ariaLabel ?? (rest["aria-labelledby"] ? undefined : breakdownText(segments, { total, remainderLabel }));
119
+ return (
120
+ <div
121
+ role="img"
122
+ aria-label={name}
123
+ data-state={sum === 0 ? "empty" : remainder > 0 ? "partial" : "complete"}
124
+ {...rest}
125
+ className={cn("w-full overflow-hidden rounded-full bg-line", byDensity(density, "h-1.5", "h-1"), className)}
126
+ style={{ display: "flex", gap: 1, ...rest.style }}
127
+ >
128
+ {shown.map((segment) => (
129
+ <div
130
+ key={segment.label}
131
+ data-tone={segment.tone}
132
+ className={cn("h-full transition-[flex-grow] duration-300", TONE_CLASSES[segment.tone])}
133
+ style={{ flex: `${amount(segment.value)} 0 0`, minWidth: 2 }}
134
+ />
135
+ ))}
136
+ {remainder > 0 && <div className="h-full transition-[flex-grow] duration-300" style={{ flex: `${remainder} 0 0` }} />}
137
+ </div>
138
+ );
139
+ }
@@ -102,7 +102,8 @@ const SORT_ICONS = { ascending: ArrowUp, descending: ArrowDown, none: ArrowUpDow
102
102
  * show it and the rows render as given. Row callbacks always receive a row's index in `rows`,
103
103
  * whatever the order on screen.
104
104
  *
105
- * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End.
105
+ * Activatable rows also move focus with the arrow keys, Page Up / Page Down and Home / End;
106
+ * with `selectionFollowsFocus` each of those moves also activates the row it lands on.
106
107
  * With `windowed` the table scrolls itself (vertically, under a sticky header), renders only the
107
108
  * rows in view, and uses a fixed layout: give columns a `width` where the content needs it.
108
109
  */
@@ -121,6 +122,7 @@ export function Table<Row>({
121
122
  onSortChange,
122
123
  manualSort = false,
123
124
  windowed,
125
+ selectionFollowsFocus = false,
124
126
  }: {
125
127
  /** The columns, in order. */
126
128
  columns: Column<Row>[];
@@ -170,6 +172,15 @@ export function Table<Row>({
170
172
  * callbacks and keyboard navigation work as without it.
171
173
  */
172
174
  windowed?: TableWindowing | undefined;
175
+ /**
176
+ * Activate a row as soon as the keyboard moves to it: each arrow key, Page Up / Page Down,
177
+ * Home or End that lands on another row also calls `onRowClick` for that row, with the key
178
+ * press as the event — selection follows focus, as in a list where the selected item shows
179
+ * its detail beside the list. Clicks, Enter and Space work as without it. Leave it off when
180
+ * activating a row is costly or navigates away: then a reader can move through the rows and
181
+ * choose one. Needs `onRowClick`.
182
+ */
183
+ selectionFollowsFocus?: boolean | undefined;
173
184
  }) {
174
185
  const density = useDensity();
175
186
  const [ownSort, setOwnSort] = useState<TableSort | null>(defaultSort);
@@ -202,18 +213,25 @@ export function Table<Row>({
202
213
  : { start: 0, end: order.length, before: 0, after: 0 };
203
214
 
204
215
  const isWindowed = windowed !== undefined;
216
+ // With no rows the scroll area is replaced by the `empty` message: measure the new one when
217
+ // the rows come back, rather than keep observing the one that went away.
218
+ const hasRows = rows.length > 0;
205
219
  useLayoutEffect(() => {
206
220
  const scroller = scrollRef.current;
207
- if (!isWindowed || !scroller || typeof ResizeObserver === "undefined") return;
221
+ if (!isWindowed || !hasRows || !scroller || typeof ResizeObserver === "undefined") return;
208
222
  const measure = () => {
223
+ // A detached element measures 0, which would shrink the window to a row or two.
224
+ if (!scroller.isConnected) return;
209
225
  const next = { viewport: scroller.clientHeight, header: headRef.current?.offsetHeight ?? 0 };
210
226
  setMeasured((last) => (last?.viewport === next.viewport && last.header === next.header ? last : next));
211
227
  };
212
228
  measure();
229
+ // A new scroll area starts at the top, whatever the last one was scrolled to.
230
+ setScrollTop(scroller.scrollTop);
213
231
  const observer = new ResizeObserver(measure);
214
232
  observer.observe(scroller);
215
233
  return () => observer.disconnect();
216
- }, [isWindowed]);
234
+ }, [isWindowed, hasRows]);
217
235
 
218
236
  useLayoutEffect(() => {
219
237
  const target = pendingFocus.current;
@@ -224,11 +242,18 @@ export function Table<Row>({
224
242
  row.focus({ preventScroll: isWindowed });
225
243
  });
226
244
 
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 => {
245
+ /**
246
+ * Moves focus from the row at `position` for a navigation key, activating the new row when
247
+ * selection follows focus; `true` when the key was one.
248
+ */
249
+ const moveFocus = (event: KeyboardEvent<HTMLTableRowElement>, position: number): boolean => {
229
250
  const page = windowed ? Math.floor((viewportHeight - headerHeight) / rowHeight) : 10;
230
- const target = navigate(key, position, order.length, page);
251
+ const target = navigate(event.key, position, order.length, page);
231
252
  if (target === null) return false;
253
+ if (selectionFollowsFocus && onRowClick && target !== position) {
254
+ const index = order[target]!;
255
+ onRowClick(rows[index]!, index, event);
256
+ }
232
257
  pendingFocus.current = target;
233
258
  const scroller = scrollRef.current;
234
259
  if (windowed && scroller) {
@@ -237,10 +262,12 @@ export function Table<Row>({
237
262
  // Render the target's window now rather than on the scroll event, so it can take focus.
238
263
  setScrollTop(scroller.scrollTop);
239
264
  }
265
+ // A row already in the DOM takes focus now: when nothing scrolled, nothing re-renders, and
266
+ // the pending focus would wait for a render that never comes.
240
267
  const row = bodyRef.current?.querySelector<HTMLElement>(`tr[data-position="${target}"]`);
241
- if (row && !windowed) {
268
+ if (row) {
242
269
  pendingFocus.current = null;
243
- row.focus();
270
+ row.focus({ preventScroll: windowed !== undefined });
244
271
  }
245
272
  return true;
246
273
  };
@@ -375,7 +402,7 @@ export function Table<Row>({
375
402
  event.preventDefault();
376
403
  onRowClick(row, index, event);
377
404
  // 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();
405
+ } else if (event.target === event.currentTarget && moveFocus(event, position)) event.preventDefault();
379
406
  }
380
407
  : undefined
381
408
  }
@@ -8,6 +8,14 @@
8
8
  export { cn, focusRing, focusRingInset } from "./cn";
9
9
 
10
10
  export { Badge, CountRun, StatusDot, type Tone } from "./Badge";
11
+ export {
12
+ Breakdown,
13
+ breakdownText,
14
+ type BreakdownProps,
15
+ type BreakdownSegment,
16
+ type BreakdownTextOptions,
17
+ type BreakdownTone,
18
+ } from "./Breakdown";
11
19
  export { Button, ButtonLink, buttonClasses, type ButtonSize, type ButtonVariant } from "./Button";
12
20
  export { DensityProvider, byDensity, useDensity, type Density } from "./Density";
13
21
  export { ConfirmDialog, Dialog, DialogClose } from "./Dialog";