Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

    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.

    Live example, StorybookOpen in Storybook
    Live example, StorybookOpen in Storybook
    Live example, StorybookOpen in Storybook
    Terminal window
    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) } })}
    />
    </>
    );
    }
    1. AppShell template (width="md") and the h1 “Records”.
    2. A ListItem list of button rows; the open record is marked selected.
    3. Drawer (side="end", size="md"): the title is the record name, the description its category and date.
    4. Drawer body: a StatusBadge, the position and previous/next IconButtons, a stacked DescriptionList, and the last three events under the h3 “Activity”.
    5. Footer: “Close” in Drawer.Close and a primary “Edit”; the edit is confirmed with a Toast.

    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.

    • The drawer has the dialog role 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 at h2 level.

    Drawer, ListItem, DescriptionList, StatusBadge, IconButton, Button, Toast. Patterns: Notifications.