@tristan2828/ui-foundation 3.28.1 → 3.29.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.
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
export type NavItem = {
|
|
3
|
+
/** The route this entry links to. */
|
|
4
|
+
to: string;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: ComponentType;
|
|
7
|
+
/** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
|
|
8
|
+
end?: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** A labelled group of sidebar entries: one module's pages, say. */
|
|
11
|
+
export type NavSection = {
|
|
12
|
+
label: string;
|
|
13
|
+
items: readonly NavItem[];
|
|
14
|
+
};
|
|
15
|
+
/** One entry of `AppShell`'s `nav`: a page, or a labelled section of pages. */
|
|
16
|
+
export type NavEntry = NavItem | NavSection;
|
|
17
|
+
export declare function isNavSection(entry: NavEntry): entry is NavSection;
|
|
18
|
+
/** A sidebar group as rendered: an optional label over its items. */
|
|
19
|
+
export type NavGroup = {
|
|
20
|
+
label?: string;
|
|
21
|
+
items: readonly NavItem[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The sidebar groups for a nav, in order. A flat nav (no sections) is the
|
|
25
|
+
* one "Navigation" group it always was. Otherwise each section is its own
|
|
26
|
+
* labelled group, and each run of plain items between them an unlabelled one.
|
|
27
|
+
*/
|
|
28
|
+
export declare function navGroups(nav: readonly NavEntry[]): NavGroup[];
|
|
29
|
+
/**
|
|
30
|
+
* The `to` of the one entry to mark active: the most specific that matches.
|
|
31
|
+
* With '/tasks' and '/tasks/categories' both in the nav, '/tasks/categories/2'
|
|
32
|
+
* marks Categories only, and '/tasks/3' marks Tasks.
|
|
33
|
+
*/
|
|
34
|
+
export declare function activeNavTo(nav: readonly NavEntry[], pathname: string): string | undefined;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export function isNavSection(entry) {
|
|
2
|
+
return 'items' in entry;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The sidebar groups for a nav, in order. A flat nav (no sections) is the
|
|
6
|
+
* one "Navigation" group it always was. Otherwise each section is its own
|
|
7
|
+
* labelled group, and each run of plain items between them an unlabelled one.
|
|
8
|
+
*/
|
|
9
|
+
export function navGroups(nav) {
|
|
10
|
+
if (!nav.some(isNavSection))
|
|
11
|
+
return [{ label: 'Navigation', items: nav }];
|
|
12
|
+
const groups = [];
|
|
13
|
+
let run = [];
|
|
14
|
+
for (const entry of nav) {
|
|
15
|
+
if (isNavSection(entry)) {
|
|
16
|
+
if (run.length)
|
|
17
|
+
groups.push({ items: run });
|
|
18
|
+
run = [];
|
|
19
|
+
groups.push({ label: entry.label, items: entry.items });
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
run.push(entry);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (run.length)
|
|
26
|
+
groups.push({ items: run });
|
|
27
|
+
return groups;
|
|
28
|
+
}
|
|
29
|
+
function matches(item, pathname) {
|
|
30
|
+
if (item.end)
|
|
31
|
+
return pathname === item.to;
|
|
32
|
+
if (pathname === item.to)
|
|
33
|
+
return true;
|
|
34
|
+
// On a path-segment boundary: '/tasks' is active on '/tasks/3', not on
|
|
35
|
+
// '/tasks-archive'.
|
|
36
|
+
return pathname.startsWith(item.to.endsWith('/') ? item.to : `${item.to}/`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The `to` of the one entry to mark active: the most specific that matches.
|
|
40
|
+
* With '/tasks' and '/tasks/categories' both in the nav, '/tasks/categories/2'
|
|
41
|
+
* marks Categories only, and '/tasks/3' marks Tasks.
|
|
42
|
+
*/
|
|
43
|
+
export function activeNavTo(nav, pathname) {
|
|
44
|
+
let best;
|
|
45
|
+
for (const entry of nav) {
|
|
46
|
+
for (const item of isNavSection(entry) ? entry.items : [entry]) {
|
|
47
|
+
if (matches(item, pathname) && (best === undefined || item.to.length > best.length))
|
|
48
|
+
best = item.to;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return best;
|
|
52
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
to: string;
|
|
5
|
-
label: string;
|
|
6
|
-
icon: ComponentType;
|
|
7
|
-
/** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
|
|
8
|
-
end?: boolean;
|
|
9
|
-
};
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type NavEntry } from './app-shell-nav.js';
|
|
3
|
+
export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
|
|
10
4
|
export type AppShellProps = {
|
|
11
5
|
/** Shown at the top of the sidebar (hidden on the collapsed icon rail). */
|
|
12
6
|
title: string;
|
|
13
|
-
/**
|
|
14
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The app's primary navigation, in order: pages, and labelled sections of
|
|
9
|
+
* pages (`{ label, items }`, one per module, say). A nav with no sections
|
|
10
|
+
* is the single "Navigation" group. One entry is marked active: the most
|
|
11
|
+
* specific whose route matches, so '/tasks' and '/tasks/categories' can
|
|
12
|
+
* both be listed.
|
|
13
|
+
*/
|
|
14
|
+
nav: readonly NavEntry[];
|
|
15
15
|
/**
|
|
16
16
|
* More sidebar groups under the primary nav — reference links, say. Built
|
|
17
17
|
* from the sidebar primitives (`@tristan2828/ui-foundation/ui/sidebar`).
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
|
|
3
3
|
import { useTheme } from 'next-themes';
|
|
4
4
|
import { useEffect, useState } from 'react';
|
|
5
|
-
import {
|
|
5
|
+
import { Link, Navigate, Outlet, useLocation } from 'react-router';
|
|
6
6
|
import { useAuth } from '../../auth/use-auth.js';
|
|
7
|
+
import { activeNavTo, navGroups } from './app-shell-nav.js';
|
|
7
8
|
import { ErrorState } from './error-state.js';
|
|
8
9
|
import { Button } from '../ui/button.js';
|
|
9
10
|
import { Skeleton } from '../ui/skeleton.js';
|
|
@@ -64,12 +65,14 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true }
|
|
|
64
65
|
const from = `${location.pathname}${location.search}${location.hash}`;
|
|
65
66
|
return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const activeTo = activeNavTo(nav, location.pathname);
|
|
69
|
+
return (_jsxs(TooltipProvider, { children: [_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 }) => {
|
|
70
|
+
// A plain Link, not NavLink: NavLink marks every
|
|
71
|
+
// matching entry current, and only the most specific
|
|
72
|
+
// one should be (activeNavTo).
|
|
73
|
+
const isActive = to === activeTo;
|
|
74
|
+
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));
|
|
75
|
+
}) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsx(SidebarFooter, { children: _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: () => {
|
|
73
76
|
setIsLoggingOut(true);
|
|
74
77
|
// A failed request still ends the session on screen
|
|
75
78
|
// (AuthProvider), so this stays a deliberate logout: a
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
|
|
1
|
+
export { AppShell, type AppShellProps, type NavEntry, type NavItem, type NavSection } from './components/app/app-shell.js';
|
|
2
2
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
3
3
|
export { DataTable, type DataTableProps, type DataTableWidth, type SortingState } from './components/app/data-table.js';
|
|
4
4
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.29.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": {
|