@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/README.md +2 -2
- package/dist/index.d.ts +76 -3
- package/dist/index.js +111 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ui/Breakdown.tsx +139 -0
- package/src/components/ui/Table.tsx +36 -9
- package/src/components/ui/index.ts +8 -0
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
228
|
-
|
|
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
|
|
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
|
|
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";
|