From 7e1673f8983a80201f0e298e34db82a048e411b3 Mon Sep 17 00:00:00 2001 From: Daniel Esteves Date: Fri, 6 Nov 2026 23:47:03 -0400 Subject: [PATCH 1/6] feat(web): add Nav Rail --- .../components/web-demos/index.generated.tsx | 5 + .../web-demos/nav-rail/collapsed.tsx | 87 ++ .../web-demos/nav-rail/controlled.tsx | 117 +++ .../components/web-demos/nav-rail/groups.tsx | 94 +++ .../web-demos/nav-rail/overview.tsx | 119 +++ .../components/web-demos/nav-rail/thumb.tsx | 35 + .../content/docs/web/components/index.mdx | 2 +- .../content/docs/web/components/meta.json | 1 + .../content/docs/web/components/nav-rail.mdx | 681 ++++++++++++++++ apps/docs/public/r/web/nav-rail.json | 26 + apps/docs/public/r/web/registry.json | 23 + .../public/skills/panelui-web/components.md | 1 + .../skills/panelui-web/components/nav-rail.md | 298 +++++++ apps/docs/scripts/web-meta.json | 10 + apps/docs/scripts/web-usage/nav-rail.json | 41 + .../web/src/components/nav-rail/index.tsx | 752 ++++++++++++++++++ packages/web/src/components/nav-rail/rail.ts | 118 +++ packages/web/src/index.ts | 21 + packages/web/test/nav-rail-rail.test.mjs | 70 ++ skills/panelui-web/components.md | 1 + skills/panelui-web/components/nav-rail.md | 298 +++++++ 21 files changed, 2799 insertions(+), 1 deletion(-) create mode 100644 apps/docs/components/web-demos/nav-rail/collapsed.tsx create mode 100644 apps/docs/components/web-demos/nav-rail/controlled.tsx create mode 100644 apps/docs/components/web-demos/nav-rail/groups.tsx create mode 100644 apps/docs/components/web-demos/nav-rail/overview.tsx create mode 100644 apps/docs/components/web-demos/nav-rail/thumb.tsx create mode 100644 apps/docs/content/docs/web/components/nav-rail.mdx create mode 100644 apps/docs/public/r/web/nav-rail.json create mode 100644 apps/docs/public/skills/panelui-web/components/nav-rail.md create mode 100644 apps/docs/scripts/web-usage/nav-rail.json create mode 100644 packages/web/src/components/nav-rail/index.tsx create mode 100644 packages/web/src/components/nav-rail/rail.ts create mode 100644 packages/web/test/nav-rail-rail.test.mjs create mode 100644 skills/panelui-web/components/nav-rail.md diff --git a/apps/docs/components/web-demos/index.generated.tsx b/apps/docs/components/web-demos/index.generated.tsx index 13a0e93e7..1c34d0575 100644 --- a/apps/docs/components/web-demos/index.generated.tsx +++ b/apps/docs/components/web-demos/index.generated.tsx @@ -257,6 +257,11 @@ export const WEB_DEMOS: Record = { 'model-picker/prompt': dynamic(() => import('./model-picker/prompt')), 'model-picker/rail': dynamic(() => import('./model-picker/rail')), 'model-picker/thumb': dynamic(() => import('./model-picker/thumb')), + 'nav-rail/collapsed': dynamic(() => import('./nav-rail/collapsed')), + 'nav-rail/controlled': dynamic(() => import('./nav-rail/controlled')), + 'nav-rail/groups': dynamic(() => import('./nav-rail/groups')), + 'nav-rail/overview': dynamic(() => import('./nav-rail/overview')), + 'nav-rail/thumb': dynamic(() => import('./nav-rail/thumb')), 'not-found/dissolve': dynamic(() => import('./not-found/dissolve')), 'not-found/outline': dynamic(() => import('./not-found/outline')), 'not-found/page': dynamic(() => import('./not-found/page')), diff --git a/apps/docs/components/web-demos/nav-rail/collapsed.tsx b/apps/docs/components/web-demos/nav-rail/collapsed.tsx new file mode 100644 index 000000000..f1bb5c93e --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/collapsed.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { + BellIcon, + CreditCardIcon, + FolderKanbanIcon, + InboxIcon, + LayoutDashboardIcon, + LayersIcon, + LifeBuoyIcon, + SettingsIcon, + UsersIcon, +} from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const stop = (event: { preventDefault(): void }) => event.preventDefault(); + +const ACTIVITY = [ + { label: 'Inbox', detail: '12 unread', icon: BellIcon }, + { label: 'Projects', detail: '4 in review', icon: FolderKanbanIcon }, + { label: 'People', detail: '8 collaborators', icon: UsersIcon }, +]; + +export default function NavRailCollapsed() { + return ( +
+ + + }>Meridian + + + + + } label="Overview" onClick={stop} /> + } label="Inbox" onClick={stop}> + Inbox 12 + + } label="Projects" onClick={stop}> + Projects 4 + + } label="People" onClick={stop} /> + + + + } label="Billing" onClick={stop} /> + } label="Settings" onClick={stop} /> + + + + } label="Help and support" onClick={stop} /> + + + +
+
+
+

