Selectable list
PortedRows to pick one from, for master–detail screens: conversations, candidate matches, references. Up and Down move focus, so do Home, End, Page Up and Page Down (10 rows), and they skip disabled rows. Typing moves focus to the next row whose label starts with the typed letters. Enter, Space or a click selects. Moving focus does not select, so the detail loads only when the user asks. Tab enters at the selected row, or at the first one. Rows hold no interactive content: a screen reader reads each row as text, so a link or button in it cannot be reached.
import { Badge } from "@etched/monet/badge";
import { SelectableList, type SelectableListItem } from "@etched/monet/selectable-list";
const conversations: SelectableListItem[] = [
{ value: "ana", label: "Ana Ruiz", description: "Thanks, Thursday at 2 works for me.", avatar: { name: "Ana Ruiz" }, end: <Badge>2</Badge> },
{ value: "jose", label: "José Núñez", description: "Could you share more about the bring-up team?", avatar: { name: "José Núñez" }, end: <Badge>1</Badge> },
{ value: "lee", label: "Lee Park", description: "You: Sent the loop schedule for next week.", avatar: { name: "Lee Park" } },
{ value: "mira", label: "Mira Osei", description: "Not looking right now. Maybe after the tape-out.", avatar: { name: "Mira Osei" } },
{ value: "kai", label: "Kai Ito", description: "Happy to chat. Is the role in Cupertino?", avatar: { name: "Kai Ito" } },
];
export function Preview() {
return (
<div className="card" style={{ inlineSize: "18rem" }}>
<SelectableList aria-label="Conversations" items={conversations.slice(0, 3)} defaultValue="jose" />
</div>
);
}Installation
import { SelectableList } from "@etched/monet/selectable-list";Examples
Conversations
Up and Down move focus; Enter, Space or a click opens the conversation. Moving focus does not open it.
Showing the conversation with Lee Park.
import { useState } from "react";
import { Badge } from "@etched/monet/badge";
import { Text } from "@etched/monet/text";
import { SelectableList, type SelectableListItem } from "@etched/monet/selectable-list";
const conversations: SelectableListItem[] = [
{ value: "ana", label: "Ana Ruiz", description: "Thanks, Thursday at 2 works for me.", avatar: { name: "Ana Ruiz" }, end: <Badge>2</Badge> },
{ value: "jose", label: "José Núñez", description: "Could you share more about the bring-up team?", avatar: { name: "José Núñez" }, end: <Badge>1</Badge> },
{ value: "lee", label: "Lee Park", description: "You: Sent the loop schedule for next week.", avatar: { name: "Lee Park" } },
{ value: "mira", label: "Mira Osei", description: "Not looking right now. Maybe after the tape-out.", avatar: { name: "Mira Osei" } },
{ value: "kai", label: "Kai Ito", description: "Happy to chat. Is the role in Cupertino?", avatar: { name: "Kai Ito" } },
];
export function Conversations() {
const [open, setOpen] = useState<string | null>("lee");
const person = conversations.find((c) => c.value === open)?.label;
return (
<div className="grid">
<div className="card">
<SelectableList aria-label="Conversations" items={conversations} value={open} onValueChange={setOpen} />
</div>
<Text variant="secondary">Showing the conversation with {person}.</Text>
</div>
);
}Candidate matches
Nothing is selected yet, so Tab enters at the first row. Visible text names the list through aria-labelledby.
Is this one of your candidates?
import { Pill } from "@etched/monet/pill";
import { Text } from "@etched/monet/text";
import { SelectableList, type SelectableListItem } from "@etched/monet/selectable-list";
const matches: SelectableListItem[] = [
{ value: "c-1042", label: "Ana Ruiz", description: "Staff engineer at Tenstorrent", avatar: { name: "Ana Ruiz" }, end: <Pill tone="green">94% match</Pill> },
{ value: "c-2210", label: "Ana Ruíz Ortega", description: "Design verification at Cerebras", avatar: { name: "Ana Ruíz Ortega" }, end: <Pill tone="amber">71% match</Pill> },
{ value: "c-0388", label: "A. Ruiz", description: "Referral from Sam Lee, 2024", avatar: { name: "A. Ruiz" }, end: <Pill>38% match</Pill> },
];
export function CandidateMatches() {
return (
<div className="grid">
<div className="card">
<Text id="matches-label" bold DANGEROUS_style={{ padding: "var(--et-space-2) var(--et-space-4) 0" }}>
Is this one of your candidates?
</Text>
<SelectableList aria-labelledby="matches-label" items={matches} />
</div>
</div>
);
}Disabled rows
A disabled row cannot be selected, and the arrow keys skip it. Say why in its description.
import { Pill } from "@etched/monet/pill";
import { SelectableList, type SelectableListItem } from "@etched/monet/selectable-list";
const references: SelectableListItem[] = [
{ value: "sam", label: "Sam Lee", description: "Manager at Groq, 2021–2024", end: <Pill tone="green">Received</Pill> },
{ value: "priya", label: "Priya Shah", description: "No email or phone yet", end: <Pill>To do</Pill>, disabled: true },
{ value: "kai", label: "Kai Ito", description: "Peer at Cerebras, 2019–2021", end: <Pill tone="purple">Scheduled</Pill> },
{ value: "tom", label: "Tom Weber", description: "Report at SambaNova, 2022–2024", end: <Pill tone="blue">Activating</Pill> },
];
export function DisabledRows() {
return (
<div className="grid">
<div className="card">
<SelectableList aria-label="References" items={references} defaultValue="sam" />
</div>
</div>
);
}Groups and type-to-find
Groups put rows under headings. Type a name's first letters to move to it; the same letter again moves on to the next match. Page Up and Page Down move 10 rows.
import { SelectableList, type SelectableListGroup, type SelectableListItem } from "@etched/monet/selectable-list";
const sender = (name: string, description: string): SelectableListItem => ({ value: name, label: name, description, avatar: { name } });
const senders: SelectableListGroup[] = [
{ label: "Today", items: [sender("Ana Ruiz", "ASIC design engineer"), sender("Kai Ito", "Firmware engineer"), sender("Mira Osei", "Physical design lead")] },
{
label: "This week",
items: [sender("Maya Chen", "Thermal engineer"), sender("Sam Lee", "Recruiter"), sender("José Núñez", "Verification engineer"), sender("Priya Shah", "Packaging engineer")],
},
{
label: "Earlier",
items: [
sender("Lee Park", "Test engineer"),
sender("Marco Rossi", "Board designer"),
sender("Tom Weber", "Compiler engineer"),
sender("Aiko Tanaka", "Power engineer"),
sender("Noah Kim", "Systems engineer"),
sender("Rosa Díaz", "Design verification"),
sender("Ben Okafor", "Product manager"),
],
},
];
export function GroupsAndTypeToFind() {
return (
<div className="grid">
<div className="card" style={{ maxBlockSize: "22rem", overflowY: "auto" }}>
<SelectableList aria-label="Senders" items={senders} />
</div>
</div>
);
}Empty and loading
loading shows placeholder rows and marks the list busy. With no rows, empty says why, and the list takes focus itself so keyboard and screen reader users hear it.
import { SelectableList } from "@etched/monet/selectable-list";
export function EmptyAndLoading() {
return (
<div className="grid">
<div className="card">
<SelectableList aria-label="Candidate matches" items={[]} loading />
</div>
<div className="card">
<SelectableList aria-label="Archived conversations" items={[]} empty="No archived conversations." />
</div>
</div>
);
}Accessibility
ArrowUp/ArrowDown,Home/EndandPageUp/PageDown(10 rows) move focus and skip disabled rows. Typing moves to a row whose label starts with the letters.Enter,Spaceor a click selects; moving focus does not.Tabenters at the selected row, or at the first row.- A
listboxofoptionrows, named by the requiredaria-labeloraria-labelledby. A row'slabelnames it; itsdescriptionandenddescribe it. - The selected row has
aria-selected="true", and groups are named by their heading. Whileloadingthe list isaria-busy; an empty list is described byempty. - Put no links or buttons in a row: screen readers read each row as text. Load the detail in
onValueChange.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| items * | (SelectableListItem | SelectableListGroup)[] | – | The rows in order, or groups of rows under headings. |
| value | string | null | – | The selected row, controlled. null selects none. |
| defaultValue | string | null | null | The first selected row, uncontrolled. |
| onValueChange | (value: string) => void | – | Called when Enter, Space or a click selects a row. Moving focus does not select. |
| empty | ReactNode | "No items." | Shown when there are no rows, such as "No conversations yet". The list then takes focus itself and screen readers hear this text. |
| loading | boolean | – | Placeholder rows in place of the rows, while they load. The list is aria-busy. |
| aria-label | string | – | Names the list, for example "Conversations". |
| aria-labelledby | string | – | The id of visible text that names the list. |