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-navigation

Code

'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>
	);
}