@rackbops/ui-react 0.2.14 → 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.
@@ -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
+ }
package/dist/Dialog.d.ts CHANGED
@@ -2,6 +2,11 @@ import { type DialogHTMLAttributes, type ReactNode, type RefAttributes } from "r
2
2
  export interface DialogProps extends Omit<DialogHTMLAttributes<HTMLDialogElement>, "open" | "title" | "onClose">, RefAttributes<HTMLDialogElement> {
3
3
  open: boolean;
4
4
  onClose: () => void;
5
+ /**
6
+ * Renders as a heading in `.rb-dialog__title` (linked via aria-labelledby), replacing the
7
+ * native HTML `title` tooltip attribute -- there's no way to set a real tooltip through
8
+ * this component.
9
+ */
5
10
  title?: ReactNode;
6
11
  actions?: ReactNode;
7
12
  }
@@ -1,6 +1,11 @@
1
1
  import { type HTMLAttributes, type RefAttributes } from "react";
2
2
  export interface LinkUrl {
3
3
  label: string;
4
+ /**
5
+ * Rendered as-is in an <a href> -- consumer-authored/trusted data (the app/site URLs you
6
+ * configure), not sanitized against `javascript:`-style schemes. Don't feed this untrusted
7
+ * input without validating the scheme first.
8
+ */
4
9
  url: string;
5
10
  }
6
11
  export interface LinkCategory {
@@ -20,6 +25,12 @@ export interface LinkItem {
20
25
  export interface LinksIndexProps extends HTMLAttributes<HTMLDivElement>, RefAttributes<HTMLDivElement> {
21
26
  categories: LinkCategory[];
22
27
  links: LinkItem[];
28
+ /**
29
+ * Heading level for each category label; each card's own heading renders one level deeper
30
+ * (clamped to h6). Set this to match whatever heading LinksIndex is embedded under, so
31
+ * heading order stays sequential instead of skipping or repeating a level. Defaults to 2.
32
+ */
33
+ level?: 2 | 3 | 4 | 5 | 6;
23
34
  }
24
35
  /**
25
36
  * A generic, data-driven grouped index of apps/sites. Iterates `categories` in order, each a group
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
2
+ import { forwardRef, } from "react";
3
3
  import { Card } from "./Card.js";
4
4
  import { Badge } from "./feedback.js";
5
5
  // Structural inline layout only -- the card/badge surfaces + colour come from @rackbops/styles
@@ -17,23 +17,29 @@ const listStyle = { listStyle: "none", margin: 0, padding: 0 };
17
17
  // External = an http(s) or protocol-relative (`//host`) url -> opens in a new tab; anything else
18
18
  // (relative, mailto:, tel:, ...) stays a plain in-page link.
19
19
  const isExternal = (url) => /^(https?:)?\/\//i.test(url);
20
- function LinkCard({ link }) {
21
- return (_jsxs(Card, { children: [_jsxs("h3", { children: [link.name, link.monitored ? (_jsxs(_Fragment, { children: [" ", _jsx(Badge, { variant: "success", children: "monitored" })] })) : null] }), link.description ? _jsx("p", { children: link.description }) : null, _jsx("ul", { style: listStyle, children: link.urls.map((u) => (_jsx("li", { children: isExternal(u.url) ? (_jsx("a", { href: u.url, target: "_blank", rel: "noreferrer noopener", children: u.label })) : (_jsx("a", { href: u.url, children: u.label })) }, u.url))) }), link.host ? _jsx("p", { children: link.host }) : null] }));
20
+ function LinkCard({ link, level }) {
21
+ const Heading = `h${level}`;
22
+ return (_jsxs(Card, { children: [_jsxs(Heading, { children: [link.name, link.monitored ? (_jsxs(_Fragment, { children: [" ", _jsx(Badge, { variant: "success", children: "monitored" })] })) : null] }), link.description ? _jsx("p", { children: link.description }) : null, _jsx("ul", { style: listStyle, children: link.urls.map((u) => (
23
+ // Two urls on one link can share a `url` with different `label`s ("prod" /
24
+ // "canonical") -- key on the pair, not the url alone, so they don't collide.
25
+ _jsx("li", { children: isExternal(u.url) ? (_jsx("a", { href: u.url, target: "_blank", rel: "noreferrer noopener", children: u.label })) : (_jsx("a", { href: u.url, children: u.label })) }, `${u.label}::${u.url}`))) }), link.host ? _jsx("p", { children: link.host }) : null] }));
22
26
  }
23
- function Group({ label, items }) {
24
- return (_jsxs("section", { children: [_jsx("h2", { children: label }), _jsx("div", { style: gridStyle, children: items.map((link) => (_jsx(LinkCard, { link: link }, link.name))) })] }));
27
+ function Group({ label, items, level, }) {
28
+ const Heading = `h${level}`;
29
+ const cardLevel = (level < 6 ? level + 1 : 6);
30
+ return (_jsxs("section", { children: [_jsx(Heading, { children: label }), _jsx("div", { style: gridStyle, children: items.map((link) => (_jsx(LinkCard, { link: link, level: cardLevel }, link.name))) })] }));
25
31
  }
26
32
  /**
27
33
  * A generic, data-driven grouped index of apps/sites. Iterates `categories` in order, each a group
28
34
  * of `rb-card`s; links whose `category` is not listed collect into a trailing "Other" group. No
29
35
  * router assumed -- external URLs open in a new tab. Themed by the consumer's `data-rb-style`.
30
36
  */
31
- export const LinksIndex = forwardRef(function LinksIndex({ categories, links, className, ...rest }, ref) {
37
+ export const LinksIndex = forwardRef(function LinksIndex({ categories, links, className, level = 2, ...rest }, ref) {
32
38
  const known = new Set(categories.map((c) => c.id));
33
39
  const other = links.filter((l) => !known.has(l.category));
34
40
  return (_jsxs("div", { ref: ref, className: className, ...rest, children: [categories.map((cat) => {
35
41
  const items = links.filter((l) => l.category === cat.id);
36
- return items.length > 0 ? _jsx(Group, { label: cat.label, items: items }, cat.id) : null;
37
- }), other.length > 0 ? _jsx(Group, { label: "Other", items: other }) : null] }));
42
+ return items.length > 0 ? (_jsx(Group, { label: cat.label, items: items, level: level }, cat.id)) : null;
43
+ }), other.length > 0 ? _jsx(Group, { label: "Other", items: other, level: level }) : null] }));
38
44
  });
