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