Raft 设计系统

组件 · 输入

ChoiceList

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

实验性也叫 Selection list, Open list, Picker list
import { ChoiceList } from "@botiverse/raft-ui/wip";
英文版有更新
这份译文写成之后,英文原文改过,部分内容可能已经过时。 阅读英文版

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

choice-list/compositions/migration-target

何时使用

各种选择器之间怎么选,完整说明见选择组件。

情况使用
从任意数量的选项里选一个,选择是对话框的主要内容ChoiceList
从任意数量的选项里选几个,选择是对话框的主要内容(Add members)带 multiple 和 search="always" 的 ChoiceList
从 2 到 5 个答案里选一个,作为表单里与其他字段并列的一个字段RadioGroup
从最多 10 个值里选一个,作为表单里的一个字段Select
从很多值里选一个或几个,作为表单里的一个字段;或者值在人输入时从服务器获取ComboboxSelect
从很多项里选一个,而选中一行有代价,比如要为它询问服务器带 inline open 的 Combobox:方向键只移动高亮

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

结构

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

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

变体

变体用于
单选(默认)选一个:每行一个单选圆圈;value 是字符串,还没选时是 null
multiple选几个:每行一个复选框;value 是数组
choice-list/compositions/add-members

状态

  • 选中: 墨色圆点或墨色对勾,行本身不填充。选中的值是墨色的,和其他地方一样(状态)。
  • 悬停和按下: 鼠标指针下的行用 brand-soft 填充,和下拉选择列表的选项一样;按下时盖上 overlay-medium(悬停与按下)。
  • 禁用: 禁用的项,或者带 disabled 时的整个列表,标记、名称和说明行都用统一的禁用样式,没有悬停填充。
  • 没有匹配: 搜索除了选中的行之外什么都没匹配到:选中的行留着,下面是调用方给的一句短提示。
choice-list/compositions/no-match
  • 空: 一项都没有:框里只有调用方的 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);行的文字也相应地让开它。详情在 Popover 里打开,轻点也能打开它。
choice-list/compositions/search

内容

  • 名称用 text-subhead,字重 600;每行说明用 text-footnote、foreground-muted。每一行各自截断;某一行被截断时,它的完整文字就是它的 title,所以用鼠标仍然能读到长描述(输入框里的长值)。
  • 只有当每一行说明都在说人会跨行比较的东西时,才给一行加第二行说明("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("Recommended"),最多标一行,并给这一行设 searchText,因为它的 label 不再是字符串。不要给这一行加填充或边框:有填充的行读起来像是已选中。
  • 一行里的颜色由调用方决定,用于需要颜色的值:已用完的额度时段用 warning-strong。
  • raft-ui 不自带任何文案。搜索框的名称和占位文字、清除按钮的名称、无匹配那一行和空状态那一行,都由调用方用产品自己的话提供("No matches"、"No one matches")。
  • 用行的名字给行操作命名("Studio Mac details"),不要只写 "Details"。

无障碍

  • 单选:Base UI 的 RadioGroup,经由 raft-ui 的 RadioGroup。整组是一个 Tab 停靠点,落在选中的行上;方向键在已画出的行之间移动选择和焦点,从不移到被搜索隐藏的行。方向键到达的每一行都会被选中,所以选择会开始一件事(为那一行发出请求)的地方,用内嵌的 Combobox,它的方向键只移动高亮。
  • multiple:Base UI 的 CheckboxGroup,配合 raft-ui 的 Checkbox,是一个由复选框组成的 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 的点击区域(触屏)。

规则

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

API

ChoiceList

实验性
Prop类型默认值
aria-labelstring
aria-labelledbystring
clearSearchLabel (required)The clear button's accessible name.string
defaultSearchValueThe search box's text when the list first draws, such as a search carried over.string
disabledboolean
emptyThe line drawn when there are no items at all.ReactNode
fillThe 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.boolean
footerA 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".ReactNode
groupsHeadings 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.readonly ChoiceListGroup[]
items (required)readonly ChoiceListItem[]
matchedValuesThe 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.readonly string[]
multipleboolean
noMatchLabelThe line under the chosen rows when nothing else matches.ReactNode
onSearchValueChange(value: string) => void
onValueChange (required)((value: string) => void) | ((value: string[]) => void)
searchWhen 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."auto" | "always"
searchLabel (required)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.string
searchPlaceholderstring
searchValueThe 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.string
value (required)string | readonly string[]