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-detailPreview
Open in new tabCode
'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>
);
}