@rackbops/ui-react 0.2.15 → 0.2.16
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/dist/DataTable.d.ts +38 -0
- package/dist/DataTable.js +100 -0
- package/dist/Tabstrip.d.ts +33 -0
- package/dist/Tabstrip.js +62 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export interface DataTableColumn<T> {
|
|
3
|
+
/** Unique among this table's columns; also the sort key passed back via onSort/defaultSortKey. */
|
|
4
|
+
key: string;
|
|
5
|
+
header: ReactNode;
|
|
6
|
+
render: (row: T) => ReactNode;
|
|
7
|
+
/** Right-aligns the cell (rb-num, matching the existing numeric-column class). */
|
|
8
|
+
numeric?: boolean;
|
|
9
|
+
/** Omit to make the column unsortable. Returning a number sorts numerically; a string sorts
|
|
10
|
+
* lexicographically (case-insensitive). */
|
|
11
|
+
sortValue?: (row: T) => string | number;
|
|
12
|
+
}
|
|
13
|
+
export interface DataTableProps<T> {
|
|
14
|
+
columns: DataTableColumn<T>[];
|
|
15
|
+
rows: T[];
|
|
16
|
+
rowKey: (row: T) => string;
|
|
17
|
+
/** Groups rows under a labelled header row instead of one flat body. Sorting (if active)
|
|
18
|
+
* applies within each group, not across the whole table. */
|
|
19
|
+
groupBy?: (row: T) => string;
|
|
20
|
+
/** Known group labels in display order; any group present in the data but not listed here
|
|
21
|
+
* is appended after, sorted alphabetically. Ignored when groupBy is omitted. */
|
|
22
|
+
groupOrder?: string[];
|
|
23
|
+
defaultSortKey?: string;
|
|
24
|
+
defaultSortDirection?: "asc" | "desc";
|
|
25
|
+
/** Sticky header within a scrolling wrapper -- for a table taller than its container. */
|
|
26
|
+
sticky?: boolean;
|
|
27
|
+
emptyMessage?: ReactNode;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A sortable, optionally-grouped data table on the `rb-table` contract (`rb-table`, `th`, `td`,
|
|
31
|
+
* `rb-num`) plus three additions this component itself introduces: `rb-table__group-row` (a
|
|
32
|
+
* labelled group header row), `rb-table__sort` (the clickable sort-header button), and
|
|
33
|
+
* `rb-table-scroll` (a sticky-header scroll wrapper for a table taller than its container).
|
|
34
|
+
* Generic over the row type and driven entirely by the `columns`/`rows` props -- no app-specific
|
|
35
|
+
* knowledge. Originally built for Kenzen (design.md section 9's decision tables); moved here so
|
|
36
|
+
* any app on this design system can use it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function DataTable<T>({ columns, rows, rowKey, groupBy, groupOrder, defaultSortKey, defaultSortDirection, sticky, emptyMessage, }: DataTableProps<T>): import("react").JSX.Element;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cx } from "./cx.js";
|
|
4
|
+
function compareBy(column, direction) {
|
|
5
|
+
const sign = direction === "asc" ? 1 : -1;
|
|
6
|
+
return (a, b) => {
|
|
7
|
+
const av = column.sortValue?.(a) ?? "";
|
|
8
|
+
const bv = column.sortValue?.(b) ?? "";
|
|
9
|
+
if (typeof av === "number" && typeof bv === "number") {
|
|
10
|
+
return (av - bv) * sign;
|
|
11
|
+
}
|
|
12
|
+
return String(av).localeCompare(String(bv), undefined, { sensitivity: "base" }) * sign;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function sortRows(rows, columns, sort) {
|
|
16
|
+
if (!sort) {
|
|
17
|
+
return rows;
|
|
18
|
+
}
|
|
19
|
+
const column = columns.find((c) => c.key === sort.key);
|
|
20
|
+
if (!column?.sortValue) {
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
return [...rows].sort(compareBy(column, sort.direction));
|
|
24
|
+
}
|
|
25
|
+
/** Orders group labels: those in `groupOrder` first (in that order, skipping ones with no
|
|
26
|
+
* rows), then any remaining present-in-data label sorted alphabetically. */
|
|
27
|
+
function orderGroups(present, groupOrder) {
|
|
28
|
+
if (!groupOrder) {
|
|
29
|
+
return [...present].sort((a, b) => a.localeCompare(b));
|
|
30
|
+
}
|
|
31
|
+
const presentSet = new Set(present);
|
|
32
|
+
const known = groupOrder.filter((g) => presentSet.has(g));
|
|
33
|
+
const knownSet = new Set(known);
|
|
34
|
+
const rest = present.filter((g) => !knownSet.has(g)).sort((a, b) => a.localeCompare(b));
|
|
35
|
+
return [...known, ...rest];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A sortable, optionally-grouped data table on the `rb-table` contract (`rb-table`, `th`, `td`,
|
|
39
|
+
* `rb-num`) plus three additions this component itself introduces: `rb-table__group-row` (a
|
|
40
|
+
* labelled group header row), `rb-table__sort` (the clickable sort-header button), and
|
|
41
|
+
* `rb-table-scroll` (a sticky-header scroll wrapper for a table taller than its container).
|
|
42
|
+
* Generic over the row type and driven entirely by the `columns`/`rows` props -- no app-specific
|
|
43
|
+
* knowledge. Originally built for Kenzen (design.md section 9's decision tables); moved here so
|
|
44
|
+
* any app on this design system can use it.
|
|
45
|
+
*/
|
|
46
|
+
export function DataTable({ columns, rows, rowKey, groupBy, groupOrder, defaultSortKey, defaultSortDirection = "asc", sticky, emptyMessage, }) {
|
|
47
|
+
const [sort, setSort] = useState(defaultSortKey ? { key: defaultSortKey, direction: defaultSortDirection } : null);
|
|
48
|
+
if (rows.length === 0) {
|
|
49
|
+
return _jsx("p", { children: emptyMessage ?? "No data." });
|
|
50
|
+
}
|
|
51
|
+
function onHeaderClick(column) {
|
|
52
|
+
if (!column.sortValue) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
setSort((current) => {
|
|
56
|
+
if (current?.key !== column.key) {
|
|
57
|
+
return { key: column.key, direction: "asc" };
|
|
58
|
+
}
|
|
59
|
+
return { key: column.key, direction: current.direction === "asc" ? "desc" : "asc" };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const groups = groupBy
|
|
63
|
+
? (() => {
|
|
64
|
+
const byGroup = new Map();
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const label = groupBy(row);
|
|
67
|
+
const bucket = byGroup.get(label);
|
|
68
|
+
if (bucket) {
|
|
69
|
+
bucket.push(row);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
byGroup.set(label, [row]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return orderGroups([...byGroup.keys()], groupOrder).map((label) => {
|
|
76
|
+
// Safe: label came from byGroup's own keys.
|
|
77
|
+
const groupRows = byGroup.get(label);
|
|
78
|
+
if (!groupRows) {
|
|
79
|
+
throw new Error(`unreachable: no rows for group ${label}`);
|
|
80
|
+
}
|
|
81
|
+
return { label, rows: sortRows(groupRows, columns, sort) };
|
|
82
|
+
});
|
|
83
|
+
})()
|
|
84
|
+
: [{ label: null, rows: sortRows(rows, columns, sort) }];
|
|
85
|
+
const table = (_jsxs("table", { className: "rb-table", children: [_jsx("thead", { children: _jsx("tr", { children: columns.map((column) => {
|
|
86
|
+
const isSorted = sort?.key === column.key;
|
|
87
|
+
// `aria-sort` claims a table-wide order; grouping only sorts within each group, so
|
|
88
|
+
// asserting it there would tell a screen-reader user something false.
|
|
89
|
+
const ariaSort = isSorted && !groupBy
|
|
90
|
+
? sort.direction === "asc"
|
|
91
|
+
? "ascending"
|
|
92
|
+
: "descending"
|
|
93
|
+
: undefined;
|
|
94
|
+
return (_jsx("th", { scope: "col", "aria-sort": ariaSort, children: column.sortValue ? (_jsxs("button", { type: "button", className: "rb-table__sort", onClick: () => onHeaderClick(column), children: [column.header, isSorted && (_jsx("span", { "aria-hidden": "true", children: sort.direction === "asc" ? " ▲" : " ▼" }))] })) : (column.header) }, column.key));
|
|
95
|
+
}) }) }), _jsx("tbody", { children: groups.map((group) => (_jsx(GroupRows, { group: group, columns: columns, rowKey: rowKey }, group.label ?? "__ungrouped__"))) })] }));
|
|
96
|
+
return sticky ? _jsx("div", { className: "rb-table-scroll", children: table }) : table;
|
|
97
|
+
}
|
|
98
|
+
function GroupRows({ group, columns, rowKey, }) {
|
|
99
|
+
return (_jsxs(_Fragment, { children: [group.label !== null && (_jsx("tr", { className: "rb-table__group-row", children: _jsx("td", { colSpan: columns.length, children: group.label }) })), group.rows.map((row) => (_jsx("tr", { children: columns.map((column) => (_jsx("td", { className: cx(column.numeric && "rb-num"), children: column.render(row) }, column.key))) }, rowKey(row))))] }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
export interface TabstripTab {
|
|
3
|
+
/** Unique among this strip's tabs; what `onSelect` reports and `selected` matches. */
|
|
4
|
+
id: string;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
/** Optional trailing count/badge, e.g. how many items the tab holds. */
|
|
7
|
+
badge?: ReactNode;
|
|
8
|
+
}
|
|
9
|
+
export interface TabstripProps {
|
|
10
|
+
tabs: TabstripTab[];
|
|
11
|
+
selected: string;
|
|
12
|
+
onSelect: (id: string) => void;
|
|
13
|
+
/** Names the strip for assistive tech, e.g. "View". */
|
|
14
|
+
label: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A controlled tab strip on the `rb-tabstrip` contract -- the design system's existing
|
|
18
|
+
* top-level-view-nav pattern (bordered pill buttons; the active tab gets accent text, a wash,
|
|
19
|
+
* and an accent-tinted border), previously a two-theme "extra" (arcane-obsidian/arcane-parchment)
|
|
20
|
+
* with no React wrapper. This component gives every theme the same pattern via shared tokens,
|
|
21
|
+
* and adds `.rb-tabstrip__badge` to the contract for an optional trailing count.
|
|
22
|
+
*
|
|
23
|
+
* Controlled rather than self-stateful so the selected tab can be lifted -- a caller that wants
|
|
24
|
+
* it in the URL, or restored across a data reload, cannot do that with internal state.
|
|
25
|
+
*
|
|
26
|
+
* Implements the ARIA tabs pattern: `role="tablist"` with roving `tabIndex`, so the strip is a
|
|
27
|
+
* SINGLE tab stop and arrow keys move between tabs -- a plain row of buttons would instead make
|
|
28
|
+
* a keyboard user tab through every one to reach the content. Left/Right wrap at both ends, and
|
|
29
|
+
* Home/End jump to the first/last, which is what that pattern specifies. `aria-controls` is
|
|
30
|
+
* deliberately not asserted, since this component cannot know the panel's id and claiming a
|
|
31
|
+
* relationship that does not exist would mislead a screen-reader user.
|
|
32
|
+
*/
|
|
33
|
+
export declare function Tabstrip({ tabs, selected, onSelect, label }: TabstripProps): import("react").JSX.Element;
|
package/dist/Tabstrip.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from "./cx.js";
|
|
3
|
+
/**
|
|
4
|
+
* A controlled tab strip on the `rb-tabstrip` contract -- the design system's existing
|
|
5
|
+
* top-level-view-nav pattern (bordered pill buttons; the active tab gets accent text, a wash,
|
|
6
|
+
* and an accent-tinted border), previously a two-theme "extra" (arcane-obsidian/arcane-parchment)
|
|
7
|
+
* with no React wrapper. This component gives every theme the same pattern via shared tokens,
|
|
8
|
+
* and adds `.rb-tabstrip__badge` to the contract for an optional trailing count.
|
|
9
|
+
*
|
|
10
|
+
* Controlled rather than self-stateful so the selected tab can be lifted -- a caller that wants
|
|
11
|
+
* it in the URL, or restored across a data reload, cannot do that with internal state.
|
|
12
|
+
*
|
|
13
|
+
* Implements the ARIA tabs pattern: `role="tablist"` with roving `tabIndex`, so the strip is a
|
|
14
|
+
* SINGLE tab stop and arrow keys move between tabs -- a plain row of buttons would instead make
|
|
15
|
+
* a keyboard user tab through every one to reach the content. Left/Right wrap at both ends, and
|
|
16
|
+
* Home/End jump to the first/last, which is what that pattern specifies. `aria-controls` is
|
|
17
|
+
* deliberately not asserted, since this component cannot know the panel's id and claiming a
|
|
18
|
+
* relationship that does not exist would mislead a screen-reader user.
|
|
19
|
+
*/
|
|
20
|
+
export function Tabstrip({ tabs, selected, onSelect, label }) {
|
|
21
|
+
function onKeyDown(event) {
|
|
22
|
+
const index = tabs.findIndex((t) => t.id === selected);
|
|
23
|
+
if (index === -1) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
let next = null;
|
|
27
|
+
switch (event.key) {
|
|
28
|
+
case "ArrowRight":
|
|
29
|
+
next = (index + 1) % tabs.length;
|
|
30
|
+
break;
|
|
31
|
+
case "ArrowLeft":
|
|
32
|
+
next = (index - 1 + tabs.length) % tabs.length;
|
|
33
|
+
break;
|
|
34
|
+
case "Home":
|
|
35
|
+
next = 0;
|
|
36
|
+
break;
|
|
37
|
+
case "End":
|
|
38
|
+
next = tabs.length - 1;
|
|
39
|
+
break;
|
|
40
|
+
default:
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const target = tabs[next];
|
|
44
|
+
if (target) {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
onSelect(target.id);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// When `selected` matches no tab (a caller mid-reload passing a stale id), every tab would
|
|
50
|
+
// otherwise get tabIndex=-1 and the whole strip would drop out of the tab order entirely --
|
|
51
|
+
// a keyboard trap strictly worse than the inert arrow keys above. Fall back to making the
|
|
52
|
+
// first tab the strip's single tab stop, so it stays reachable.
|
|
53
|
+
const hasSelected = tabs.some((t) => t.id === selected);
|
|
54
|
+
return (_jsx("div", { role: "tablist", "aria-label": label, className: "rb-tabstrip", onKeyDown: onKeyDown, children: tabs.map((tab, index) => {
|
|
55
|
+
const isSelected = tab.id === selected;
|
|
56
|
+
const isTabStop = hasSelected ? isSelected : index === 0;
|
|
57
|
+
return (_jsxs("button", { type: "button", role: "tab", "aria-selected": isSelected,
|
|
58
|
+
// Roving tabIndex: exactly one tab is reachable by Tab, so the whole strip is one
|
|
59
|
+
// stop and the arrow keys above do the moving within it.
|
|
60
|
+
tabIndex: isTabStop ? 0 : -1, className: cx("rb-tabstrip__tab", isSelected && "rb-tabstrip__tab--active"), onClick: () => onSelect(tab.id), children: [tab.label, tab.badge !== undefined && _jsx("span", { className: "rb-tabstrip__badge", children: tab.badge })] }, tab.id));
|
|
61
|
+
}) }));
|
|
62
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -7,5 +7,7 @@ export { Badge, Alert, Progress, Spinner, type BadgeProps, type AlertProps, type
|
|
|
7
7
|
export { Dialog, type DialogProps } from "./Dialog.js";
|
|
8
8
|
export { Tabs, type TabsProps, type TabItem } from "./Tabs.js";
|
|
9
9
|
export { Stepper, type StepperProps, type StepperStep } from "./Stepper.js";
|
|
10
|
+
export { DataTable, type DataTableProps, type DataTableColumn } from "./DataTable.js";
|
|
11
|
+
export { Tabstrip, type TabstripProps, type TabstripTab } from "./Tabstrip.js";
|
|
10
12
|
export { LinksIndex, type LinksIndexProps, type LinkItem, type LinkCategory, type LinkUrl, } from "./LinksIndex.js";
|
|
11
13
|
export { cx } from "./cx.js";
|
package/dist/index.js
CHANGED
|
@@ -7,5 +7,7 @@ export { Badge, Alert, Progress, Spinner, } from "./feedback.js";
|
|
|
7
7
|
export { Dialog } from "./Dialog.js";
|
|
8
8
|
export { Tabs } from "./Tabs.js";
|
|
9
9
|
export { Stepper } from "./Stepper.js";
|
|
10
|
+
export { DataTable } from "./DataTable.js";
|
|
11
|
+
export { Tabstrip } from "./Tabstrip.js";
|
|
10
12
|
export { LinksIndex, } from "./LinksIndex.js";
|
|
11
13
|
export { cx } from "./cx.js";
|