# ChoiceList

> 始终展开的列表，在选择就是对话框的主要内容时，从中选一个或几个；带搜索框，每行末尾可以有一个自己的按钮。

Source: https://raft-design.botiverse.build/zh/components/choice-list. Translated from https://raft-design.botiverse.build/components/choice-list.md; where the two differ, the English page is right.

选择列表把每个选项都展开成一行，让人可以互相比较，用于本身就是为了做这个选择的对话框或页面：把 Agent 移到哪里，把谁加进频道。每一行有一个名称，下面最多两行说明；选一个时是单选圆圈，选几个时是复选框；当人按名字找某一项，或者列表长到一眼看不完时，列表上方还有一个搜索框。它从 `@botiverse/raft-ui/wip` 导出，API 在任何版本中都可能变化。

```tsx
import { useState } from "react";
import { Info } from "lucide-react";
import {
  Button,
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverTitle,
  PopoverTrigger,
} from "@botiverse/raft-ui";
import { ChoiceList, PopoverBody } from "@botiverse/raft-ui/wip";

function ComputerDetails({ name, details }: { name: string; details: string }) {
  return (
    <Popover>
      <PopoverTrigger
        render={<Button variant="ghost" size="icon-md" aria-label={`${name} details`} />}
      >
        <Info />
      </PopoverTrigger>
      <PopoverContent className="w-[300px]">
        <PopoverBody>
          <PopoverTitle>{name}</PopoverTitle>
          <PopoverDescription>{details}</PopoverDescription>
        </PopoverBody>
      </PopoverContent>
    </Popover>
  );
}

const COMPUTERS = [
  {
    value: "studio-mac",
    label: "Studio Mac",
    meta: ["Claude Max · 5h 72% left · this week 40% left", "120 GB free · 3 agents"],
    action: (
      <ComputerDetails
        name="Studio Mac"
        details="macOS 15 · 64 GB memory · daemon 1.0.52. Kirby's workspace moves here in one step."
      />
    ),
  },
  {
    value: "hetzner-ex63-2",
    label: "hetzner-ex63-2",
    meta: [
      <>
        Claude Max · 5h <span className="text-warning-strong">used up</span> · this week 12% left
      </>,
      "48 GB free · 9 agents",
    ],
    action: (
      <ComputerDetails
        name="hetzner-ex63-2"
        details="Linux x64 · 128 GB memory · daemon 1.0.52. The 5-hour window resets at 14:00."
      />
    ),
  },
  {
    value: "old-laptop",
    label: "old-laptop",
    meta: ["No Claude login", "Offline since Monday"],
    disabled: true,
  },
  {
    value: "raft-cloud",
    label: "Raft Cloud",
    description: "Runs in Raft's cloud",
  },
];

export function ChoiceListMigrationTargetComposition() {
  const [target, setTarget] = useState<string | null>("studio-mac");
  return (
    <div className="w-104 max-w-full">
      <ChoiceList
        aria-label="Move Kirby to"
        searchLabel="Search computers"
        clearSearchLabel="Clear search"
        noMatchLabel="No matches"
        items={COMPUTERS}
        value={target}
        onValueChange={setTarget}
      />
    </div>
  );
}
```

