All templates
Nested navigation
List Detail - Settings
Settings navigation beside the selected settings page, inside the sidebar layout. Settings, preferences, account, brand, business profile, configuration, or secondary navigation.
npx @adaptavant/edscli templates nested-navigationPreview
Open in new tabCode
'use client';
import {
Avatar,
CalDynamicIcon,
Heading,
IconButton,
type IconProps,
MenuItem,
Text,
Tooltip,
} from '@adaptavant/eds-core';
import {
ActivityIcon,
CalendarPreferencesIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronUpIcon,
ConnectIcon,
CustomerIcon,
GeneralIcon,
GettingStartedIcon,
GrowYourBrandIcon,
IntegrationsIcon,
LogoutIcon,
MenuIcon,
MoneyIcon,
NotificationsIcon,
ProfileIcon,
PromotionIcon,
ReportsIcon,
ReviewStarIcon,
ServicesIcon,
SetmoreIcon,
SettingsIcon,
TeamIcon,
UpdatesIcon,
YourAppIcon,
YourBrandIcon,
} from '@adaptavant/eds-icons/icons';
import * as React from 'react';
const NAV_ITEMS = [
{
label: 'Calendar',
icon: ({ size }: IconProps) => (
<CalDynamicIcon size={size}>{new Date().getDate()}</CalDynamicIcon>
),
},
{ label: 'Services', icon: ServicesIcon },
{ label: 'Connect', icon: ConnectIcon },
{ label: 'Customers', icon: CustomerIcon },
{ label: 'Payments', icon: MoneyIcon },
{ label: 'Integrations', icon: IntegrationsIcon },
{ label: 'Updates', icon: UpdatesIcon },
{ label: 'Settings', icon: SettingsIcon },
];
/**
* The app frame: a collapsible navigation sidebar beside a main area that
* scrolls on its own. Pass the current page as `children`.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const [activeNav, setActiveNav] = React.useState('Settings');
const [isNavCollapsed, setIsNavCollapsed] = React.useState(false);
return (
<div className="flex h-screen overflow-hidden bg-canvas text-primary">
<NavSidebar
activeNav={activeNav}
isCollapsed={isNavCollapsed}
onNavigate={setActiveNav}
onToggleCollapsed={() => setIsNavCollapsed((collapsed) => !collapsed)}
/>
{/* The sidebar is fixed, so the main area is offset by its width. */}
<main
className={`min-w-0 flex-1 overflow-y-auto transition-[margin] duration-200 ease-in-out ${
isNavCollapsed ? 'ms-[57px]' : 'ms-[216px]'
}`}
>
{children}
</main>
</div>
);
}
type SettingsItem = {
label: string;
icon: (props: IconProps) => React.ReactNode;
/** Sub pages, shown when the item is expanded. */
children?: string[];
};
const SETTINGS_SECTIONS: { title?: string; items: SettingsItem[] }[] = [
{
items: [
{ label: 'Your brand', icon: YourBrandIcon },
{ label: 'Your profile', icon: ProfileIcon },
{ label: 'Your team', icon: TeamIcon },
{ label: 'Services', icon: ServicesIcon },
{ label: 'General', icon: GeneralIcon },
],
},
{
title: 'Manage',
items: [
{
label: 'Booking preferences',
icon: CalendarPreferencesIcon,
children: [
'Overview',
'Booking policies',
'Booking setup',
'Customization',
'Booking Page visibility',
],
},
{ label: 'Your branded app', icon: YourAppIcon },
{
label: 'Payments',
icon: MoneyIcon,
children: ['Payment providers', 'Invoices'],
},
{ label: 'Promotions', icon: PromotionIcon },
{ label: 'Reports', icon: ReportsIcon },
{
label: 'Notifications',
icon: NotificationsIcon,
children: ['Email', 'Text messages'],
},
{ label: 'Reviews', icon: ReviewStarIcon },
],
},
{
title: 'More',
items: [
{ label: 'Activity', icon: ActivityIcon },
{ label: 'Getting started', icon: GettingStartedIcon },
{ label: 'Log out', icon: LogoutIcon },
],
},
];
/** Preview: the settings layout with placeholder page content in its slot. */
export default function NestedNavigationTemplate() {
const [activePage, setActivePage] = React.useState('Your brand');
return (
<AppShell>
<SettingsLayout activePage={activePage} onNavigate={setActivePage}>
<div aria-hidden className="flex flex-1 py-6">
<div className="flex-1 rounded-8px bg-neutral-secondary" />
</div>
</SettingsLayout>
</AppShell>
);
}
/**
* The settings frame: the settings navigation beside the active page's title.
* Pass the active page's content as `children`.
*/
export function SettingsLayout({
activePage,
onNavigate,
children,
}: {
activePage: string;
onNavigate: (label: string) => void;
children: React.ReactNode;
}) {
// A sub page shows under its parent's title.
const activeItem = SETTINGS_SECTIONS.flatMap((section) => section.items).find(
(item) => item.label === activePage || item.children?.includes(activePage)
);
const title = activeItem?.label ?? activePage;
const PageIcon = activeItem?.icon ?? SettingsIcon;
return (
<div className="flex h-full">
<SettingsNav activePage={activePage} onNavigate={onNavigate} />
<section
aria-label={title}
className="flex min-w-0 flex-1 flex-col overflow-y-auto px-6"
>
<div className="flex items-center gap-2 border-b border-solid border-tertiary py-4">
<PageIcon size="20" />
<Heading as="h1" className="text-heading-20">
{title}
</Heading>
</div>
{children}
</section>
</div>
);
}
function SettingsNav({
activePage,
onNavigate,
}: {
activePage: string;
onNavigate: (label: string) => void;
}) {
const [expanded, setExpanded] = React.useState<string[]>([]);
const toggle = (label: string) =>
setExpanded((labels) =>
labels.includes(label)
? labels.filter((open) => open !== label)
: [...labels, label]
);
return (
<nav
aria-label="Settings"
className="flex w-[240px] shrink-0 flex-col gap-4 overflow-y-auto border-r border-solid border-tertiary px-3 py-4"
>
<Heading as="h2" className="ps-2 text-heading-20">
Settings
</Heading>
{SETTINGS_SECTIONS.map((section, index) => (
<div className="flex flex-col gap-1" key={section.title ?? index}>
{section.title ? (
<Text className="px-2 pb-1 pt-2 text-body-10 uppercase text-secondary">
{section.title}
</Text>
) : null}
{section.items.map(({ label, icon: Icon, children }) => {
const isExpanded = expanded.includes(label);
const ToggleIcon = isExpanded ? ChevronUpIcon : ChevronDownIcon;
return (
<React.Fragment key={label}>
<MenuItem
aria-current={label === activePage ? 'page' : undefined}
aria-expanded={children ? isExpanded : undefined}
className="rounded-8px text-body-12"
isHighlighted={
label === activePage || children?.includes(activePage)
}
onClick={() => (children ? toggle(label) : onNavigate(label))}
railEnd={children ? <ToggleIcon size="16" /> : undefined}
railStart={<Icon size="16" />}
size="large"
>
{label}
</MenuItem>
{children && isExpanded ? (
// Sub pages hang off a line under the parent's label; the
// active one marks the line.
<div className="ms-11 flex flex-col border-l border-solid border-tertiary">
{children.map((child) => (
<MenuItem
aria-current={child === activePage ? 'page' : undefined}
className={`relative rounded-8px text-body-12 ${
child === activePage
? 'before:absolute before:inset-y-2 before:left-[-1px] before:w-0.5 before:bg-inverse'
: ''
}`}
key={child}
onClick={() => onNavigate(child)}
>
{child}
</MenuItem>
))}
</div>
) : null}
</React.Fragment>
);
})}
</div>
))}
</nav>
);
}
function NavSidebar({
activeNav,
isCollapsed,
onNavigate,
onToggleCollapsed,
}: {
activeNav: string;
isCollapsed: boolean;
onNavigate: (label: string) => void;
onToggleCollapsed: () => void;
}) {
const navItem = (
label: string,
Icon: (typeof NAV_ITEMS)[number]['icon'],
isActive = false
) => {
const item = (triggerProps = {}) => (
<MenuItem
{...triggerProps}
aria-current={isActive ? 'page' : undefined}
aria-label={isCollapsed ? label : undefined}
as="a"
className={
isCollapsed
? 'justify-center origin-left overflow-hidden whitespace-nowrap rounded-8px px-2 text-body-12'
: 'origin-left overflow-hidden whitespace-nowrap rounded-8px text-body-12'
}
classNames={isCollapsed ? { center: 'hidden' } : undefined}
href={`#${label.toLowerCase()}`}
isHighlighted={isActive}
key={label}
onClick={() => onNavigate(label)}
railStart={<Icon size="16" />}
size="large"
>
{label}
</MenuItem>
);
// Collapsed, only the icon shows: the tooltip names it.
return isCollapsed ? (
<Tooltip content={label} key={label} placement="right">
{({ triggerProps }) => item(triggerProps)}
</Tooltip>
) : (
item()
);
};
// Collapsed, the rail's inner width is 32px: padding sizes each row to it.
return (
<aside
className={`fixed left-0 top-0 z-[2] flex h-screen origin-left flex-col items-start gap-6 border-r border-solid border-tertiary bg-neutral pb-4 pe-3 ps-3 pt-4 transition-[width] duration-200 ease-in-out ${
isCollapsed ? 'w-[57px]' : 'w-[216px]'
}`}
>
{/* Collapsed, the header is only the menu button that expands it. */}
<div
className={`flex w-full items-center gap-2 overflow-hidden whitespace-nowrap ${
isCollapsed ? 'justify-center' : 'px-2'
}`}
>
{isCollapsed ? (
<IconButton
aria-label="Expand sidebar"
icon={MenuIcon}
onClick={onToggleCollapsed}
variant="neutralTertiary"
/>
) : (
<>
<SetmoreIcon className="shrink-0" size="24" />
<Text className="where:block text-primary text-body-12 font-stronger line-clamp-2 w-[110px] flex-shrink-0 !leading-4">
AnywhereWorks
</Text>
<IconButton
aria-label="Collapse sidebar"
icon={ChevronLeftIcon}
onClick={onToggleCollapsed}
variant="neutralTertiary"
/>
</>
)}
</div>
<nav aria-label="Primary" className="flex w-full flex-col gap-1">
{navItem('Grow your brand', GrowYourBrandIcon)}
<div className="my-2" />
{NAV_ITEMS.map((item) =>
navItem(item.label, item.icon, item.label === activeNav)
)}
</nav>
<div className="mt-auto flex w-full flex-col gap-1">
<MenuItem
aria-label={isCollapsed ? 'Alex Morgan' : undefined}
className={
isCollapsed
? 'justify-start overflow-hidden whitespace-nowrap rounded-8px px-1 text-body-12'
: ' overflow-hidden justify-start whitespace-nowrap rounded-8px text-body-12'
}
classNames={{ center: isCollapsed ? 'w-0' : 'w-full ' }}
railStart={<Avatar name="Alex Morgan" size="24" status="available" />}
size="large"
>
{isCollapsed ? null : 'Alex Morgan'}
</MenuItem>
</div>
</aside>
);
}