@kennycorrea/ui 1.6.1 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.7.0 — 2026-10-23
4
+
5
+ Sidebar groups as an accordion, and select-all for stacked tables.
6
+
7
+ - **`Sidebar` groups are an accordion.** Opening one group closes any other, in the persistent column and the off-canvas panel. The group open at first is the first with `defaultOpen`, or else the first containing the active item. A `NavItem` outside a `Sidebar` still opens on its own.
8
+ - **Choosing a group from the icon rail expands the sidebar with that group open**, instead of showing its children in a menu beside the rail. This is a behaviour change: the rail no longer has a dropdown.
9
+ - **Groups open and close with a height animation**, and without one under reduced motion. A closed group stays mounted, so its list is `inert` rather than `hidden`.
10
+ - **`DataTable` select-all in the stacked layout.** When rows stack into cards and the header is hidden, a "Select all rows" checkbox appears above them. It shows the same indeterminate state and acts on the current page. Closes the known gap noted in 1.6.1.
11
+
3
12
  ## 1.6.1 — 2026-10-23
4
13
 
5
14
  Fixes found by running the example app in a real browser at phone and desktop widths, in light and dark. Each has a test in `test/browser-fixes.test.tsx`; the cascade problems are pinned by computing the selectors' specificity.
@@ -21,11 +21,14 @@ export interface NavItemProps {
21
21
  /** Whether a group starts open. Default: open when one of its children is active. */
22
22
  defaultOpen?: boolean;
23
23
  }
24
+ /** The key a group is tracked by in the Sidebar's accordion. */
25
+ export declare function navKey(item: NavItemProps): string;
24
26
  /**
25
27
  * One entry in a navigation list: a link, a button, or a group of child links.
26
28
  *
27
29
  * Renders an `<li>`, so put it inside a `<ul>` (Sidebar does). Inside a collapsed Sidebar the
28
- * label stays in the accessible name and is also shown as a tooltip, and a group opens its
29
- * children as a menu beside the rail, since there is no room to expand it in place.
30
+ * label stays in the accessible name and is also shown as a tooltip. Inside a Sidebar groups are
31
+ * an accordion: opening one closes the others, and choosing one from the icon rail expands the
32
+ * sidebar with that group open. Outside one, each group opens on its own.
30
33
  */
31
34
  export declare function NavItem(item: NavItemProps): import("react").JSX.Element;
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ChevronDownIcon } from '@radix-ui/react-icons';
3
- import { DropdownMenu as ThemedDropdownMenu } from '@radix-ui/themes';
4
3
  import { useId, useState } from 'react';
5
4
  import { Tooltip } from '../../atoms/Tooltip/Tooltip.js';
6
5
  import { useNavContext } from './context.js';
@@ -35,38 +34,39 @@ function ItemControl({ item, onNavigate }) {
35
34
  }
36
35
  return (_jsx("button", { type: "button", className: "ui-nav-item", "aria-current": item.active ? 'page' : undefined, onClick: onClick, children: children }));
37
36
  }
37
+ /** The key a group is tracked by in the Sidebar's accordion. */
38
+ export function navKey(item) {
39
+ return item.id ?? item.label;
40
+ }
38
41
  /**
39
42
  * One entry in a navigation list: a link, a button, or a group of child links.
40
43
  *
41
44
  * Renders an `<li>`, so put it inside a `<ul>` (Sidebar does). Inside a collapsed Sidebar the
42
- * label stays in the accessible name and is also shown as a tooltip, and a group opens its
43
- * children as a menu beside the rail, since there is no room to expand it in place.
45
+ * label stays in the accessible name and is also shown as a tooltip. Inside a Sidebar groups are
46
+ * an accordion: opening one closes the others, and choosing one from the icon rail expands the
47
+ * sidebar with that group open. Outside one, each group opens on its own.
44
48
  */