## 何时使用
各种选择器之间怎么选，完整说明见[选择组件](https://raft-design.botiverse.build/zh/components.md#picking-something)。

| 情况 | 使用 |
| --- | --- |
| 从任意数量的选项里选一个，选择是对话框的主要内容 | ChoiceList |
| 从任意数量的选项里选几个，选择是对话框的主要内容（Add members） | 带 `multiple` 和 `search="always"` 的 ChoiceList |
| 从 2 到 5 个答案里选一个，作为表单里与其他字段并列的一个字段 | [RadioGroup](https://raft-design.botiverse.build/zh/components/radio-group.md) |
| 从最多 10 个值里选一个，作为表单里的一个字段 | [Select](https://raft-design.botiverse.build/zh/components/select.md) |
| 从很多值里选一个或几个，作为表单里的一个字段；或者值在人输入时从服务器获取 | [ComboboxSelect](https://raft-design.botiverse.build/zh/components/combobox-select.md) |
| 从很多项里选一个，而选中一行有代价，比如要为它询问服务器 | 带 `inline open` 的 [Combobox](https://raft-design.botiverse.build/zh/components/combobox.md#inline-list)：方向键只移动高亮 |

和其他字段并列的字段把选项收在一个控件后面，这样表单保持简短。当选择本身就是内容时，要打开一个字段才能看到选项，只会把人想比较的东西藏起来，所以不论多长，列表都保持展开。

## 结构
ChoiceList 是一个由 `items` 数组驱动的组件，下面这些部件都由它根据这个数组画出。

- 搜索框：一个 [InputGroup](https://raft-design.botiverse.build/zh/components/input-group.md)，带搜索图标，有文字后还有一个 32px 的清除按钮。`search="always"` 时总是显示；`search="auto"` 时超过 5 项才显示。
- 框：包住各行的一个卡片色盒子，带细线边框。搜索框出现后，框的高度固定，各行在框里滚动。
- 分组标题，`groups` 里每组一个：一类行（Agents、People）上方的小标题，字号和颜色与 [Select](https://raft-design.botiverse.build/zh/components/select.md) 的分组标签相同。
- 行，每项一行：单选圆圈或复选框、这一项的 `media`（头像、图标）、它的 `label`，以及最多两行 `description`。整行是一个 `<label>`。
- 行的 `action`：行末的一个按钮，在行旁边，但在行的 `<label>` 之外。
- 提示行：搜索除了选中的行之外什么都没匹配到时，选中的行下方显示调用方的 `noMatchLabel`；没有任何项时，显示调用方的 `empty` 那一行。
- 页脚：调用方的 `footer`，在框里、各行下方，隔着一条接缝线。它不随各行滚动，所以始终可见。

## 变体
| 变体 | 用于 |
| --- | --- |
| 单选（默认） | 选一个：每行一个单选圆圈；`value` 是字符串，还没选时是 `null` |
| `multiple` | 选几个：每行一个复选框；`value` 是数组 |

```tsx
import { useCallback, useState } from "react";
import { Plus } from "lucide-react";
import { Avatar, Button } from "@botiverse/raft-ui";
import { ChoiceList } from "@botiverse/raft-ui/wip";
import type { ChoiceListItem } from "@botiverse/raft-ui/wip";

const PEOPLE = [
  { value: "ada", name: "Ada Park", handle: "ada", type: "human" },
  { value: "kirby", name: "Kirby", handle: "kirby", type: "agent" },
  { value: "mai", name: "Mai Tanaka", handle: "mai", type: "human" },
  { value: "noel", name: "Noel", handle: "noel", type: "agent" },
  { value: "ravi", name: "Ravi Shah", handle: "ravi", type: "human" },
] as const;

const GROUPS = [
  { value: "agent", label: "Agents" },
  { value: "human", label: "People" },
];

function personItem(person: {
  value: string;
  name: string;
  handle: string;
  type: "agent" | "human";
}): ChoiceListItem {
  return {
    value: person.value,
    label: person.name,
    searchText: `${person.name} @${person.handle}`,
    media: <Avatar size="xs" type={person.type} />,
    group: person.type,
  };
}

export function ChoiceListAddMembersComposition() {
  const [items, setItems] = useState<ChoiceListItem[]>(() => PEOPLE.map(personItem));
  const [members, setMembers] = useState<string[]>(["kirby"]);
  const [query, setQuery] = useState("");
  const name = query.trim();

  const createAgent = useCallback(() => {
    const value = `new-${items.length}`;
    setItems([
      ...items,
      personItem({ value, name: name || "New agent", handle: value, type: "agent" }),
    ]);
    setMembers([...members, value]);
    setQuery("");
  }, [items, members, name]);

  return (
    <div className="w-88 max-w-full">
      <ChoiceList
        multiple
        search="always"
        aria-label="People to add"
        searchLabel="Search people"
        searchPlaceholder="Search by name or @handle"
        clearSearchLabel="Clear search"
        noMatchLabel={`No one matches “${name}”`}
        searchValue={query}
        onSearchValueChange={setQuery}
        groups={GROUPS}
        items={items}
        value={members}
        onValueChange={setMembers}
        footer={
          <Button variant="ghost" className="w-full justify-start" onClick={createAgent}>
            <Plus aria-hidden />
            {name ? `Create an agent named “${name}”` : "Create an agent"}
          </Button>
        }
      />
    </div>
  );
}
```

## 状态
- **选中：** 墨色圆点或墨色对勾，行本身不填充。选中的值是墨色的，和其他地方一样（[状态](https://raft-design.botiverse.build/zh/foundations/states.md)）。
- **悬停和按下：** 鼠标指针下的行用 `brand-soft` 填充，和下拉选择列表的选项一样；按下时盖上 `overlay-medium`（[悬停与按下](https://raft-design.botiverse.build/zh/foundations/states.md#hover-and-press)）。
- **禁用：** 禁用的项，或者带 `disabled` 时的整个列表，标记、名称和说明行都用统一的禁用样式，没有悬停填充。
- **没有匹配：** 搜索除了选中的行之外什么都没匹配到：选中的行留着，下面是调用方给的一句短提示。

```tsx
import { useState } from "react";
import { ChoiceList } from "@botiverse/raft-ui/wip";

const COMPUTERS = [
  { value: "studio-mac", label: "Studio Mac", meta: "120 GB free · 3 agents" },
  { value: "hetzner-ex63-1", label: "hetzner-ex63-1", meta: "64 GB free · 6 agents" },
  { value: "hetzner-ex63-2", label: "hetzner-ex63-2", meta: "48 GB free · 9 agents" },
  { value: "hetzner-ax41", label: "hetzner-ax41", meta: "210 GB free · 2 agents" },
  { value: "mini-office", label: "mini-office", meta: "18 GB free · 1 agent" },
  { value: "pi-cluster", label: "pi-cluster", meta: "8 GB free · 1 agent" },
  { value: "build-box", label: "build-box", meta: "300 GB free · no agents" },
  { value: "raft-cloud", label: "Raft Cloud", description: "Runs in Raft's cloud" },
];

export function ChoiceListNoMatchComposition() {
  const [target, setTarget] = useState<string | null>("hetzner-ex63-2");
  return (
    <div className="w-104 max-w-full">
      <ChoiceList
        aria-label="Target computer"
        searchLabel="Search computers"
        searchPlaceholder="Search computers"
        defaultSearchValue="macbook"
        clearSearchLabel="Clear search"
        noMatchLabel="No matches"
        items={COMPUTERS}
        value={target}
        onValueChange={setTarget}
      />
    </div>
  );
}
```

- **空：** 一项都没有：框里只有调用方的 `empty` 那一行，没有行，也没有搜索框。

## 行为
- **它始终是列表。** 3 项也好，300 项也好，从不做成下拉。
- **整行都能选。** 在一行上任何地方点击或轻点，包括它的说明行，都会选中它；带 `multiple` 时则勾选或取消勾选它。
- **搜索框按人找东西的方式来定。** 按列表的用途选 `search`，不要按它碰巧有多长来选：
  - 人**记得**自己要什么时，用 `search="always"`，比如叫得出名字的人（Add members、新频道的成员）。不论有多少项，输入名字都是最快的，所以只有 2 项时也显示搜索框；用鼠标或触控板时，焦点一开始就在框里。在触屏上它不获取焦点，这样键盘不会弹出来盖住列表。
  - 人要靠比较各行写的内容**认出**对的那一行时，用 `search="auto"`（默认），比如按额度和磁盘选迁移目标。这时各行本身才是重点；只有列表长到一眼看不完，即超过 5 项时，才显示搜索框，而且它从不获取焦点。项数回落到 5 个或更少时，搜索框连同里面的文字一起消失。
  - 这个数量是固定的，不是 prop，所以同一类列表看起来都一样。
- **匹配。** 搜索匹配每一项的 `searchText`，或者在 `label` 是字符串时匹配 `label`，不区分大小写，匹配文字里的任意位置：给一个人的行设 `searchText`，同时包含名字和 `@handle`。自带搜索的产品，比如和提及共用的、带排序的人员搜索，传入 `searchValue`，并把它找到的值按它的顺序作为 `matchedValues` 传入：列表就显示这些值，选中的行仍然排在最前，这样在每个可以选人的地方，找人的方式都一样。传入的匹配结果要对应搜索框里当前的文字；较慢的搜索还在处理新文字时，继续传入上一次的匹配结果，而不是什么都不传，这样列表不会先清空再填满。
- **搜索时，选中的项留在视野里。** 搜索文字最后一次变化时已选中的行排在最前，然后是其他匹配项，按各项原来的顺序。搜索从不改变选择，清空搜索后，完整列表按原来的顺序回来。
- **选择时各行不动。** 排在最前的选中行是在文字变化时取定的，而不是在选择变化时，所以一行永远不会在鼠标指针或方向键底下挪走；顺序到下一次按键时才重排。
- **列表会滚动。** 有搜索框时，框最高 288px，在对话框里滚动；被截断的最后一行表示下面还有。
- **行高。** 只有一行文字的行是选项高度，在桌面端用鼠标时 34px（768px 以下 36px），在触屏上 44px；每一行说明只增加它自己的高度，不增加别的。
- **分组。** 有 `groups` 时，每一项写明自己属于哪一组，各行排在所属分组的标题下，按分组的顺序；不属于任何已知分组的项排在后面，没有标题。没有内容可显示的分组，不论在搜索前还是搜索中，都不画标题。搜索时，选中的行在它们自己那一组里排在最前，所以一个人永远不会被移出 People 标题之下。
- **列表下方的出路。** 要找的项可能不在列表里时，把创建它的入口放进 `footer`，比如 "Create an agent"：各行滚动时它留在下方。要用人输入的文字给它命名（"Create an agent named “Mia”"），传入 `searchValue` 和 `onSearchValueChange`；这样搜索文字由调用方掌管，可以在页脚或 `noMatchLabel` 里引用它。
- **填满底部面板。** 带 `fill` 时，列表占满给它的高度，比如手机底部面板剩下的部分，各行在里面滚动，而不是停在 288px。
- **行操作。** `action` 在所在行的末尾，与名称那一行居中对齐。它排在同一行的第二个网格列里，但在 DOM 里位于整组之后，所以点击它不会选中任何东西，Tab 在离开这一组后按行的顺序到达它。在会滚动的列表里（开着搜索，或 `fill`），它离末端 12px，让开滚动条那条 16px 的槽：槽在区域里的一切之上接收指针（[ScrollArea](https://raft-design.botiverse.build/zh/components/scroll-area.md#rules)）；行的文字也相应地让开它。详情在 [Popover](https://raft-design.botiverse.build/zh/components/popover.md) 里打开，轻点也能打开它。

```tsx
import { useState } from "react";
import { ChoiceList } from "@botiverse/raft-ui/wip";

const COMPUTERS = [
  { value: "studio-mac", label: "Studio Mac", meta: "120 GB free · 3 agents" },
  { value: "hetzner-ex63-1", label: "hetzner-ex63-1", meta: "64 GB free · 6 agents" },
  { value: "hetzner-ex63-2", label: "hetzner-ex63-2", meta: "48 GB free · 9 agents" },
  { value: "hetzner-ax41", label: "hetzner-ax41", meta: "210 GB free · 2 agents" },
  { value: "mini-office", label: "mini-office", meta: "18 GB free · 1 agent" },
  { value: "pi-cluster", label: "pi-cluster", meta: "8 GB free · 1 agent" },
  { value: "build-box", label: "build-box", meta: "300 GB free · no agents" },
  { value: "raft-cloud", label: "Raft Cloud", description: "Runs in Raft's cloud" },
];

export function ChoiceListSearchComposition() {
  const [target, setTarget] = useState<string | null>("hetzner-ex63-2");
  return (
    <div className="w-104 max-w-full">
      <ChoiceList
        aria-label="Target computer"
        searchLabel="Search computers"
        searchPlaceholder="Search computers"
        clearSearchLabel="Clear search"
        noMatchLabel="No matches"
        items={COMPUTERS}
        value={target}
        onValueChange={setTarget}
      />
    </div>
  );
}
```

## 内容
- 名称用 `text-subhead`，字重 600；每行说明用 `text-footnote`、`foreground-muted`。每一行各自截断；某一行被截断时，它的完整文字就是它的 `title`，所以用鼠标仍然能读到长描述（[输入框里的长值](https://raft-design.botiverse.build/zh/patterns/forms.md#long-values-in-fields)）。
- 只有当每一行说明都在说人会跨行比较的东西时，才给一行加第二行说明（"Claude Max · 5h 72% left · this week 40% left"、"3 agents · 120 GB disk free"）。没有内容可写的一行就省掉（`null`、`false` 或 `""`）：它不会被画出来，从不显示成空行。一行说明从不换行：在窄屏上它在末尾被截断，所以按人最先比较的东西排序，把最说明不了问题的放在最后（"3 agents · 12 GB RAM free · 120 GB disk free"：先是计算机的负载，然后是内存，最后是几乎总是够用的磁盘）。截掉的部分在悬停时显示（这一行的 `title`），也在这一行的详情里；在触屏上只有详情里有，所以顺序很重要。
- 要指出大多数人应该选的那个选项，在它的名称后面放一个默认颜色的描边 [Badge](https://raft-design.botiverse.build/zh/components/badge.md)（"Recommended"），最多标一行，并给这一行设 `searchText`，因为它的 `label` 不再是字符串。不要给这一行加填充或边框：有填充的行读起来像是已选中。
- 一行里的颜色由调用方决定，用于需要颜色的值：已用完的额度时段用 `warning-strong`。
- raft-ui 不自带任何文案。搜索框的名称和占位文字、清除按钮的名称、无匹配那一行和空状态那一行，都由调用方用产品自己的话提供（"No matches"、"No one matches"）。
- 用行的名字给行操作命名（"Studio Mac details"），不要只写 "Details"。

## 无障碍
- 单选：Base UI 的 RadioGroup，经由 raft-ui 的 [RadioGroup](https://raft-design.botiverse.build/zh/components/radio-group.md)。整组是一个 Tab 停靠点，落在选中的行上；方向键在已画出的行之间移动选择和焦点，从不移到被搜索隐藏的行。方向键到达的每一行都会被选中，所以选择会开始一件事（为那一行发出请求）的地方，用[内嵌的 Combobox](https://raft-design.botiverse.build/zh/components/combobox.md#inline-list)，它的方向键只移动高亮。
- `multiple`：Base UI 的 CheckboxGroup，配合 raft-ui 的 [Checkbox](https://raft-design.botiverse.build/zh/components/checkbox.md)，是一个由复选框组成的 `group`。每个复选框各是一个 Tab 停靠点，按空格键勾选：这是 WAI-ARIA 的复选框模式。多选的 listbox 会把每一行变成一个 option，而 option 里放不下它自己的按钮。
- 用 `aria-label` 给列表命名，或用 `aria-labelledby` 指向对话框里的问题。每个单选按钮或复选框只由它那一行的 `label` 命名，并由说明行描述，所以读屏软件先读名称，再读额度，而不是把整行当作名称来读。
- 行操作在组之外，也在各行的标签之外：它们从不被读进单选按钮的名称里，组的方向键也从不落到它们上面。
- 每个分组是一个由其标题命名的 `group`，位于列表自身的组里面，所以焦点进入时读屏软件会读出 "Agents"。方向键把所有分组当作一个列表来移动。
- 带 `search="always"` 且使用精确指针时，焦点一开始就在搜索框里，人可以直接输入；带 `search="auto"` 或在触屏上时，焦点留在对话框放置的位置。焦点只在搜索框第一次出现时移过去一次，而且从不离开人正在输入的字段：人填写频道名称时加载出来的项，不会把焦点从名称输入框里拿走。
- 搜索框是 `type="search"`（WebKit 自带的取消标记被隐藏；由清除按钮代替，清除后焦点回到框里）。无匹配那一行是一个 `status`，只要搜索框在，它就在，内容为空，没有匹配时才填入文字：读屏软件会播报文字发生变化的 status，而一个带着文字出现的 status 常常会被漏掉。匹配的选中行也算匹配，所以它下面从不出现 "No match"。
- 在触屏上，每行至少 44px 高，每个 32px 的操作保留 44px 的点击区域（[触屏](https://raft-design.botiverse.build/zh/patterns/responsive.md#touch-screens)）。

## 规则
- 行自己的按钮用 `action` 传入，不要把按钮放进 `label` 或 `description`：在行的 `<label>` 里，按下它也会选中这一行，读屏软件还会读出单选按钮里面有一个按钮。
- 不要把 ChoiceList 和其他字段一起放在表单里；那里用[何时使用](#when-to-use)表格里写的字段。
- 已选人员的可移除标签片（Add members）放在列表外面、列表上方；ChoiceList 只画列表。
- `groups` 用于人一眼就能分清的类别（Agents 和 People），不要用来把一长串列表分成许多小节：搜索找名字比看标题快。
- `footer` 里只放离开列表的那条出路，一行；第二个选择列表或表单应该放在框外。

## API

### ChoiceList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` | `string` |  |  |
| `aria-labelledby` | `string` |  |  |
| `clearSearchLabel` (required) | `string` |  | The clear button's accessible name. |
| `defaultSearchValue` | `string` |  | The search box's text when the list first draws, such as a search carried over. |
| `disabled` | `boolean` |  |  |
| `empty` | `ReactNode` |  | The line drawn when there are no items at all. |
| `fill` | `boolean` |  | The list takes the height it is given and its rows scroll inside it, for a sheet or panel whose main content is the list. Otherwise a list with a search scrolls past 18rem. |
| `footer` | `ReactNode` |  | A row of the caller's under the list, inside its frame, that stays in view while the rows scroll: the way out when the item isn't there, such as "Create an agent". |
| `groups` | `readonly ChoiceListGroup[]` |  | Headings that split the rows by kind, in this order, such as Agents then People. Each item names its group; a group with no rows to show, or a search that leaves it empty, draws no heading. |
| `items` (required) | `readonly ChoiceListItem[]` |  |  |
| `matchedValues` | `readonly string[]` |  | The values of the items that match the search text, in the order to show them, for a caller with its own search (a ranked people search shared with mentions). With it ChoiceList doesn't match `searchText` itself; it still keeps the chosen rows first and says when nothing matches. |
| `multiple` | `boolean` |  |  |
| `noMatchLabel` | `ReactNode` |  | The line under the chosen rows when nothing else matches. |
| `onSearchValueChange` | `(value: string) => void` |  |  |
| `onValueChange` (required) | `((value: string) => void) \| ((value: string[]) => void)` |  |  |
| `search` | `"auto" \| "always"` |  | When the search box shows. `"always"` when people look for an item they know by name, such as a person: the box shows at any count, and with a mouse or trackpad focus starts in it. `"auto"` when people choose by comparing the rows: the box shows only above 5 items. |
| `searchLabel` (required) | `string` |  | The search box's accessible name. Required: any list can come to search, since `"auto"` shows the box once the items, usually from data, pass 5. |
| `searchPlaceholder` | `string` |  |  |
| `searchValue` | `string` |  | The search box's text, for a caller that uses it: a "Create “…”" row named after it, or a no-match line that quotes it. With it, the caller owns the text, and `onSearchValueChange` reports each change. |
| `value` (required) | `string \| readonly string[]` |  |  |
