diff --git a/apps/docs/components/web-demos/index.generated.tsx b/apps/docs/components/web-demos/index.generated.tsx index 1eb365a6..e47c6d73 100644 --- a/apps/docs/components/web-demos/index.generated.tsx +++ b/apps/docs/components/web-demos/index.generated.tsx @@ -259,6 +259,14 @@ 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/nested': dynamic(() => import('./nav-rail/nested')), + 'nav-rail/nested-folded': dynamic(() => import('./nav-rail/nested-folded')), + 'nav-rail/overview': dynamic(() => import('./nav-rail/overview')), + 'nav-rail/thumb': dynamic(() => import('./nav-rail/thumb')), + 'nav-rail/variants': dynamic(() => import('./nav-rail/variants')), '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 00000000..f1bb5c93 --- /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 00000000..d8054878 --- /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 00000000..4b6800e0 --- /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/nested-folded.tsx b/apps/docs/components/web-demos/nav-rail/nested-folded.tsx new file mode 100644 index 00000000..63a270ae --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/nested-folded.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { + ArchiveIcon, + ChartNoAxesColumnIcon, + CircleDotIcon, + FolderKanbanIcon, + InboxIcon, + LayersIcon, + LayoutDashboardIcon, + LifeBuoyIcon, + UsersIcon, +} from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const stop = (event: { preventDefault(): void }) => event.preventDefault(); + +export default function NavRailNestedFolded() { + return ( +
+ + + }>Meridian + + + + + } label="Overview" onClick={stop} /> + } label="Inbox" onClick={stop}> + Inbox 5 + + + } onClick={stop}> + Projects + {/* Open by default, so the flyout is on screen for the demo; the + same list appears on hover or focus without this. */} + + } onClick={stop}> + Active projects + + } onClick={stop}> + Archive + + } onClick={stop}> + Client work + + + + + } onClick={stop}> + Reports + + } onClick={stop}> + Weekly pulse + + } onClick={stop}> + Saved reports + + + + + + + } label="Help and support" onClick={stop} /> + + + +
+
+

Nested, folded

+

Children beside the rail

+
+ +

+ Folded, the same child list becomes a flyout beside the rail. Hover or focus Projects to open it, and Tab into + the links while it is open. The flyout is not clipped by the rail, and the open child keeps its label and its + active weight even though the rail is a column of icons. +

+ +
+

From the keyboard

+
    +
  • + → opens the + list on the parent row, then steps into it +
  • +
  • + ← or{' '} + Esc closes + it and puts focus back on the parent +
  • +
  • A press anywhere outside closes a flyout that was pinned open
  • +
+
+
+
+ ); +} diff --git a/apps/docs/components/web-demos/nav-rail/nested.tsx b/apps/docs/components/web-demos/nav-rail/nested.tsx new file mode 100644 index 00000000..0c0632e0 --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/nested.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import { + ArchiveIcon, + CircleDotIcon, + FolderKanbanIcon, + LayersIcon, + LayoutDashboardIcon, + InboxIcon, + UsersIcon, +} from 'lucide-react'; +import { NavRail } from 'panelui-web/nav-rail'; + +const stop = (event: { preventDefault(): void }) => event.preventDefault(); + +const PAGES = [ + { id: 'active', label: 'Active projects', icon: CircleDotIcon }, + { id: 'archive', label: 'Archive', icon: ArchiveIcon }, + { id: 'clients', label: 'Client work', icon: UsersIcon }, +]; + +export default function NavRailNested() { + const [page, setPage] = useState('active'); + const [projectsOpen, setProjectsOpen] = useState(true); + const current = PAGES.find((item) => item.id === page) ?? PAGES[0]!; + + return ( +
+ + + }>Meridian + + + + + } label="Overview" onClick={stop} /> + } label="Inbox" onClick={stop}> + Inbox 5 + + + } onClick={stop}> + Projects + + {PAGES.map((item) => { + const Icon = item.icon; + return ( + } + onClick={(event) => { + event.preventDefault(); + setPage(item.id); + }} + > + {item.label} + + ); + })} + + + + } label="People" onClick={stop} /> + + + +
+
+

Nested

+

{current.label}

+
+ +

+ Projects owns a child list. The parent is still a link to its own page, and the disclosure beside it opens the + list. The active child carries aria-current="page"{' '} + and the one pill moves onto its row; the parent only takes the tint. +

+ +
+ + + open: {String(projectsOpen)} + +
+ +

+ open and{' '} + onOpenChange sit on the sub-list, not on the rail. Close the list + and the pill stays on the parent, so the rail never loses the current place. +

+
+
+ ); +} 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 00000000..ea69b65f --- /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 00000000..a0d4d889 --- /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/components/web-demos/nav-rail/variants.tsx b/apps/docs/components/web-demos/nav-rail/variants.tsx new file mode 100644 index 00000000..29a6e73f --- /dev/null +++ b/apps/docs/components/web-demos/nav-rail/variants.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useState } from 'react'; +import { FolderKanbanIcon, InboxIcon, LayoutDashboardIcon, LayersIcon } from 'lucide-react'; +import { NavRail, type NavRailVariant } from 'panelui-web/nav-rail'; + +const ITEMS = [ + { id: 'overview', label: 'Overview', icon: LayoutDashboardIcon }, + { id: 'inbox', label: 'Inbox', icon: InboxIcon, count: 12 }, + { id: 'projects', label: 'Projects', icon: FolderKanbanIcon }, +]; + +function Example({ variant, title }: { variant: NavRailVariant; title: string }) { + const [active, setActive] = useState('inbox'); + + return ( +
+ + {title} + +
+ + + }>Meridian + + + + + {ITEMS.map((item) => { + const Icon = item.icon; + return ( + } + onClick={(event) => { + event.preventDefault(); + setActive(item.id); + }} + > + {item.label} + {item.count ? {item.count} : null} + + ); + })} + + +
+
+ ); +} + +export default function NavRailVariants() { + return ( +
+ + +
+ ); +} diff --git a/apps/docs/content/docs/web/components/index.mdx b/apps/docs/content/docs/web/components/index.mdx index 0867e3b9..50fa1a71 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 e0772e97..3612a36c 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 00000000..ef0c8cbd --- /dev/null +++ b/apps/docs/content/docs/web/components/nav-rail.mdx @@ -0,0 +1,1010 @@ +--- +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 `