45
49
  export function NavItem(item) {
46
- const { collapsed, onNavigate } = useNavContext();
50
+ const { collapsed, onNavigate, openGroup, setOpenGroup, expand } = useNavContext();
47
51
  const childActive = item.items?.some((c) => c.active) ?? false;
48
- const [open, setOpen] = useState(item.defaultOpen ?? childActive);
52
+ const [ownOpen, setOwnOpen] = useState(item.defaultOpen ?? childActive);
49
53
  const listId = useId();
54
+ const key = navKey(item);
55
+ const open = setOpenGroup ? openGroup === key : ownOpen;
56
+ const setOpen = (next) => {
57
+ if (setOpenGroup)
58
+ setOpenGroup(next ? key : null);
59
+ else
60
+ setOwnOpen(next);
61
+ };
50
62
  if (!item.items) {
51
63
  return (_jsx("li", { children: _jsx(Tooltip, { label: collapsed ? item.label : undefined, children: _jsx(ItemControl, { item: item, onNavigate: onNavigate }) }) }));
52
64
  }
53
65
  if (collapsed) {
54
- return (_jsx("li", { children: _jsxs(ThemedDropdownMenu.Root, { children: [_jsx(ThemedDropdownMenu.Trigger, { children: _jsx("button", { type: "button", className: "ui-nav-item", "aria-current": childActive ? 'page' : undefined, children: _jsx(ItemContent, { item: item }) }) }), _jsxs(ThemedDropdownMenu.Content, { side: "right", align: "start", children: [_jsx(ThemedDropdownMenu.Label, { children: item.label }), item.items.map((child) => (_jsx(ThemedDropdownMenu.Item, { asChild: child.href !== undefined, onSelect: () => {
55
- if (child.href === undefined) {
56
- child.onClick?.();
57
- onNavigate?.();
58
- }
59
- }, children: child.href !== undefined
60
- ? (child.render ?? ((p) => _jsx("a", { ...p })))({
61
- href: child.href,
62
- 'aria-current': child.active ? 'page' : undefined,
63
- onClick: () => {
64
- child.onClick?.();
65
- onNavigate?.();
66
- },
67
- children: child.label,
68
- })
69
- : child.label }, child.id ?? child.label)))] })] }) }));
66
+ return (_jsx("li", { children: _jsx(Tooltip, { label: item.label, children: _jsx("button", { type: "button", className: "ui-nav-item", "aria-current": childActive ? 'page' : undefined, "aria-expanded": false, onClick: () => {
67
+ setOpen(true);
68
+ expand?.();
69
+ }, children: _jsx(ItemContent, { item: item }) }) }) }));
70
70
  }
71
- return (_jsxs("li", { children: [_jsx("button", { type: "button", className: "ui-nav-item", "aria-expanded": open, "aria-controls": listId, onClick: () => setOpen((o) => !o), children: _jsx(ItemContent, { item: item, trailing: _jsx("span", { className: "ui-nav-item__chevron", "aria-hidden": "true", children: _jsx(ChevronDownIcon, {}) }) }) }), _jsx("ul", { id: listId, className: "ui-nav-group__items", hidden: !open, children: item.items.map((child) => (_jsx("li", { children: _jsx(ItemControl, { item: child, onNavigate: onNavigate }) }, child.id ?? child.label))) })] }));
71
+ return (_jsxs("li", { children: [_jsx("button", { type: "button", className: "ui-nav-item", "aria-expanded": open, "aria-controls": listId, onClick: () => setOpen(!open), children: _jsx(ItemContent, { item: item, trailing: _jsx("span", { className: "ui-nav-item__chevron", "aria-hidden": "true", children: _jsx(ChevronDownIcon, {}) }) }) }), _jsx("div", { className: "ui-nav-group", "data-open": open ? '' : undefined, children: _jsx("ul", { id: listId, className: "ui-nav-group__items", inert: !open, children: item.items.map((child) => (_jsx("li", { children: _jsx(ItemControl, { item: child, onNavigate: onNavigate }) }, child.id ?? child.label))) }) })] }));
72
72
  }