+ Folded rail +

+

Everything still one press away

+
+ + 56 px + +
+ +

+ The labels are folded away, so each row keeps its accessible name and shows it in a flyout on hover or focus. + Group headings become dividers, and the two counts become dots on the corner of their icons. +

+ +
+ {ACTIVITY.map(({ label, detail, icon: Icon }) => ( +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/docs/components/web-demos/nav-rail/controlled.tsx b/apps/docs/components/web-demos/nav-rail/controlled.tsx new file mode 100644 index 000000000..d80548787 --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/controlled.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useState } from 'react'; +import { + FolderKanbanIcon, + InboxIcon, + LayoutDashboardIcon, + LayersIcon, + LifeBuoyIcon, + SettingsIcon, + UsersIcon, +} from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const PAGES = [ + { id: 'overview', label: 'Overview', icon: LayoutDashboardIcon, count: 0 }, + { id: 'inbox', label: 'Inbox', icon: InboxIcon, count: 8 }, + { id: 'projects', label: 'Projects', icon: FolderKanbanIcon, count: 0 }, + { id: 'people', label: 'People', icon: UsersIcon, count: 0 }, +]; + +export default function NavRailControlled() { + const [collapsed, setCollapsed] = useState(false); + const [page, setPage] = useState('inbox'); + + return ( +
+ + + }>Meridian + + + + + {PAGES.map((item) => { + const Icon = item.icon; + return ( + } + onClick={(event) => { + event.preventDefault(); + setPage(item.id); + }} + > + {item.label} + {item.count ? {item.count} : null} + + ); + })} + + + + } label="Settings" onClick={(event) => event.preventDefault()} /> + } + label="Help and support" + onClick={(event) => event.preventDefault()} + /> + + + +
+
+

Controlled

+

The app holds the state

+
+ +

+ Pass collapsed and{' '} + onCollapsedChange{' '} + to decide the fold yourself, from a media query or a stored preference. The trigger inside the rail reports + through the same pair. +

+ +
+ + + collapsed: {String(collapsed)} + +
+ +
+

Also controlled

+
+ {PAGES.map((item) => ( + + ))} +
+

+ The active row and the buttons here read the same page state. +

+
+
+
+ ); +} diff --git a/apps/docs/components/web-demos/nav-rail/groups.tsx b/apps/docs/components/web-demos/nav-rail/groups.tsx new file mode 100644 index 000000000..4b6800e0d --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/groups.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { + ChartNoAxesColumnIcon, + CreditCardIcon, + FolderKanbanIcon, + InboxIcon, + LayoutDashboardIcon, + LayersIcon, + SettingsIcon, +} from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const stop = (event: { preventDefault(): void }) => event.preventDefault(); + +const REPORTS = [ + { name: 'Weekly studio pulse', owner: 'Ines', change: '+12%' }, + { name: 'Review turnaround', owner: 'Ravi', change: '−3%' }, + { name: 'Client margin', owner: 'Marta', change: '+6%' }, +]; + +export default function NavRailGroups() { + return ( +
+ + + }>Meridian + + + + + } label="Overview" onClick={stop} /> + } label="Inbox" onClick={stop}> + Inbox 3 + + } label="Projects" onClick={stop} /> + + + + } label="Reports" onClick={stop} /> + + + + + + } label="Settings" onClick={stop} /> + } label="Billing" onClick={stop} /> + + + +
+
+
+

Insights

+

Reports

+
+ + Last 7 days + +
+ +
+
+

Studio pulse

+

+18% week on week

+
+
+ {[34, 48, 40, 56, 50, 68, 62, 74, 60, 84, 78, 92].map((height, index) => ( + + ))} +
+
+ +
+ {REPORTS.map((report) => ( +
+ + {report.name} + {report.owner} + {report.change} +
+ ))} +
+
+
+ ); +} diff --git a/apps/docs/components/web-demos/nav-rail/overview.tsx b/apps/docs/components/web-demos/nav-rail/overview.tsx new file mode 100644 index 000000000..ea69b65f1 --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/overview.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useState } from 'react'; +import { + BellIcon, + CreditCardIcon, + FolderKanbanIcon, + InboxIcon, + LayoutDashboardIcon, + LayersIcon, + LifeBuoyIcon, + UsersIcon, +} from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const ITEMS = [ + { id: 'overview', label: 'Overview', icon: LayoutDashboardIcon, note: 'This week across the studio.' }, + { id: 'inbox', label: 'Inbox', icon: InboxIcon, count: 12, note: 'Twelve conversations are waiting on you.' }, + { id: 'projects', label: 'Projects', icon: FolderKanbanIcon, count: 4, note: 'Four projects are in review this morning.' }, + { id: 'people', label: 'People', icon: UsersIcon, note: 'Eight collaborators, two invitations out.' }, + { id: 'billing', label: 'Billing', icon: CreditCardIcon, note: 'The studio plan renews on 1 December.' }, +]; + +export default function NavRailOverview() { + const [active, setActive] = useState('overview'); + const current = ITEMS.find((item) => item.id === active) ?? ITEMS[0]!; + + return ( +
+ + + }>Meridian + + + + + {ITEMS.map((item) => { + const Icon = item.icon; + return ( + } + onClick={(event) => { + event.preventDefault(); + setActive(item.id); + }} + > + {item.label} + {item.count ? {item.count} : null} + + ); + })} + + + + + } label="Help and support"> + Help and support + + + + +
+
+
+

