All templates

List, detail and panel

List Detail - Three Column

List, detail, and a collapsible side panel inside the sidebar layout. Three column, 3 column, inbox, messages, conversations, triage, master detail with aside, or activity panel.

npx @adaptavant/edscli templates list-detail-panel

Code

'use client';

import {
	Avatar,
	CalDynamicIcon,
	DropdownMenu,
	DropdownMenuItem,
	DropdownMenuList,
	DropdownMenuPopover,
	Field,
	Heading,
	IconButton,
	type IconProps,
	MenuItem,
	SearchInput,
	TabItem,
	TabList,
	Tabs,
	Text,
	TextLink,
	Tooltip,
} from '@adaptavant/eds-core';
import {
	CheckCircleIcon,
	ChevronLeftIcon,
	ChevronRightIcon,
	ConnectIcon,
	CustomerIcon,
	FilterIcon,
	GrowYourBrandIcon,
	IntegrationsIcon,
	MenuIcon,
	MoneyIcon,
	MoreIcon,
	PhoneIcon,
	ServicesIcon,
	SetmoreIcon,
	SettingsIcon,
	UpdatesIcon,
} 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('Connect');
	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>
	);
}

/**
 * Three columns inside the shell: a fixed-width `list`, the main `children`,
 * and an `aside` panel the user can collapse. Each column scrolls on its own.
 */
export function ThreeColumnLayout({
	list,
	children,
	aside,
	asideLabel,
}: {
	list: React.ReactNode;
	children: React.ReactNode;
	aside: React.ReactNode;
	/** Names the aside panel, e.g. for its collapse button. */
	asideLabel: string;
}) {
	const [isAsideOpen, setIsAsideOpen] = React.useState(true);

	return (
		<div className="flex h-full">
			<div className="flex w-[312px] shrink-0 flex-col overflow-y-auto border-r border-solid border-tertiary">
				{list}
			</div>
			<div className="relative flex min-w-0 flex-1 flex-col overflow-y-auto">
				{children}
			</div>
			{/* The toggle straddles the aside's border; collapsed, it moves inside. */}
			<div
				className={`relative shrink-0 border-l border-solid border-tertiary transition-[width] duration-200 ease-in-out ${
					isAsideOpen ? 'w-[320px]' : 'w-0'
				}`}
			>
				<IconButton
					aria-expanded={isAsideOpen}
					aria-label={`${isAsideOpen ? 'Collapse' : 'Expand'} ${asideLabel}`}
					className={`absolute top-20 z-[1] h-6 w-6 rounded-full border border-solid border-tertiary bg-canvas ${
						isAsideOpen ? '-left-3' : '-left-8'
					}`}
					icon={isAsideOpen ? ChevronRightIcon : ChevronLeftIcon}
					onClick={() => setIsAsideOpen((open) => !open)}
					size="small"
					variant="neutralTertiary"
				/>
				<aside
					aria-label={asideLabel}
					className={`flex h-full w-[320px] flex-col overflow-y-auto ${
						isAsideOpen ? '' : 'invisible'
					}`}
				>
					{aside}
				</aside>
			</div>
		</div>
	);
}

/** The list column: title, optional view switcher and actions, then search. */
function ListPane({
	title,
	view,
	actions,
	search,
	onSearchChange,
	children,
}: {
	title: string;
	view?: React.ReactNode;
	actions?: React.ReactNode;
	search: string;
	onSearchChange: (search: string) => void;
	children: React.ReactNode;
}) {
	return (
		<section aria-label={title} className="flex flex-col gap-4 px-3 py-4">
			<div className="flex flex-col gap-1 ps-2">
				<div className="flex items-center justify-between">
					<Heading as="h1" className="text-heading-20">
						{title}
					</Heading>
					{actions}
				</div>
				{view}
			</div>
			<Field label={`Search ${title.toLowerCase()}`} labelVisibility="hidden">
				<SearchInput
					onChange={(event) => onSearchChange(event.target.value)}
					onClear={() => onSearchChange('')}
					placeholder="Search"
					value={search}
				/>
			</Field>
			<div className="flex flex-col gap-1">{children}</div>
		</section>
	);
}

/** A list row: avatar, title with a trailing meta, and one line of preview. */
function ListRow({
	title,
	meta,
	preview,
	isSelected,
	onSelect,
}: {
	title: string;
	meta?: string;
	preview?: string;
	isSelected: boolean;
	onSelect: () => void;
}) {
	return (
		<MenuItem
			aria-current={isSelected ? 'true' : undefined}
			className="rounded-8px py-2"
			isHighlighted={isSelected}
			onClick={onSelect}
			railStart={<Avatar name={title} size="40" />}
		>
			<span className="flex min-w-0 flex-col gap-0.5">
				<span className="flex items-baseline justify-between gap-2">
					<Text className="truncate text-body-14">{title}</Text>
					{meta ? (
						<Text className="shrink-0 text-body-12 text-secondary">{meta}</Text>
					) : null}
				</span>
				{preview ? (
					<Text className="truncate text-body-12 text-secondary">
						{preview}
					</Text>
				) : null}
			</span>
		</MenuItem>
	);
}

/** A labelled value in the main column. */
function DetailField({
	label,
	children,
}: {
	label: string;
	children: React.ReactNode;
}) {
	return (
		<div className="flex flex-col gap-1">
			<Text className="text-body-12 text-secondary">{label}</Text>
			<div className="text-body-14">{children}</div>
		</div>
	);
}

