MasterDetail
Show a list and the details of the selected item. From breakpoint (720px of the component width) both panes sit side by side, with resizable the border can be dragged; below it the detail replaces the list and onBack renders a back button. When nothing is selected the wide layout shows emptyDetail. The component fills its parent height and each pane scrolls on its own.
Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-reactExamples
Wide
Narrow
Resizable
Source
The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.
primitives/MasterDetail/MasterDetail.tsx
import React, { memo } from 'react';
import { Box, Button, Divider, EmptyState, Group, rem, ScrollArea, Splitter } from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconArrowLeft, IconLayoutSidebarRight } from '@tabler/icons-react';
import { cx } from '../../utils/cx';
import classes from './MasterDetail.module.css';
const DEFAULT_BREAKPOINT = 720;
export interface MasterDetailLabels {
/** Back button shown above the detail when narrow, `Back` by default */
back: string;
/** Title of the default empty detail, `Select an item` by default */
emptyTitle: string;
/** Description of the default empty detail, `Its details will appear here` by default */
emptyDescription: string;
}
export const DEFAULT_MASTER_DETAIL_LABELS: MasterDetailLabels = {
back: 'Back',
emptyTitle: 'Select an item',
emptyDescription: 'Its details will appear here',
};
export interface MasterDetailProps {
/** List pane content */
list: React.ReactNode;
/** Detail of the selected item, `null` when nothing is selected */
detail: React.ReactNode | null;
/** When narrow, shows the detail instead of the list, `true` whenever `detail` is set by default */
detailOpened?: boolean;
/** Called by the back button shown above the detail when narrow, the button is rendered only when set */
onBack?: () => void;
/** List pane width in px when wide, `320` by default */
listWidth?: number;
/** Component width in px from which list and detail sit side by side, `720` by default */
breakpoint?: number;
/** Lets the user drag the border between the panes when wide */
resizable?: boolean;
/** Shown in the detail pane when wide and `detail` is `null`, an empty state with `labels.emptyTitle` and `labels.emptyDescription` by default */
emptyDetail?: React.ReactNode;
/** Overrides of the default English labels */
labels?: Partial<MasterDetailLabels>;
/** Class name added to the root element */
className?: string;
/** Inline styles added to the root element */
style?: React.CSSProperties;
}
/** List and detail: two panes when wide, one pane with a back action when narrow; fills the parent height */
export const MasterDetail = memo(function MasterDetail({
list,
detail,
detailOpened,
onBack,
listWidth = 320,
breakpoint = DEFAULT_BREAKPOINT,
resizable = false,
emptyDetail,
labels: labelsProp,
className,
style,
}: MasterDetailProps) {
const labels = { ...DEFAULT_MASTER_DETAIL_LABELS, ...labelsProp };
const { ref, width } = useElementSize();
const measured = width > 0;
const isWide = width >= breakpoint;
const hasDetail = detail !== null && detail !== undefined;
const showDetail = hasDetail && (detailOpened ?? true);
const listPane = (
<ScrollArea className={classes.pane} type="auto" data-pane="list">
{list}
</ScrollArea>
);
const detailPane = (
<ScrollArea className={classes.pane} type="auto" data-pane="detail">
{hasDetail
? detail
: (emptyDetail ?? (
<EmptyState
className={classes.empty}
icon={<IconLayoutSidebarRight />}
title={labels.emptyTitle}
description={labels.emptyDescription}
/>
))}
</ScrollArea>
);
const listColumn = (
<Box className={classes.list} hidden={!isWide && showDetail} data-column="list">
{listPane}
</Box>
);
const detailColumn = (
<Box className={classes.detail} hidden={!isWide && !showDetail} data-column="detail">
{onBack && (
<Box className={classes.back} hidden={isWide || !showDetail}>
<Group px="xs" py={4}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconArrowLeft size={16} />}
onClick={onBack}
>
{labels.back}
</Button>
</Group>
</Box>
)}
{detailPane}
</Box>
);
const narrowPaneStyle = isWide ? undefined : { flexGrow: 1, flexBasis: 0 };
let content: React.ReactNode = null;
if (!resizable) {
content = (
<Box className={classes.panes}>
{listColumn}
<Divider orientation="vertical" className={classes.divider} hidden={!isWide} />
{detailColumn}
</Box>
);
} else if (measured) {
content = (
<Splitter className={classes.fill} classNames={{ handle: classes.handle }}>
<Splitter.Pane
defaultSize={`${listWidth}px`}
min={`${Math.round(listWidth * 0.6)}px`}
max={`${listWidth * 2}px`}
className={classes.splitterPane}
hidden={!isWide && showDetail}
style={narrowPaneStyle}
>
{listColumn}
</Splitter.Pane>
<Splitter.Pane
defaultSize={100}
className={classes.splitterPane}
hidden={!isWide && !showDetail}
style={narrowPaneStyle}
>
{detailColumn}
</Splitter.Pane>
</Splitter>
);
}
return (
<Box
ref={ref}
className={cx(classes.root, className)}
style={{ '--ae-master-detail-list-width': rem(listWidth), ...style } as React.CSSProperties}
data-layout={measured ? (isWide ? 'wide' : 'narrow') : undefined}
data-measuring={(!measured && breakpoint !== DEFAULT_BREAKPOINT) || undefined}
>
{content}
</Box>
);
});
MasterDetail.displayName = 'MasterDetail';
API reference
Prop
Type
Required
list
React.ReactNode
Yes
List pane content
detail
React.ReactNode | null
Yes
Detail of the selected item, `null` when nothing is selected
detailOpened
boolean
No
When narrow, shows the detail instead of the list, `true` whenever `detail` is set by default
onBack
() => void
No
Called by the back button shown above the detail when narrow, the button is rendered only when set
listWidth
number
No
List pane width in px when wide, `320` by default
breakpoint
number
No
Component width in px from which list and detail sit side by side, `720` by default
resizable
boolean
No
Lets the user drag the border between the panes when wide
emptyDetail
React.ReactNode
No
Shown in the detail pane when wide and `detail` is `null`, an empty state with `labels.emptyTitle` and `labels.emptyDescription` by default
labels
Partial<MasterDetailLabels>
No
Overrides of the default English labels
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element