+ Thursday, 6 November +

+

{current.label}

+
+ +
+ +

{current.note}

+ +
+
+

Open reviews

+

07

+
+ {[30, 46, 38, 62, 54, 74, 68].map((height, index) => ( + + ))} +
+
+
+

Time saved

+

+ 6.4h +

+

Since the new flow shipped.

+
+
+ +
+ + ID + +
+

Ines Duarte

+

ines@meridian.studio

+
+ + Owner + +
+
+
+ ); +} diff --git a/apps/docs/components/web-demos/nav-rail/thumb.tsx b/apps/docs/components/web-demos/nav-rail/thumb.tsx new file mode 100644 index 000000000..a0d4d8898 --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/thumb.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { FolderKanbanIcon, InboxIcon, LayoutDashboardIcon, LayersIcon, UsersIcon } from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const stop = (event: { preventDefault(): void }) => event.preventDefault(); + +export default function NavRailThumb() { + return ( +
+ + + }>Meridian + + + } label="Inbox" onClick={stop}> + Inbox 8 + + } label="Overview" onClick={stop} /> + } label="Projects" onClick={stop} /> + } label="People" onClick={stop} /> + + +
+ + + +
+ + +
+
+
+ ); +} diff --git a/apps/docs/content/docs/web/components/index.mdx b/apps/docs/content/docs/web/components/index.mdx index 0867e3b9b..50fa1a717 100644 --- a/apps/docs/content/docs/web/components/index.mdx +++ b/apps/docs/content/docs/web/components/index.mdx @@ -1,6 +1,6 @@ --- title: Components -description: The 73 components in the web collection, each one running. +description: The 74 components in the web collection, each one running. full: true --- diff --git a/apps/docs/content/docs/web/components/meta.json b/apps/docs/content/docs/web/components/meta.json index e0772e97c..3612a36c1 100644 --- a/apps/docs/content/docs/web/components/meta.json +++ b/apps/docs/content/docs/web/components/meta.json @@ -30,6 +30,7 @@ "menu", "message", "model-picker", + "nav-rail", "not-found", "planet", "prompt", diff --git a/apps/docs/content/docs/web/components/nav-rail.mdx b/apps/docs/content/docs/web/components/nav-rail.mdx new file mode 100644 index 000000000..6ac5cd635 --- /dev/null +++ b/apps/docs/content/docs/web/components/nav-rail.mdx @@ -0,0 +1,681 @@ +--- +title: Nav Rail +description: A collapsible sidebar of destinations that keeps one active pill sliding between rows, and folds to a column of icons with tooltips. +status: new +--- + + + +```tsx lineNumbers +'use client'; + +import { useState } from 'react'; +import { + BellIcon, + CreditCardIcon, + FolderKanbanIcon, + InboxIcon, + LayoutDashboardIcon, + LayersIcon, + LifeBuoyIcon, + UsersIcon, +} from 'lucide-react'; +import { NavRail } from '@/components/panelui/nav-rail'; + +const ITEMS = [ + { id: 'overview', label: 'Overview', icon: LayoutDashboardIcon, note: 'This week across the studio.' }, + { id: 'inbox', label: 'Inbox', icon: InboxIcon, count: 12, note: 'Twelve conversations are waiting on you.' }, + { id: 'projects', label: 'Projects', icon: FolderKanbanIcon, count: 4, note: 'Four projects are in review this morning.' }, + { id: 'people', label: 'People', icon: UsersIcon, note: 'Eight collaborators, two invitations out.' }, + { id: 'billing', label: 'Billing', icon: CreditCardIcon, note: 'The studio plan renews on 1 December.' }, +]; + +export default function NavRailOverview() { + const [active, setActive] = useState('overview'); + const current = ITEMS.find((item) => item.id === active) ?? ITEMS[0]!; + + return ( +
+ + + }>Meridian + + + + + {ITEMS.map((item) => { + const Icon = item.icon; + return ( + } + onClick={(event) => { + event.preventDefault(); + setActive(item.id); + }} + > + {item.label} + {item.count ? {item.count} : null} + + ); + })} + + + + + } label="Help and support"> + Help and support + + + + +
+
+
+

+ Thursday, 6 November +

+

{current.label}

+
+ +
+ +

{current.note}

+ +
+
+

Open reviews

+

07

+
+ {[30, 46, 38, 62, 54, 74, 68].map((height, index) => ( + + ))} +
+
+
+

Time saved

+

+ 6.4h +

+

Since the new flow shipped.

+
+
+ +
+ + ID + +
+

Ines Duarte

+

ines@meridian.studio

+
+ + Owner + +
+
+
+ ); +} +``` + +
+ +Use it for the destinations of a product that should stay visible while the work beside them changes: the sections of a studio, the parts of an editor, the pages of an account. It is a `