Templates
Detail drawer
The details of a record picked from a list in a side panel, without leaving the page; previous and next, activity and an edit action.
The detail drawer opens a record from a list without changing the page. People keep their place in the list; when the drawer closes, focus goes back to the row they picked. The row menu of the List and filters block opens the same drawer.
Preview
Section titled “Preview”Press a row: the drawer slides in from the inline end. Move between records with the previous and next buttons without closing it. The brand and mode switches in the page header restyle the preview too.
pnpm add @bigbang/ui| Data | Type | Note |
|---|---|---|
record |
DetailRecord | null |
null closes the drawer; the last record stays drawn while it slides out. |
position |
{ index, total, onMove } |
When given, the previous and next buttons and the “2 of 6” position show. |
openRecord |
number |
In the block demo, the record open on first render (1-based). |
RecordDetailDrawer is exported on its own, so another list can use the same drawer.
import { Button, DescriptionList, Drawer, Heading, Icon, IconButton, Inline, ListItem, Stack, StatusBadge, Text, useToast } from '@bigbang/ui';import type { IconName, StatusKey } from '@bigbang/ui';import { useState } from 'react';import type { Lang } from '../../patterns/lang';import { AppShellTemplate } from '../../templates/app-shell/AppShellTemplate';
const copy = { tr: { title: 'Kayıtlar', description: 'Bir kaydı seçin; ayrıntılar sayfadan ayrılmadan yan panelde açılır.', listLabel: 'Kayıt listesi', record: (index: number) => `Kayıt ${index}`, categories: { a: 'Kategori A', b: 'Kategori B', c: 'Kategori C' }, status: 'Durum', category: 'Kategori', created: 'Oluşturulma', count: 'Adet', owner: 'Sahibi', ownerValue: 'Kullanıcı A', activity: 'Etkinlik', events: [ { icon: 'pencil', title: 'Ad güncellendi', meta: '2 saat önce' }, { icon: 'user', title: 'Kullanıcı B eklendi', meta: 'Dün' }, { icon: 'plus', title: 'Kayıt oluşturuldu', meta: '3 gün önce' }, ], previous: 'Önceki kayıt', next: 'Sonraki kayıt', position: (index: number, total: number) => `${total} kayıttan ${index}.`, close: 'Kapat', edit: 'Düzenle', editing: (name: string) => `${name} düzenleme için açıldı.`, }, en: { title: 'Records', description: 'Pick a record; its details open in a side panel without leaving the page.', listLabel: 'Record list', record: (index: number) => `Record ${index}`, categories: { a: 'Category A', b: 'Category B', c: 'Category C' }, status: 'Status', category: 'Category', created: 'Created', count: 'Count', owner: 'Owner', ownerValue: 'User A', activity: 'Activity', events: [ { icon: 'pencil', title: 'Name updated', meta: '2 hours ago' }, { icon: 'user', title: 'User B added', meta: 'Yesterday' }, { icon: 'plus', title: 'Record created', meta: '3 days ago' }, ], previous: 'Previous record', next: 'Next record', position: (index: number, total: number) => `${index} of ${total}`, close: 'Close', edit: 'Edit', editing: (name: string) => `${name} opened for editing.`, },};
export type DetailRecord = { id: string; name: string; status: StatusKey; category: 'a' | 'b' | 'c'; day: number; count: number };
const statusCycle: StatusKey[] = ['active', 'pending', 'completed', 'archived'];const categoryCycle = ['a', 'b', 'c'] as const;
function makeRecords(lang: Lang): DetailRecord[] { return Array.from({ length: 6 }, (_, index) => ({ id: `record-${index + 1}`, name: copy[lang].record(index + 1), status: statusCycle[index % statusCycle.length] ?? 'active', category: categoryCycle[index % categoryCycle.length] ?? 'a', day: index + 20, count: (index + 1) * 50, }));}
const dateFormat = (lang: Lang) => new Intl.DateTimeFormat(lang === 'tr' ? 'tr-TR' : 'en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
export type RecordDetailDrawerProps = { lang: Lang; /** The record to show; `null` closes the drawer. */ record: DetailRecord | null; onOpenChange: (open: boolean) => void; /** Position in the list, for the previous and next buttons. Leave it out to hide them. */ position?: { index: number; total: number; onMove: (step: -1 | 1) => void };};
/** * The details of one record in a Drawer: status, a description list, recent activity, previous and * next buttons and an edit action. The list and list-filters blocks share it. */export function RecordDetailDrawer({ lang, record, onOpenChange, position }: RecordDetailDrawerProps) { const t = copy[lang]; const toast = useToast(); const date = dateFormat(lang); // The last record stays drawn while the drawer slides out. const [shown, setShown] = useState(record); if (record !== null && record !== shown) setShown(record); const current = record ?? shown; return ( <Drawer open={record !== null} onOpenChange={onOpenChange} size="md" title={current?.name ?? ''} {...(current ? { description: `${t.categories[current.category]} · ${date.format(new Date(2026, 9, current.day))}` } : {})} closeLabel={t.close} footer={ <> <Drawer.Close> <Button variant="secondary">{t.close}</Button> </Drawer.Close> <Button startIcon={<Icon name="pencil" />} onClick={() => { if (current) toast.show({ title: t.editing(current.name) }); }} > {t.edit} </Button> </> } > {current ? ( <Stack gap="xl"> {position ? ( <Inline justify="between" align="center"> <StatusBadge status={current.status} /> <Inline gap="xs" align="center"> <Text size="sm" tone="subtle" numeric> {t.position(position.index + 1, position.total)} </Text> <IconButton icon="chevron-left" label={t.previous} variant="tertiary" size="sm" disabled={position.index === 0} onClick={() => position.onMove(-1)} /> <IconButton icon="chevron-right" label={t.next} variant="tertiary" size="sm" disabled={position.index === position.total - 1} onClick={() => position.onMove(1)} /> </Inline> </Inline> ) : ( <StatusBadge status={current.status} /> )} <DescriptionList layout="stacked" dividers items={[ { term: t.category, details: t.categories[current.category] }, { term: t.created, details: date.format(new Date(2026, 9, current.day)) }, { term: t.count, details: current.count }, { term: t.owner, details: t.ownerValue }, ]} /> <section aria-labelledby={`activity-${current.id}-${lang}`}> <Stack gap="sm"> <Heading level={3} size="sm" id={`activity-${current.id}-${lang}`}> {t.activity} </Heading> <ListItem.List divided> {t.events.map((event) => ( <ListItem key={event.title} size="sm" leading={<Icon name={event.icon as IconName} tone="subtle" />} title={event.title} meta={event.meta} /> ))} </ListItem.List> </Stack> </section> </Stack> ) : null} </Drawer> );}
export type DetailDrawerBlockProps = { lang?: Lang; /** Opens the drawer on this record (1-based), as after a click in the list. */ openRecord?: number;};
/** A record list whose rows open their details in a side drawer, with previous and next inside the drawer. */export function DetailDrawerBlock({ lang = 'tr', openRecord }: DetailDrawerBlockProps) { const t = copy[lang]; return ( <AppShellTemplate lang={lang} current="records" title={t.title} description={t.description} width="md"> <DetailDrawerContent lang={lang} openRecord={openRecord} /> </AppShellTemplate> );}
function DetailDrawerContent({ lang, openRecord }: { lang: Lang; openRecord: number | undefined }) { const t = copy[lang]; const [records] = useState(() => makeRecords(lang)); const [openIndex, setOpenIndex] = useState<number | null>(openRecord === undefined ? null : openRecord - 1); const record = openIndex === null ? null : (records[openIndex] ?? null);
return ( <> <ListItem.List divided aria-label={t.listLabel} className="rounded-md border border-line-subtle"> {records.map((item, index) => ( <ListItem key={item.id} render={<button type="button" />} selected={index === openIndex} title={item.name} subtitle={t.categories[item.category]} trailing={<StatusBadge status={item.status} size="sm" />} onClick={() => setOpenIndex(index)} /> ))} </ListItem.List> <RecordDetailDrawer lang={lang} record={record} onOpenChange={(open) => { if (!open) setOpenIndex(null); }} {...(openIndex === null ? {} : { position: { index: openIndex, total: records.length, onMove: (step: -1 | 1) => setOpenIndex(openIndex + step) } })} /> </> );}Structure
Section titled “Structure”- AppShell template (
width="md") and theh1“Records”. - A ListItem list of button rows; the open record is marked
selected. - Drawer (
side="end",size="md"): the title is the record name, the description its category and date. - Drawer body: a StatusBadge, the position and previous/next IconButtons, a
stackedDescriptionList, and the last three events under theh3“Activity”. - Footer: “Close” in
Drawer.Closeand a primary “Edit”; the edit is confirmed with a Toast.
Motion
Section titled “Motion”The drawer slides in from the inline end with the motion presets while the backdrop fades; below 640 px it becomes a bottom sheet. With reduced motion the slide stops and only a short fade stays. Moving between records keeps the drawer in place and changes only its content.
Accessibility
Section titled “Accessibility”- The drawer has the
dialogrole and is named by the record; its description reads the category and date. - On open, focus moves into the drawer and stays there; Escape, an outside press and Close shut it, and focus returns to the pressed row.
- The previous and next buttons are named “Previous record” and “Next record” and are disabled at the ends. When the title changes, the drawer’s name changes with it.
- Each row is one button; the status badge is part of its name, so colour never carries meaning alone.
- “Activity” is an
h3; the drawer title sits ath2level.
Components used
Section titled “Components used”Drawer, ListItem, DescriptionList, StatusBadge, IconButton, Button, Toast. Patterns: Notifications.