All templates

List and detail

List Detail - Customers

Customer list beside the selected customer’s profile, inside the sidebar layout. List detail, master detail, two column, CRM, contacts, directory, profile, or search list.

npx @adaptavant/edscli templates list-detail

Code

'use client';

import {
	Avatar,
	Button,
	CalDynamicIcon,
	CheckboxPrimitive,
	DropdownMenu,
	DropdownMenuItem,
	DropdownMenuList,
	DropdownMenuPopover,
	DropdownMenuTrigger,
	Field,
	Heading,
	IconButton,
	type IconProps,
	Label,
	MenuItem,
	SearchInput,
	TabContent,
	TabItem,
	TabList,
	Tabs,
	Text,
	TextLink,
	Tooltip,
} from '@adaptavant/eds-core';
import {
	AddIcon,
	ChevronLeftIcon,
	CompanyIcon,
	ConnectIcon,
	CustomerIcon,
	EditIcon,
	GrowYourBrandIcon,
	IntegrationsIcon,
	MailIcon,
	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('Customers');
	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 Customer = {
	id: string;
	name: string;
	phone?: string;
	email?: string;
	company?: string;
};

const CUSTOMERS: Customer[] = [
	{ id: '1', name: 'Aaron Blake', company: 'Northwind' },
	{ id: '2', name: 'Abigail Chen', phone: '+1 415 555 0132' },
	{ id: '3', name: 'Adam Foster', email: 'adam.foster@example.com' },
	{ id: '4', name: 'Adrian Hughes' },
	{ id: '5', name: 'Aisha Patel', phone: '+1 212 555 0178', company: 'Lumen' },
	{ id: '6', name: 'Alan Brooks' },
	{ id: '7', name: 'Alice Moreno', email: 'alice.moreno@example.com' },
	{ id: '8', name: 'Amelia Ward' },
	{ id: '9', name: 'Andre Silva', company: 'Brightside' },
	{ id: '10', name: 'Anna Kowalski' },
	{ id: '11', name: 'Arjun Mehta', phone: '+44 20 7946 0958' },
	{ id: '12', name: 'Ava Thompson' },
	{ id: '13', name: 'Benjamin Clark', email: 'ben.clark@example.com' },
	{ id: '14', name: 'Bianca Rossi' },
	{ id: '15', name: 'Caleb Turner', company: 'Harbor & Co' },
];

const PROFILE_TABS = ['About', 'Notes', 'Appointments', 'Updates'];

/** Preview: the customer list and profile in the shell's slot. */
export default function ListDetailTemplate() {
	const [selectedId, setSelectedId] = React.useState('1');
	const selected = CUSTOMERS.find((customer) => customer.id === selectedId);

	return (
		<AppShell>
			<div className="flex h-full">
				<CustomerList onSelect={setSelectedId} selectedId={selectedId} />
				{selected ? (
					<CustomerProfile customer={selected} key={selected.id} />
				) : null}
			</div>
		</AppShell>
	);
}

function CustomerList({
	selectedId,
	onSelect,
}: {
	selectedId: string;
	onSelect: (id: string) => void;
}) {
	const [search, setSearch] = React.useState('');
	const [checkedIds, setCheckedIds] = React.useState<string[]>([]);
	const query = search.trim().toLowerCase();
	const customers = CUSTOMERS.filter((customer) =>
		customer.name.toLowerCase().includes(query)
	);

	return (
		<section
			aria-label="Customers"
			className="flex w-[312px] shrink-0 flex-col border-r border-solid border-tertiary"
		>
			<div className="flex flex-col gap-4 px-3 pt-4">
				<div className="flex items-center justify-between ps-2">
					<Heading as="h1" className="text-heading-20">
						Customers
					</Heading>
					<IconButton
						aria-label="Add customer"
						icon={AddIcon}
						variant="inversePrimary"
					/>
				</div>

				<div className="flex items-center justify-between">
					<Tabs defaultValue="all">
						<TabList>
							<TabItem value="all">All</TabItem>
						</TabList>
					</Tabs>
					<DropdownMenu popoverMaxWidth={180} popoverPlacement="bottom-end">
						<DropdownMenuTrigger variant="neutralTertiary">
							Options
						</DropdownMenuTrigger>
						<DropdownMenuPopover>
							<DropdownMenuList>
								<DropdownMenuItem>Import customers</DropdownMenuItem>
								<DropdownMenuItem>Export customers</DropdownMenuItem>
							</DropdownMenuList>
						</DropdownMenuPopover>
					</DropdownMenu>
				</div>

				<Field
					label="Search customers"
					labelVisibility="hidden"
					size="standard"
				>
					<SearchInput
						onChange={(event) => setSearch(event.target.value)}
						onClear={() => setSearch('')}
						placeholder="Search"
						value={search}
					/>
				</Field>
			</div>

			{/* The list scrolls on its own, under the fixed header. */}
			<div className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-3">
				{customers.map((customer) => (
					<MenuItem
						aria-current={customer.id === selectedId ? 'true' : undefined}
						className="group rounded-8px text-body-12"
						isHighlighted={customer.id === selectedId}
						key={customer.id}
						onClick={() => onSelect(customer.id)}
						railStart={
							<CustomerCheckbox
								isChecked={checkedIds.includes(customer.id)}
								name={customer.name}
								onToggle={() =>
									setCheckedIds((ids) =>
										ids.includes(customer.id)
											? ids.filter((id) => id !== customer.id)
											: [...ids, customer.id]
									)
								}
							/>
						}
					>
						{customer.name}
					</MenuItem>
				))}
				{customers.length === 0 ? (
					<Text className="px-2 py-4 text-body-14 text-secondary">
						No customers match “{search}”.
					</Text>
				) : null}
			</div>
		</section>
	);
}

/**
 * The row's avatar, swapped for a checkbox while the row is hovered. A checked
 * row keeps its checkbox.
 */
function CustomerCheckbox({
	name,
	isChecked,
	onToggle,
}: {
	name: string;
	isChecked: boolean;
	onToggle: () => void;
}) {
	return (
		<span className="grid h-6 w-6 place-items-center">
			{isChecked ? null : (
				<Avatar className="group-hover:hidden" name={name} size="24" />
			)}
			{/* Toggling the checkbox must not also select the row. */}
			<Label
				className={
					isChecked
						? 'flex cursor-pointer'
						: 'hidden cursor-pointer group-hover:flex'
				}
				onClick={(event: React.MouseEvent) => event.stopPropagation()}
			>
				<CheckboxPrimitive
					aria-label={`Select ${name}`}
					checked={isChecked}
					onChange={onToggle}
				/>
			</Label>
		</span>
	);
}

function CustomerProfile({ customer }: { customer: Customer }) {
	return (
		<section
			aria-label={customer.name}
			className="flex min-w-0 flex-1 flex-col gap-6 overflow-y-auto p-6"
		>
			<div className="flex items-center gap-4">
				<Avatar name={customer.name} size="64" />
				<Heading as="h2" className="min-w-0 flex-1 truncate text-heading-16">
					{customer.name}
				</Heading>
				<IconButton
					aria-label="Edit customer"
					icon={EditIcon}
					variant="neutralTertiary"
				/>
				<DropdownMenu popoverMaxWidth={180} popoverPlacement="bottom-end">
					{({ triggerProps }) => (
						<>
							<IconButton
								aria-label="More actions"
								icon={MoreIcon}
								variant="neutralTertiary"
								{...triggerProps}
							/>
							<DropdownMenuPopover>
								<DropdownMenuList>
									<DropdownMenuItem>Delete Customer</DropdownMenuItem>
								</DropdownMenuList>
							</DropdownMenuPopover>
						</>
					)}
				</DropdownMenu>
				<Button variant="neutralSecondary">Book appointment</Button>
			</div>

			<Tabs defaultValue="About">
				<TabList>
					{PROFILE_TABS.map((tab) => (
						<TabItem key={tab} value={tab}>
							{tab}
						</TabItem>
					))}
				</TabList>
				<TabContent className="flex flex-col gap-5 pt-6" value="About">
					<DetailRow icon={PhoneIcon}>
						{customer.phone ?? <TextLink href="#">Add phone</TextLink>}
					</DetailRow>
					<DetailRow icon={MailIcon}>
						{customer.email ?? <TextLink href="#">Add email</TextLink>}
					</DetailRow>
					<DetailRow icon={CompanyIcon}>
						{customer.company ?? <TextLink href="#">Add company</TextLink>}
					</DetailRow>
				</TabContent>
				{PROFILE_TABS.slice(1).map((tab) => (
					<TabContent className="pt-6" key={tab} value={tab}>
						<Text className="text-body-14 text-secondary">
							No {tab.toLowerCase()} yet.
						</Text>
					</TabContent>
				))}
			</Tabs>
		</section>
	);
}

function DetailRow({
	icon: Icon,
	children,
}: {
	icon: (props: IconProps) => React.ReactNode;
	children: React.ReactNode;
}) {
	return (
		<div className="flex items-center gap-3 text-body-12">
			<Icon size="16" />
			{children}
		</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>
	);
}