39
45
  LinksIndex.displayName = "LinksIndex";
package/dist/NavRail.d.ts CHANGED
@@ -1,9 +1,10 @@
1
- import { type HTMLAttributes, type ReactNode, type RefAttributes } from "react";
2
- export interface NavRailItem {
1
+ import { type AnchorHTMLAttributes, type HTMLAttributes, type ReactNode, type RefAttributes } from "react";
2
+ export interface NavRailItem extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "id" | "href"> {
3
3
  /** Matched against `activeId`; also used as the React list key. */
4
4
  id: string;
5
5
  label: ReactNode;
6
6
  href?: string;
7
+ [dataAttr: `data-${string}`]: unknown;
7
8
  }
8
9
  export interface NavRailProps extends HTMLAttributes<HTMLElement>, RefAttributes<HTMLElement> {
9
10
  items: NavRailItem[];
@@ -14,6 +15,8 @@ export interface NavRailProps extends HTMLAttributes<HTMLElement>, RefAttributes
14
15
  /**
15
16
  * A vertical nav container (.rb-nav-rail) rendering one NavLink per item.
16
17
  * Controlled by the caller via `activeId`, same as NavLink's own `active` prop --
17
- * this component owns no state of its own.
18
+ * this component owns no state of its own. Any extra props on an item (onClick, target,
19
+ * rel, aria-*, data-*, className, ...) forward onto that item's own NavLink/<a> -- e.g. to
20
+ * intercept a click for a client router while still setting a real `href`.
18
21
  */
19
22
  export declare const NavRail: import("react").ForwardRefExoticComponent<Omit<NavRailProps, "ref"> & RefAttributes<HTMLElement>>;
package/dist/NavRail.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { forwardRef } from "react";
2
+ import { forwardRef, } from "react";
3
3
  import { cx } from "./cx.js";
4
4
  import { NavLink } from "./NavLink.js";
5
5
  /**
6
6
  * A vertical nav container (.rb-nav-rail) rendering one NavLink per item.
7
7
  * Controlled by the caller via `activeId`, same as NavLink's own `active` prop --
8
- * this component owns no state of its own.
8
+ * this component owns no state of its own. Any extra props on an item (onClick, target,
9
+ * rel, aria-*, data-*, className, ...) forward onto that item's own NavLink/<a> -- e.g. to
10
+ * intercept a click for a client router while still setting a real `href`.
9
11
  */
10
12
  export const NavRail = forwardRef(function NavRail({ items, activeId, className, ...rest }, ref) {
11
- return (_jsx("nav", { ref: ref, className: cx("rb-nav-rail", className), ...rest, children: items.map((item) => (_jsx(NavLink, { href: item.href, active: item.id === activeId, children: item.label }, item.id))) }));
13
+ return (_jsx("nav", { ref: ref, className: cx("rb-nav-rail", className), ...rest, children: items.map(({ id, label, href, ...itemRest }) => (_jsx(NavLink, { href: href, active: id === activeId, ...itemRest, children: label }, id))) }));
12
14
  });
13
15
  NavRail.displayName = "NavRail";
package/dist/Tabs.d.ts CHANGED
@@ -1,13 +1,12 @@
1
- import { type ReactNode, type RefAttributes } from "react";
1
+ import { type HTMLAttributes, type ReactNode, type RefAttributes } from "react";
2
2
  export interface TabItem {
3
3
  id: string;
4
4
  label: ReactNode;
5
5
  content: ReactNode;
6
6
  }
7
- export interface TabsProps extends RefAttributes<HTMLDivElement> {
7
+ export interface TabsProps extends HTMLAttributes<HTMLDivElement>, RefAttributes<HTMLDivElement> {
8
8
  items: TabItem[];
9
9
  /** Initially active tab id; defaults to the first item. */
10
10
  defaultId?: string;
11
- className?: string;
12
11
  }
13
12
  export declare const Tabs: import("react").ForwardRefExoticComponent<Omit<TabsProps, "ref"> & RefAttributes<HTMLDivElement>>;
package/dist/Tabs.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useId, useRef, useState, } from "react";
3
3
  import { cx } from "./cx.js";
4
- export const Tabs = forwardRef(function Tabs({ items, defaultId, className }, ref) {
4
+ export const Tabs = forwardRef(function Tabs({ items, defaultId, className, ...rest }, ref) {
5
5
  const base = useId();
6
6
  const [active, setActive] = useState(defaultId ?? items[0]?.id);
7
7
  // Fall back to the first tab if `active` names no item, so exactly one tab is
@@ -26,7 +26,13 @@ export const Tabs = forwardRef(function Tabs({ items, defaultId, className }, re
26
26
  // Move focus with the selection — the whole point of the roving tabindex.
27
27
  tabRefs.current[next]?.focus();
28
28
  };
29
- return (_jsxs("div", { ref: ref, className: className, children: [_jsx("div", { className: "rb-tabs", role: "tablist", children: items.map((t, idx) => {
29
+ return (
30
+ // The outer <div> is a plain structural wrapper -- an ARIA tablist should
31
+ // contain only `tab` children, so the tabpanels below live as its
32
+ // siblings instead. That's the element `ref` points to; className and
33
+ // arbitrary props (aria-label, data-testid, id, ...) belong on the actual
34
+ // role="tablist" element, which is what they're meant to reach.
35
+ _jsxs("div", { ref: ref, children: [_jsx("div", { className: cx("rb-tabs", className), role: "tablist", ...rest, children: items.map((t, idx) => {
30
36
  const selected = t.id === activeId;
31
37
  return (_jsx("button", { ref: (el) => {
32
38
  tabRefs.current[idx] = el;
@@ -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;
@@ -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
+ }
@@ -6,8 +6,13 @@ export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, RefAttribut
6
6
  export declare const Badge: import("react").ForwardRefExoticComponent<Omit<BadgeProps, "ref"> & RefAttributes<HTMLSpanElement>>;
7
7
  export interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, "title">, RefAttributes<HTMLDivElement> {
8
8
  variant?: SemanticVariant;
9
+ /**
10
+ * Renders as a heading in `.rb-alert__title`, replacing the native HTML `title` tooltip
11
+ * attribute -- there's no way to set a real tooltip through this component.
12
+ */
9
13
  title?: ReactNode;
10
14
  }
15
+ /** A status banner (.rb-alert); `role="alert"` announces it to screen readers on mount/update. */
11
16
  export declare const Alert: import("react").ForwardRefExoticComponent<Omit<AlertProps, "ref"> & RefAttributes<HTMLDivElement>>;
12
17
  export interface ProgressProps extends ProgressHTMLAttributes<HTMLProgressElement>, RefAttributes<HTMLProgressElement> {
13
18
  }
package/dist/feedback.js CHANGED
@@ -5,6 +5,7 @@ export const Badge = forwardRef(function Badge({ variant, className, ...rest },
5
5
  return (_jsx("span", { ref: ref, className: cx("rb-badge", variant && `rb-badge--${variant}`, className), ...rest }));
6
6
  });
7
7
  Badge.displayName = "Badge";
8
+ /** A status banner (.rb-alert); `role="alert"` announces it to screen readers on mount/update. */
8
9
  export const Alert = forwardRef(function Alert({ variant, title, className, children, ...rest }, ref) {
9
10
  return (_jsxs("div", { ref: ref, role: "alert", className: cx("rb-alert", variant && `rb-alert--${variant}`, className), ...rest, children: [title !== undefined && _jsx("div", { className: "rb-alert__title", children: title }), children] }));
10
11
  });
package/dist/form.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type HTMLAttributes, type InputHTMLAttributes, type LabelHTMLAttributes, type ReactNode, type RefAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
1
+ import { type CSSProperties, type HTMLAttributes, type InputHTMLAttributes, type LabelHTMLAttributes, type ReactNode, type RefAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
2
2
  export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, RefAttributes<HTMLInputElement> {
3
3
  }
4
4
  export declare const Input: import("react").ForwardRefExoticComponent<Omit<InputProps, "ref"> & RefAttributes<HTMLInputElement>>;
@@ -17,6 +17,10 @@ export declare const Field: import("react").ForwardRefExoticComponent<Omit<Field
17
17
  export interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type">, RefAttributes<HTMLInputElement> {
18
18
  /** Optional label text; when set the control is wrapped in a .rb-choice row. */
19
19
  label?: ReactNode;
20
+ /** Styles the .rb-choice row itself (e.g. margins, flex alignment) -- `className` still styles the control. Only used when `label` is set. */
21
+ wrapperClassName?: string;
22
+ /** Styles the .rb-choice row itself; `style` still styles the control. Only used when `label` is set. */
23
+ wrapperStyle?: CSSProperties;
20
24
  }
21
25
  export declare const Checkbox: import("react").ForwardRefExoticComponent<Omit<ChoiceProps, "ref"> & RefAttributes<HTMLInputElement>>;
22
26
  export declare const Radio: import("react").ForwardRefExoticComponent<Omit<ChoiceProps, "ref"> & RefAttributes<HTMLInputElement>>;
package/dist/form.js CHANGED
@@ -22,20 +22,20 @@ export const Field = forwardRef(function Field({ className, ...rest }, ref) {
22
22
  });
23
23
  Field.displayName = "Field";
24
24
  /** A checkbox/radio/switch control, optionally wrapped with its label. */
25
- function ChoiceControl({ label, control }) {
25
+ function ChoiceControl({ label, control, wrapperClassName, wrapperStyle, }) {
26
26
  if (label === undefined)
27
27
  return _jsx(_Fragment, { children: control });
28
- return (_jsxs("label", { className: "rb-choice", children: [control, label] }));
28
+ return (_jsxs("label", { className: cx("rb-choice", wrapperClassName), style: wrapperStyle, children: [control, label] }));
29
29
  }
30
- export const Checkbox = forwardRef(function Checkbox({ label, className, ...rest }, ref) {
31
- return (_jsx(ChoiceControl, { label: label, control: _jsx("input", { ref: ref, type: "checkbox", className: cx("rb-checkbox", className), ...rest }) }));
30
+ export const Checkbox = forwardRef(function Checkbox({ label, className, wrapperClassName, wrapperStyle, ...rest }, ref) {
31
+ return (_jsx(ChoiceControl, { label: label, wrapperClassName: wrapperClassName, wrapperStyle: wrapperStyle, control: _jsx("input", { ref: ref, type: "checkbox", className: cx("rb-checkbox", className), ...rest }) }));
32
32
  });
33
33
  Checkbox.displayName = "Checkbox";
34
- export const Radio = forwardRef(function Radio({ label, className, ...rest }, ref) {
35
- return (_jsx(ChoiceControl, { label: label, control: _jsx("input", { ref: ref, type: "radio", className: cx("rb-radio", className), ...rest }) }));
34
+ export const Radio = forwardRef(function Radio({ label, className, wrapperClassName, wrapperStyle, ...rest }, ref) {
35
+ return (_jsx(ChoiceControl, { label: label, wrapperClassName: wrapperClassName, wrapperStyle: wrapperStyle, control: _jsx("input", { ref: ref, type: "radio", className: cx("rb-radio", className), ...rest }) }));
36
36
  });
37
37
  Radio.displayName = "Radio";
38
- export const Switch = forwardRef(function Switch({ label, className, ...rest }, ref) {
39
- return (_jsx(ChoiceControl, { label: label, control: _jsx("input", { ref: ref, type: "checkbox", role: "switch", className: cx("rb-switch", className), ...rest }) }));
38
+ export const Switch = forwardRef(function Switch({ label, className, wrapperClassName, wrapperStyle, ...rest }, ref) {
39
+ return (_jsx(ChoiceControl, { label: label, wrapperClassName: wrapperClassName, wrapperStyle: wrapperStyle, control: _jsx("input", { ref: ref, type: "checkbox", role: "switch", className: cx("rb-switch", className), ...rest }) }));
40
40
  });
41
41
  Switch.displayName = "Switch";
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ui-react",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "React components styled by @rackbops/styles rackbops themes.",
5
5
  "license": "MIT",
6
6
  "repository": {