@tristan2828/ui-foundation 4.13.0 → 4.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.
@@ -11,6 +11,15 @@ export type NavItem = {
11
11
  export type NavSection = {
12
12
  label: string;
13
13
  items: readonly NavItem[];
14
+ /**
15
+ * The label folds the section away: a button with a chevron. Whether
16
+ * it's open is remembered per browser, by the section's label. The
17
+ * section holding the page you're on is always open, and the icon rail
18
+ * shows every section's items whatever their state.
19
+ */
20
+ collapsible?: boolean;
21
+ /** Whether a collapsible section starts open for someone who has never folded it. Defaults to true. */
22
+ defaultOpen?: boolean;
14
23
  };
15
24
  /** One entry of `AppShell`'s `nav`: a page, or a labelled section of pages. */
16
25
  export type NavEntry = NavItem | NavSection;
@@ -19,6 +28,9 @@ export declare function isNavSection(entry: NavEntry): entry is NavSection;
19
28
  export type NavGroup = {
20
29
  label?: string;
21
30
  items: readonly NavItem[];
31
+ /** From its section: the label folds it (NavSection's `collapsible`). */
32
+ collapsible?: boolean;
33
+ defaultOpen?: boolean;
22
34
  };
23
35
  /**
24
36
  * The sidebar groups for a nav, in order. A flat nav (no sections) is the
@@ -16,7 +16,11 @@ export function navGroups(nav) {
16
16
  if (run.length)
17
17
  groups.push({ items: run });
18
18
  run = [];
19
- groups.push({ label: entry.label, items: entry.items });
19
+ groups.push({
20
+ label: entry.label,
21
+ items: entry.items,
22
+ ...(entry.collapsible && { collapsible: true, defaultOpen: entry.defaultOpen ?? true }),
23
+ });
20
24
  }
21
25
  else {
22
26
  run.push(entry);
@@ -11,7 +11,10 @@ export type AppShellProps = {
11
11
  * pages (`{ label, items }`, one per module, say). A nav with no sections
12
12
  * is the single "Navigation" group. One entry is marked active: the most
13
13
  * specific whose route matches, so '/tasks' and '/tasks/categories' can
14
- * both be listed.
14
+ * both be listed. A section marked `collapsible` folds under its label,
15
+ * remembered per browser (`defaultOpen` for the first visit); the one
16
+ * holding the current page stays open, and the icon rail shows every
17
+ * section's items.
15
18
  */
16
19
  nav: readonly NavEntry[];
17
20
  /**
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
2
+ import { ChevronRightIcon, LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
3
3
  import { useTheme } from 'next-themes';
4
- import { useEffect, useState } from 'react';
4
+ import { useEffect, useId, useState } from 'react';
5
5
  import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
7
  import { activeNavTo, navGroups } from './app-shell-nav.js';
@@ -9,6 +9,7 @@ import { versionHref, versionLabel } from './app-shell-version.js';
9
9
  import { ErrorState } from './error-state.js';
10
10
  import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
11
11
  import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
12
+ import { cn } from 'cn';
12
13
  import { Button } from '../ui/button.js';
13
14
  import { Skeleton } from '../ui/skeleton.js';
14
15
  import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
@@ -47,6 +48,64 @@ function VersionEntry({ version }) {
47
48
  const className = 'block truncate px-2 type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden';
48
49
  return href ? (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", className: `${className} hover:underline`, children: label })) : (_jsx("span", { className: className, children: label }));
49
50
  }
51
+ // One group's entries. A plain Link, not NavLink: NavLink marks every
52
+ // matching entry current, and only the most specific one should be
53
+ // (activeNavTo).
54
+ function NavMenu({ items, activeTo }) {
55
+ return (_jsx(SidebarMenu, { children: items.map(({ to, label, icon: Icon }) => {
56
+ const isActive = to === activeTo;
57
+ return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
58
+ }) }));
59
+ }
60
+ // Whether a collapsible section is open, remembered per browser by its
61
+ // label. Storage can be missing or refuse (a private window): then it's
62
+ // remembered for the page's life only.
63
+ const SECTION_KEY = (label) => `ui-foundation:nav-section:${label}`;
64
+ function storedSectionOpen(label) {
65
+ try {
66
+ const stored = window.localStorage.getItem(SECTION_KEY(label));
67
+ return stored === null ? null : stored === 'open';
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ function storeSectionOpen(label, open) {
74
+ try {
75
+ window.localStorage.setItem(SECTION_KEY(label), open ? 'open' : 'closed');
76
+ }
77
+ catch {
78
+ // Not remembered; the state still holds until the page reloads.
79
+ }
80
+ }
81
+ // A section whose label folds it (#152). On the icon rail (the collapsed
82
+ // sidebar on a wide screen) its items always show and its label stays the
83
+ // rail's hidden one, so no page is out of reach and no hidden button
84
+ // takes a tab stop. The section holding the current page is always open:
85
+ // arriving in a closed one opens it and remembers that.
86
+ function CollapsibleNavGroup({ group, activeTo }) {
87
+ const { state, isMobile } = useSidebar();
88
+ const contentId = useId();
89
+ const [stored, setStored] = useState(() => storedSectionOpen(group.label));
90
+ const holdsCurrent = group.items.some((item) => item.to === activeTo);
91
+ // Arriving in a folded section unfolds it, and it stays unfolded after.
92
+ if (holdsCurrent && stored !== true)
93
+ setStored(true);
94
+ useEffect(() => {
95
+ if (holdsCurrent)
96
+ storeSectionOpen(group.label, true);
97
+ }, [holdsCurrent, group.label]);
98
+ if (state === 'collapsed' && !isMobile) {
99
+ return (_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }));
100
+ }
101
+ const open = holdsCurrent || (stored ?? group.defaultOpen ?? true);
102
+ return (_jsxs(SidebarGroup, { "data-collapsible-section": open ? 'open' : 'closed', children: [_jsxs(SidebarGroupLabel, { render: _jsx("button", { type: "button", "aria-expanded": open, "aria-controls": contentId, "aria-disabled": holdsCurrent || undefined, onClick: () => {
103
+ if (holdsCurrent)
104
+ return;
105
+ setStored(!open);
106
+ storeSectionOpen(group.label, !open);
107
+ } }), className: "w-full justify-between text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-disabled:hover:bg-transparent aria-disabled:hover:text-sidebar-foreground/70", children: [group.label, _jsx(ChevronRightIcon, { "aria-hidden": "true", className: cn('transition-transform', open && 'rotate-90') })] }), _jsx(SidebarGroupContent, { id: contentId, hidden: !open, children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }));
108
+ }
50
109
  function AppShellSkeleton() {
51
110
  return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
52
111
  }
@@ -79,13 +138,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true,
79
138
  return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
80
139
  }
81
140
  const activeTo = activeNavTo(nav, location.pathname);
82
- return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(SidebarMenu, { children: group.items.map(({ to, label, icon: Icon }) => {
83
- // A plain Link, not NavLink: NavLink marks every
84
- // matching entry current, and only the most specific
85
- // one should be (activeNavTo).
86
- const isActive = to === activeTo;
87
- return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
88
- }) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
141
+ return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => group.collapsible && group.label !== undefined ? (_jsx(CollapsibleNavGroup, { group: { ...group, label: group.label }, activeTo: activeTo }, `${groupIndex}:${group.label}`)) : (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
89
142
  setIsLoggingOut(true);
90
143
  // A failed request still ends the session on screen
91
144
  // (AuthProvider), so this stays a deliberate logout: a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.13.0",
3
+ "version": "4.14.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {