表单
Combobox - 可搜索选择器
一个能打字的 select,可以选一个,也可以选多个。
用法
什么时候用它
import { Combobox } from '@misoto22/folio'说明
一个能打字的 select,可以选一个,也可以选多个。
和 Select 的分界线是长度,而且这不是口味问题:大约十几个选项以内,带样式的 select 更好,因为一个不用筛的列表扫起来比一个需要思考的更快。超过之后,这个才是正确答案。
筛选、高亮行和方向键都来自 cmdk,它正确实现了 ARIA combobox 模式:高亮通过 aria-activedescendant 移动,焦点留在输入框里。手搓的 combobox 会把焦点挪进列表,于是打出来的字就没法再改了。
和这里其他每一个表单控件一样,受控非受控都行。
结构
| 部件 | 说明 |
|---|---|
| 触发器必填 | 一个 <button role="combobox">,名字由 label 和它自己那行摘要合起来构成,并带着 aria-expanded。它的文字是截断而不是换行,所以不管选了什么,这个字段的高度都不变。 |
| 摘要 | 触发器上的那行文字:placeholder,或者最多两个用逗号连起来的选中标签,再多就是「已选 n 项」。超过两个就改成计数,正是这一点让多选控件不会每选一次就把表单重排一遍。 |
| 清除 | 箭头旁边的一个 <span role="button">,只在 multiple 且已经选了东西时出现。用 span 而不是嵌套的 <button>,是因为后者在触发器里面是非法的,浏览器会把它挪出这个字段。 |
| 筛选框 | 面板里 cmdk 的那个输入框。它的名字是通过 Command 外层容器给的,形如「label: searchPlaceholder」,因为 aria-labelledby 压过 aria-label,直接给这个输入框命名什么用都没有。 |
| 选项行 | 单选是一个勾,多选是一个可填充的方框,后面跟着标签。筛选什么都没命中时,emptyMessage 顶替整个列表。 |
实践建议
推荐
- 受控时,「什么都没选」要写成空字符串。value={undefined} 恰恰是这个组件判定自己非受控的依据,所以那样清空等于把状态还给了它自己,它从此不再跟随父组件。
- 看得懂的文字放 label,其他值得被匹配上的放 keywords:cmdk 也拿选项的 value 去打分,所以一份用 UUID 做键的列表,是在按一串永远不会有读者输入的字符排序。
- emptyMessage 里要说清楚什么才匹配得上。默认那句只告诉读者筛选跑过了,至于这四百个选项里他本该输入哪一个,一个字都没说。
避免
- 超过两项之后,触发器就不再点名了——它播报的是「标签,已选 3 项」,至于是哪三项,只有面板里才有。不打开面板也必须核对得了选了什么时,就把它们打印在字段旁边。
- 不要给它几千个选项。这里没有任何东西做虚拟化:打开时数组里每一个选项都渲染进面板,并且在筛选背后一直待着,所以列表长度是 DOM 的开销,不是搜索的开销。
- 禁用的选项不等于隐藏的选项——它照样渲染、照样被筛选命中,所以读者可以把它的名字一字不差地打出来,看着它浮上来,然后选不中,还得不到任何解释。
示例
default
七个天体,藏在一个「匹配得比印出来的多」的过滤器后面:输入 compact,会浮上来两个,尽管两个都没写这个词。可读的文字放 label,其他值得参与匹配的放 keywords——cmdk 同时也拿选项的 value 去打分,所以一个用 UUID 做键的列表,是在拿一个读者永远不会去敲的字符串排名次。高亮是靠 aria-activedescendant 移动的,焦点一直留在输入框里,这正是输入的文字始终可编辑的原因。这里什么都没有虚拟化:每个选项在打开时就全部渲染出来,并一直留在过滤器后面,所以一千个选项是 DOM 的开销,不是搜索的开销。
Type to filter — “compact” matches two of them.
multiple
一次选好几个,触发器给的是概括而不是罗列:两个标签,然后一个计数。正是这个阈值挡住了字段随着它的值一起长大、每选一次就把表单重排一遍。选的过程中面板不关,而人字形旁边那个清除控件是一个带 button role 的 span——在触发器里真的嵌一个 button 是非法标记,浏览器会把它整个挪出这个字段。触发器由它的标签和它的概括共同命名——aria-labelledby 同时指向两者——所以读屏软件听到的是「Tags 已选 3 项」,而不是只有标签,那个计数不打开面板也能确认。
The panel stays open while you pick; past two it counts instead of listing.
controlled and cleared
一个受控的 Combobox,以及把它清空的唯一正确做法。「什么都没选」要拼成空字符串:value={undefined} 恰恰是这个组件用来判定自己是「非受控」的那个信号,所以那样清空等于把它自己的状态还给了它,从此它就不再跟着父组件走了——这个 bug 表现出来,是一个从第一次之后就无视每一次重置的选择器。下面那行读数就是这份状态,也正是把它拎到外面来的意义。
value is an empty string
键盘操作
| 按键 | 作用 |
|---|---|
| EnterSpace↓ | 展开列表。 |
| ↑↓ | 移动高亮,焦点始终留在筛选框里。 |
| Enter | 选中高亮那一项;再选一次当前项就是取消。 |
| Escape | 不选中直接关闭。 |
无障碍
- 高亮通过 aria-activedescendant 移动,焦点留在输入框里——这是 ARIA combobox 模式。自己手搓的会把焦点挪进列表,然后打的字就没法改了。
- label 必填,而且它是连着摘要一起被念出来的、不是取而代之:触发器读作「Tags, 3 selected」。在 Field 里面,名字那一半由 Field 的标签提供,这里的 label 在触发器上既不渲染也不播报——但它仍然命名那个清除控件,念作「Clear Tags」,所以哪怕触发器不再说它,它也得是真话。