type Message = {
	id: string;
	from: string;
	isVerified?: boolean;
	fromNumber: string;
	toNumber: string;
	answeredBy: string;
	email: string;
	body: string;
	takenAt: string;
	date: string;
};

const MESSAGES: Message[] = [
	{
		id: '1',
		from: 'AnswerConnect',
		isVerified: true,
		fromNumber: '+44 800 802 1039',
		toNumber: '+44 922 230 0349',
		answeredBy: 'Stanley M',
		email: 'clientservices@answerconnect.co.uk',
		body: 'Welcome aboard! We’re delighted to partner with you in providing the best call experience for your customers.',
		takenAt: '30 Oct 2024 · 12:58 PM GMT',
		date: '30 Oct 2024',
	},
	{
		id: '2',
		from: 'Priya Nair',
		fromNumber: '+44 20 7946 0123',
		toNumber: '+44 922 230 0349',
		answeredBy: 'Jordan K',
		email: 'priya.nair@example.com',
		body: 'Could you move my Thursday appointment to the afternoon? Anytime after 2 PM works.',
		takenAt: '29 Oct 2024 · 09:14 AM GMT',
		date: '29 Oct 2024',
	},
	{
		id: '3',
		from: 'Tom Hughes',
		fromNumber: '+44 161 496 0754',
		toNumber: '+44 922 230 0349',
		answeredBy: 'Stanley M',
		email: 'tom.hughes@example.com',
		body: 'Calling about the quote you sent last week. Please call me back when you can.',
		takenAt: '27 Oct 2024 · 04:40 PM GMT',
		date: '27 Oct 2024',
	},
];

const VIEWS = ['All messages', 'Unread', 'Starred'];

/** Preview: an inbox, its selected message, and an activity panel. */
export default function ListDetailPanelTemplate() {
	const [selectedId, setSelectedId] = React.useState('1');
	const [search, setSearch] = React.useState('');
	const [view, setView] = React.useState<string | undefined>(VIEWS[0]);
	const query = search.trim().toLowerCase();
	const messages = MESSAGES.filter((message) =>
		`${message.from} ${message.body}`.toLowerCase().includes(query)
	);
	const selected = MESSAGES.find((message) => message.id === selectedId);

	return (
		<AppShell>
			<ThreeColumnLayout
				aside={<Activities />}
				asideLabel="Activities"
				list={
					<ListPane
						actions={
							<IconButton
								aria-label="Filter messages"
								icon={FilterIcon}
								variant="neutralTertiary"
							/>
						}
						onSearchChange={setSearch}
						search={search}
						title="Inbox"
						view={
							<Tabs
								onSelectedValueChange={setView}
								selectedValue={view}
								size="small"
								variant="pills"
							>
								<TabList>
									{VIEWS.map((option) => (
										<TabItem key={option} value={option}>
											{option}
										</TabItem>
									))}
								</TabList>
							</Tabs>
						}
					>
						{messages.map((message) => (
							<ListRow
								isSelected={message.id === selectedId}
								key={message.id}
								meta={message.date}
								onSelect={() => setSelectedId(message.id)}
								preview={message.body}
								title={message.from}
							/>
						))}
						{messages.length === 0 ? (
							<Text className="px-2 py-4 text-body-14 text-secondary">
								No messages match “{search}”.
							</Text>
						) : null}
					</ListPane>
				}
			>
				{selected ? <MessageDetail message={selected} /> : null}
			</ThreeColumnLayout>
		</AppShell>
	);
}

function MessageDetail({ message }: { message: Message }) {
	return (
		<section aria-label={message.from} className="flex flex-col gap-6 p-6">
			<div className="flex items-center gap-4">
				<Avatar name={message.from} size="64" />
				<div className="flex min-w-0 flex-1 items-center gap-2">
					<Heading as="h2" className="truncate text-heading-16">
						{message.from}
					</Heading>
					{message.isVerified ? (
						<CheckCircleIcon aria-label="Verified" size="16" />
					) : null}
				</div>
				<IconButton
					aria-label={`Call ${message.from}`}
					icon={PhoneIcon}
					variant="neutralTertiary"
				/>
				<DropdownMenu popoverMaxWidth={180} popoverPlacement="bottom-end">
					{({ triggerProps }) => (
						<>
							<IconButton
								aria-label="More actions"
								icon={MoreIcon}
								variant="neutralTertiary"
								{...triggerProps}
							/>
							<DropdownMenuPopover>
								<DropdownMenuList>
									<DropdownMenuItem>Mark as unread</DropdownMenuItem>
									<DropdownMenuItem>Delete message</DropdownMenuItem>
								</DropdownMenuList>
							</DropdownMenuPopover>
						</>
					)}
				</DropdownMenu>
			</div>

			<div className="grid grid-cols-2 gap-6">
				<DetailField label="From">{message.fromNumber}</DetailField>
				<DetailField label="To">{message.toNumber}</DetailField>
				<DetailField label="Answered by">{message.answeredBy}</DetailField>
				<DetailField label="Message taken">{message.takenAt}</DetailField>
			</div>
			<DetailField label="Email">
				<TextLink href={`mailto:${message.email}`}>{message.email}</TextLink>
			</DetailField>
			<DetailField label="Message">{message.body}</DetailField>
		</section>
	);
}

function Activities() {
	return (
		<div className="flex h-full flex-col gap-6 p-6">
			<Heading as="h2" className="text-heading-20">
				Activities
			</Heading>
			<Text className="m-auto text-body-14 text-secondary">
				No activity yet
			</Text>
		</div>
	);
}

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