@@ -9,6 +9,11 @@ export interface NavContextValue {
9
9
  collapsed: boolean;
10
10
  /** Called after any item is activated, so an off-canvas panel can close. */
11
11
  onNavigate?: () => void;
12
+ /** The one group that is open, by key. Sidebar keeps it so groups behave as an accordion. */
13
+ openGroup?: string | null;
14
+ setOpenGroup?: (key: string | null) => void;
15
+ /** Leaves the icon rail, so a group chosen there can open in place. */
16
+ expand?: () => void;
12
17
  }
13
18
  export declare const NavContext: import("react").Context<NavContextValue>;
14
19
  export declare function useNavContext(): NavContextValue;
@@ -23,6 +23,7 @@ const INTERACTIVE = 'a, button, input, select, textarea, [role="checkbox"], [rol
23
23
  export function DataTable({ columns, rows, getRowId, caption, showCaption = false, getRowLabel, sort = null, onSortChange, selection, onSelectionChange, rowActions, onRowAction, onRowClick, state = 'ready', empty, error, loadingRows = 5, pagination, density = 'comfortable', stackBelow = 'md', stickyFirstColumn = false, maxHeight, className, }) {
24
24
  const strings = useUiStrings();
25
25
  const captionId = useId();
26
+ const selectAllId = useId();
26
27
  const selectable = !!selection && !!onSelectionChange;
27
28
  const hasActions = !!rowActions;
28
29
  const span = columns.length + (selectable ? 1 : 0) + (hasActions ? 1 : 0);
@@ -113,7 +114,7 @@ export function DataTable({ columns, rows, getRowId, caption, showCaption = fals
113
114
  const regionStyle = maxHeight
114
115
  ? { maxBlockSize: maxHeight }
115
116
  : undefined;
116
- return (_jsxs("div", { className: ['ui-table', className].filter(Boolean).join(' '), "data-density": density, "data-stack": stackBelow || undefined, "data-sticky-first": stickyFirstColumn ? '' : undefined, children: [effective === 'loading' ? (_jsx("span", { role: "status", className: "ui-sr-only", children: strings.loading })) : null, _jsx("section", { className: "ui-table__region", "aria-labelledby": captionId,
117
+ return (_jsxs("div", { className: ['ui-table', className].filter(Boolean).join(' '), "data-density": density, "data-stack": stackBelow || undefined, "data-sticky-first": stickyFirstColumn ? '' : undefined, children: [effective === 'loading' ? (_jsx("span", { role: "status", className: "ui-sr-only", children: strings.loading })) : null, selectable && effective === 'ready' ? (_jsxs("label", { className: "ui-table__selectall", htmlFor: selectAllId, children: [_jsx(Checkbox, { id: selectAllId, checked: allChecked, onCheckedChange: toggleAll }), strings.selectAll] })) : null, _jsx("section", { className: "ui-table__region", "aria-labelledby": captionId,
117
118
  // biome-ignore lint/a11y/noNoninteractiveTabindex: a scrolling region must be reachable by keyboard to be scrolled.
118
119
  tabIndex: 0, style: regionStyle, children: _jsxs("table", { className: "ui-table__table", role: "table", "aria-busy": effective === 'loading' || undefined, children: [_jsx("caption", { id: captionId, className: showCaption ? 'ui-table__caption' : 'ui-sr-only', children: caption }), header, _jsx("tbody", { role: "rowgroup", children: body })] }) }), pagination && effective !== 'loading' ? _jsx(Pagination, { ...pagination }) : null] }));
119
120
  }
@@ -8,7 +8,7 @@ import { minWidth } from '../../hooks/breakpoints.js';
8
8
  import { useControllableState } from '../../hooks/useControllableState.js';
9
9
  import { useMediaQuery } from '../../hooks/useMediaQuery.js';
10
10
  import { NavContext } from '../../molecules/NavItem/context.js';
11
- import { NavItem } from '../../molecules/NavItem/NavItem.js';
11
+ import { NavItem, navKey } from '../../molecules/NavItem/NavItem.js';
12
12
  import { useUiStrings } from '../../strings.js';
13
13
  import { useThemePortalTarget } from '../../utils/themePortal.js';
14
14
  import { useShell } from '../AppShell/context.js';
@@ -38,6 +38,13 @@ export function Sidebar({ header, collapsedHeader, sections, footer, collapsed:
38
38
  });
39
39
  const { anchorRef, target, resolved } = useThemePortalTarget(container);
40
40
  const navId = useId();
41
+ const [openGroup, setOpenGroup] = useState(() => {
42
+ for (const item of sections.flatMap((section) => section.items)) {
43
+ if (item.items && (item.defaultOpen ?? item.items.some((c) => c.active)))
44
+ return navKey(item);
45
+ }
46
+ return null;
47
+ });
41
48
  const wide = shell ? shell.wide : ownWide;
42
49
  const panelOpen = shell ? shell.navOpen : (open ?? ownOpen);
43
50
  const setPanelOpen = (next) => {
@@ -51,9 +58,14 @@ export function Sidebar({ header, collapsedHeader, sections, footer, collapsed:
51
58
  if (wide) {
52
59
  const toggleLabel = collapsed ? strings.expandSidebar : strings.collapseSidebar;
53
60
  const railHeader = collapsed ? (collapsedHeader ?? header) : header;
54
- return (_jsxs("div", { className: "ui-sidebar", "data-collapsed": collapsed ? '' : undefined, children: [railHeader ? _jsx("div", { className: "ui-sidebar__header", children: railHeader }) : null, _jsx(NavContext.Provider, { value: { collapsed }, children: _jsx(Sections, { sections: sections, label: label, id: navId }) }), footer || collapsible ? (_jsxs("div", { className: "ui-sidebar__footer", children: [footer, collapsible ? (_jsx(Tooltip, { label: collapsed ? toggleLabel : undefined, children: _jsxs("button", { type: "button", className: "ui-nav-item", "aria-expanded": !collapsed, "aria-controls": navId, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { className: "ui-nav-item__icon", "aria-hidden": "true", children: collapsed ? _jsx(DoubleArrowRightIcon, {}) : _jsx(DoubleArrowLeftIcon, {}) }), _jsx("span", { className: "ui-nav-item__label", children: toggleLabel })] }) })) : null] })) : null] }));
61
+ return (_jsxs("div", { className: "ui-sidebar", "data-collapsed": collapsed ? '' : undefined, children: [railHeader ? _jsx("div", { className: "ui-sidebar__header", children: railHeader }) : null, _jsx(NavContext.Provider, { value: { collapsed, openGroup, setOpenGroup, expand: () => setCollapsed(false) }, children: _jsx(Sections, { sections: sections, label: label, id: navId }) }), footer || collapsible ? (_jsxs("div", { className: "ui-sidebar__footer", children: [footer, collapsible ? (_jsx(Tooltip, { label: collapsed ? toggleLabel : undefined, children: _jsxs("button", { type: "button", className: "ui-nav-item", "aria-expanded": !collapsed, "aria-controls": navId, onClick: () => setCollapsed(!collapsed), children: [_jsx("span", { className: "ui-nav-item__icon", "aria-hidden": "true", children: collapsed ? _jsx(DoubleArrowRightIcon, {}) : _jsx(DoubleArrowLeftIcon, {}) }), _jsx("span", { className: "ui-nav-item__label", children: toggleLabel })] }) })) : null] })) : null] }));
55
62
  }
56
- const panel = (_jsxs(_Fragment, { children: [_jsx(RadixDialog.Overlay, { className: "ui-sidebar-overlay" }), _jsxs(RadixDialog.Content, { className: "ui-sidebar-panel", "aria-describedby": undefined, children: [_jsx(RadixDialog.Title, { className: "ui-sr-only", children: label }), _jsxs("div", { className: "ui-sidebar__header", children: [_jsx("div", { className: "min-w-0 flex-1", children: header }), _jsxs(RadixDialog.Close, { className: "ui-nav-item ui-sidebar__close", children: [_jsx("span", { className: "ui-nav-item__icon", "aria-hidden": "true", children: _jsx(Cross1Icon, {}) }), _jsx("span", { className: "ui-sr-only", children: strings.closeNavigation })] })] }), _jsx(NavContext.Provider, { value: { collapsed: false, onNavigate: () => setPanelOpen(false) }, children: _jsx(Sections, { sections: sections, label: label, id: navId }) }), footer ? _jsx("div", { className: "ui-sidebar__footer", children: footer }) : null] })] }));
63
+ const panel = (_jsxs(_Fragment, { children: [_jsx(RadixDialog.Overlay, { className: "ui-sidebar-overlay" }), _jsxs(RadixDialog.Content, { className: "ui-sidebar-panel", "aria-describedby": undefined, children: [_jsx(RadixDialog.Title, { className: "ui-sr-only", children: label }), _jsxs("div", { className: "ui-sidebar__header", children: [_jsx("div", { className: "min-w-0 flex-1", children: header }), _jsxs(RadixDialog.Close, { className: "ui-nav-item ui-sidebar__close", children: [_jsx("span", { className: "ui-nav-item__icon", "aria-hidden": "true", children: _jsx(Cross1Icon, {}) }), _jsx("span", { className: "ui-sr-only", children: strings.closeNavigation })] })] }), _jsx(NavContext.Provider, { value: {
64
+ collapsed: false,
65
+ onNavigate: () => setPanelOpen(false),
66
+ openGroup,
67
+ setOpenGroup,
68
+ }, children: _jsx(Sections, { sections: sections, label: label, id: navId }) }), footer ? _jsx("div", { className: "ui-sidebar__footer", children: footer }) : null] })] }));
57
69
  const anchor = _jsx("span", { ref: anchorRef, hidden: true });
58
70
  if (!resolved)
59
71
  return anchor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "description": "A React + TypeScript component library built on Radix Themes: atoms, a Tailwind token bridge and theming.",
5
5
  "private": false,
6
6
  "type": "module",
package/tokens.css CHANGED
@@ -1022,8 +1022,10 @@
1022
1022
  .ui-nav-item__badge[data-dot] { min-inline-size: 8px; block-size: 8px; padding: 0; }
1023
1023
  .ui-nav-item__chevron { flex: none; display: inline-flex; transition: transform 150ms ease-out; }
1024
1024
  .ui-nav-item[aria-expanded="true"] .ui-nav-item__chevron { transform: rotate(180deg); }
1025
- .ui-nav-group__items { margin: 2px 0 0; padding: 0 0 0 32px; list-style: none; display: flex; flex-direction: column; gap: 2px; }
1026
- @media (prefers-reduced-motion: reduce) { .ui-nav-item__chevron { transition: none; } }
1025
+ .ui-nav-group { display: grid; grid-template-rows: 0fr; visibility: hidden; transition: grid-template-rows 200ms ease-out, visibility 200ms; }
1026
+ .ui-nav-group[data-open] { grid-template-rows: 1fr; visibility: visible; }
1027
+ .ui-nav-group__items { min-block-size: 0; overflow: hidden; margin: 0; padding: 2px 0 0 32px; list-style: none; display: flex; flex-direction: column; gap: 2px; }
1028
+ @media (prefers-reduced-motion: reduce) { .ui-nav-item__chevron, .ui-nav-group { transition: none; } }
1027
1029
 
1028
1030
  .ui-topbar { position: sticky; inset-block-start: 0; z-index: 20; display: grid; box-sizing: border-box;
1029
1031
  grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "lead title actions" "search search search";
@@ -1187,6 +1189,7 @@
1187
1189
  .ui-table__sort:focus-visible, .ui-table__rowbutton:focus-visible { outline: 2px solid var(--focus-8); outline-offset: 2px; border-radius: var(--radius-1); }
1188
1190
  .ui-table__rowbutton { padding: 0; border: 0; background: none; font: inherit; color: inherit; text-align: start; cursor: pointer; }
1189
1191
  @media (hover: hover) { .ui-table__rowbutton:hover { text-decoration: underline; } }
1192
+ .ui-table__selectall { display: none; align-items: center; gap: 8px; padding-inline: 12px; font-size: 14px; font-weight: 500; cursor: pointer; }
1190
1193
  .ui-table__empty { padding: 0; }
1191
1194
  .ui-table__table .ui-table__empty { block-size: auto; }
1192
1195
  .ui-table__empty > * { flex: 1; }
@@ -1197,6 +1200,7 @@
1197
1200
  @container (width < 480px) {
1198
1201
  .ui-table[data-stack="sm"] :is(.ui-table__table, tbody, tr, th, td) { display: block; }
1199
1202
  .ui-table[data-stack="sm"] thead { display: none; }
1203
+ .ui-table[data-stack="sm"] .ui-table__selectall { display: flex; }
1200
1204
  .ui-table[data-stack="sm"] .ui-table__region { border: 0; background: none; overflow: visible; }
1201
1205
  .ui-table[data-stack="sm"] tbody tr { position: relative; margin-block-end: 12px; padding: 8px 12px; border: 1px solid var(--color-hairline); border-radius: var(--radius-4); background: var(--color-panel-solid); }
1202
1206
  .ui-table[data-stack="sm"] .ui-table__table tbody :is(th, td) { display: flex; justify-content: space-between; gap: 12px; block-size: auto; min-block-size: 32px; align-items: center; padding: 4px 0; border: 0; text-align: end; }
@@ -1208,6 +1212,7 @@
1208
1212
  @container (width < 640px) {
1209
1213
  .ui-table[data-stack="md"] :is(.ui-table__table, tbody, tr, th, td) { display: block; }
1210
1214
  .ui-table[data-stack="md"] thead { display: none; }
1215
+ .ui-table[data-stack="md"] .ui-table__selectall { display: flex; }
1211
1216
  .ui-table[data-stack="md"] .ui-table__region { border: 0; background: none; overflow: visible; }
1212
1217
  .ui-table[data-stack="md"] tbody tr { position: relative; margin-block-end: 12px; padding: 8px 12px; border: 1px solid var(--color-hairline); border-radius: var(--radius-4); background: var(--color-panel-solid); }
1213
1218
  .ui-table[data-stack="md"] .ui-table__table tbody :is(th, td) { display: flex; justify-content: space-between; gap: 12px; block-size: auto; min-block-size: 32px; align-items: center; padding: 4px 0; border: 0; text-align: end; }
@@ -1219,6 +1224,7 @@
1219
1224
  @container (width < 800px) {
1220
1225
  .ui-table[data-stack="lg"] :is(.ui-table__table, tbody, tr, th, td) { display: block; }
1221
1226
  .ui-table[data-stack="lg"] thead { display: none; }
1227
+ .ui-table[data-stack="lg"] .ui-table__selectall { display: flex; }
1222
1228
  .ui-table[data-stack="lg"] .ui-table__region { border: 0; background: none; overflow: visible; }
1223
1229
  .ui-table[data-stack="lg"] tbody tr { position: relative; margin-block-end: 12px; padding: 8px 12px; border: 1px solid var(--color-hairline); border-radius: var(--radius-4); background: var(--color-panel-solid); }
1224
1230
  .ui-table[data-stack="lg"] .ui-table__table tbody :is(th, td) { display: flex; justify-content: space-between; gap: 12px; block-size: auto; min-block-size: 32px; align-items: center; padding: 4px 0; border: 0; text-align: end; }