Skip to content

ListDetail

Open .md
Live preview

Loading example…

Master-detail: a list of items beside the detail of the selected one. Under 641px of its own width it collapses to a single pane: the list until an item is chosen, then the detail with a back action (focus follows the change). Panes render flush to the container’s inline edges.

import { ListDetail } from "@jcsoftdev/ui/containers";

Single-component entry points tree-shake to just this component: @jcsoftdev/ui/containers/list-detail.

export const SideBySide: Story = {
decorators: inWidth("48rem"),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const list = canvas.getByRole("list", { name: "Students" });
await expect(list).toBeVisible();
await expect(canvas.getByText("Pick a student.")).toBeVisible();
await expect(canvas.queryByRole("button", { name: "Back to list" })).toBeNull();
await userEvent.click(canvas.getByRole("button", { name: /Luis/ }));
await expect(canvas.getByRole("button", { name: /Luis/ })).toHaveAttribute(
"aria-current",
"true",
);
await expect(canvas.getByRole("region", { name: "Luis Ramírez" })).toBeVisible();
// Beside the list, the list stays visible and keeps focus.
await expect(list).toBeVisible();
await expect(canvas.getByRole("button", { name: /Luis/ })).toHaveFocus();
await expect(
getComputedStyle(list.closest("[data-view]") as Element).gridTemplateColumns.split(" "),
).toHaveLength(2);
},
};

Source of the Storybook story.

Prop Type Default Description
value string | null Controlled selected item id; null means none (the single-pane view then shows the list).
defaultValue string | null null
onValueChange ((value: string) => void) Called with the id the user chose. It is never called with null: going back to the list calls onBack instead, so a handler that loads the selected record never sees “no record”.
onBack (() => void) Called when the back action (or select(null) from useListDetail) clears the selection. Uncontrolled, the list shows again on its own; controlled, set value to null here.
aria-label * string Names the list of items.
listWidth string | number Fixed width of the list column beside the detail, from 641px of the component’s own width: a number is pixels, a string any CSS length ("20rem"). A rail of 280-360px reads better than an even split for most catalogues. Wins over ratio.
ratio "1:1" | "1:2" | "1:3" "1:1" Proportional split when listWidth is not set. Defaults to "1:1" (the original even split).
surface "card" | "flat" "flat" "flat" (default) keeps the panes flush with a divider between them. "card" draws each pane as a raised card with a gap between them.
stickyList boolean false Keeps the list column in view while the page scrolls and gives it its own scroll, from 641px of the component’s own width. The list header stays at the top of the column.
stickyOffset string | number Distance the sticky list keeps from the top of its scroll container (number = pixels). Default 0.
children * ReactNode

* marks a required prop.

It also accepts the native props of the element or primitive it renders (for example className, id, aria-* and event handlers).

packages/ui/src/containers/list-detail/list-detail.tsx