Overview
Reference usage
A list on one side, the open item on the other, with a draggable divider between them. Built from ResizablePanels, DataList and SelectableRow, it adds a list header, keyboard selection, the open item kept in the URL, a remembered divider width, and a layout that shows one pane at a time when the component is narrow.
Use it to browse a collection where choosing a record shows it beside the list: chats, runs, memories. Choose ResizablePanels alone when you need the layout without a list, and StackedListItem when every record has its own page and the list only navigates there.
Usage
items, key, label, list and detail are required. ListDetail owns the iteration, so it wraps each row in the <li> that DataList needs; your list snippet renders the row itself, usually a SelectableRow with selected={row.selected}, selectedState="current" and onclick={row.open}. Activating a SelectableRow primary action opens its item even without onclick, and row.open opens it from any other control.
Header
The header snippet sits above the rows. Put the title and the create action on their own row and the filter below at full width, so the action never reads as the filter's submit button. The count, search and filter are whatever you render there.
Keyboard
J moves the keyboard cursor to the next row and K to the previous one, moving focus with it. Enter opens the row the cursor is on. The cursor is separate from the open item: moving it never changes the detail. J and K are ignored while focus is in a text field, a menu, a listbox, a combobox or a dialog, and while Control, Meta or Alt is held.
Selection in the URL
The open item's key is kept in the selected search parameter (selectionParameter; pass null to keep it out of the URL). On mount, an empty selectedKey is filled from the URL, so a reload or a shared link lands on the same item. The URL is updated with history.replaceState, so selecting does not add history entries, and other search parameters are kept. Pass selectionLocation with read and write to route differently or to test. An explicit selectedKey wins over the URL and is written to it. A key that names no row shows the empty detail when the layout is split, and the list when it is stacked.
selectedKey is bindable, and onSelectedKeyChange reports each change.
Divider width
Pass storageKey and the divider width is remembered under that key in localStorage (or in storage, any object with getItem and setItem). The key is yours, so two lists on a page do not share a width. A storage that is missing or throws is ignored, and the list pane starts at defaultListWidth. Resizing the container does not rewrite the stored width, only moving the divider does.
Narrow layout
When the component is narrower than pushBelow (900 pixels by default), it shows one pane at a time: the list, then the detail with a Back button. The width is the component's own, not the viewport's, so a ListDetail in a narrow column stacks inside a wide window. Back clears the selection and returns focus to the row that was open. A width that has not been measured yet counts as wide.
Row actions
Row actions stay visible. A SelectableRow never hides its trailing actions until hover, so give each row a ⋯ menu trigger, as the example does, when there are more actions than fit.
Chats 3
Nothing selected
Choose an item from the list to see it here.
When to use
- Browsing a collection of records where choosing one shows it beside the list, such as chats, runs, or memories.
- The open record must survive a reload and be shareable as a link.
- Each record has its own page and the list is only a way to navigate there. Use Stacked List Item instead
- You need the resizable layout alone, with no list. Use Resizable Panels instead
Examples
Chats with a header, row menus, and keyboard selection
The list header carries the title and the create action on one row and the filter below. Row menus stay visible, J and K move the cursor, Enter opens, the open chat is kept in the URL, and the divider width is remembered.
Props
Name | Type | Default | Description |
|---|---|---|---|
items required | T[] | The records to list. Each is passed to list and, when selected, to detail. | |
key required | (item: T) => string | Key extractor. The key identifies an item in the keyboard cursor, the selection, and the URL. | |
label required | text | Accessible name of the list region, for example "Chats". | |
list required | snippet | Row renderer, called once per item inside an <li> that ListDetail provides. Render a
SelectableRow with selected={row.selected} and selectedState="current"; activating its
primary action opens the item. | |
detail required | snippet | Detail renderer for the open item. | |
empty | snippet | Rendered in the detail pane while no item is open. A default message is shown when omitted. | |
listEmpty | snippet | Rendered in the list when items is empty. | |
header | snippet | List header, above the rows: the title, the count, the create action, the search and the filter. Put the title and the create action on their own row and the filter below at full width, so the action never reads as the filter's submit button. | |
selectedKey bindable | text | null | Key of the open item, or null when none is open. Bindable. When it is null on mount, the
item named by the URL's search parameter opens. |
onSelectedKeyChange | (key: string | null) => void | Called with the new key when an item opens, and with null when the selection is cleared. | |
selectionParameter | text | 'selected' | Name of the search parameter that holds the open item's key. Pass null to keep the
selection out of the URL. |
selectionLocation | {
/** Returns the value of the search parameter, or `null` when it is absent. */
read: (parameter: string) => stringnull;
/** Sets the search parameter to `key`, or removes it when `key` is `null`. */
write: (parameter: string, key: string | null) => void;
} | Where the selection is read and written. Defaults to the address bar. | |
storageKey | text | Storage key under which the divider width is remembered. Omit to not remember it. The key is the caller's, so two ListDetails on one page do not share a width. | |
storage | Pick<Storage, 'getItem' | 'setItem'> | Where the divider width is remembered. Defaults to localStorage. | |
pushBelow | number | 900 | Width in pixels of the component below which it shows one pane at a time: the list, then the detail with a Back button. It is measured on the component, not the viewport. |
defaultListWidth | number | 320 | Width in pixels of the list pane before the divider is moved. |
backLabel | text | 'Back' | Label of the Back button shown above the detail below pushBelow. |
detailLabel | text | 'Details' | Accessible name of the detail region. |
emptyTitle | text | 'Nothing selected' | Title of the default empty detail, used when empty is omitted. |
emptyDescription | text | 'Choose an item from the list to see it here.' | Description of the default empty detail, used when empty is omitted. |