表单
Select - 选择器
从一个列表里选一项,从头到尾都由我们绘制。
用法
什么时候用它
import { Select } from '@misoto22/folio'说明
从一个列表里选一项,从上到下都是自己的样式。
选项列表是我们自己的——用的是和别处同一套 token,所以它不会一打开就换掉字体、间距和选中色。这正是它取代原生控件成为默认值的全部理由:一个最常用的表单控件一被点击就不再属于这套系统的设计系统,那不是设计系统,那是一份只管闭合状态的样式表。
键盘约定是 Radix 的,也就是平台的:首字母跳转能用,方向键能走,Home 和 End 到两端,Escape 不选中直接关闭。那本来是留在原生方案上唯一真正站得住的论据,现在它被回答了。
超过大约十几个选项就换 Combobox——一个没法筛的列表,比一个能打字进去的更难用。在平台选择器确实更好的地方——手机上,或者一个必须在没有 JavaScript 时也能活的表单——换 NativeSelect。
结构
| 部件 | 说明 |
|---|---|
| 触发器必填 | 一个站在 CONTROL_BASE 上的 <button role="combobox">,所以它和旁边那个 Input 严丝合缝地对齐。它的名字由 label 和它自己的值合起来构成,并带着面板打开时会翻过来的那个箭头。 |
| 值 | 选中项的文字;什么都没选时,是 --ink-3-aa 的 placeholder。它会截断,而且它正是无障碍名称里交代「选了什么」的那一半。 |
| 面板 | 通过 portal 渲染,宽度不小于触发器,高度不超过 18rem;列表比这更长时,两端各出现一个滚动箭头。 |
| 选项 | 一个选项。那个 3.5 的勾位一直画着,出现和消失的只是里面那个勾,所以选中不会把每一行标签往旁边推;data-highlighted 是那块底色,勾才是「被选中的那个」。 |
| 分组标题 | SelectLabel——SelectGroup 里一行等宽的小标题。它是标题,不是选项,这正是它和「拿一个禁用项当分隔线」之间的区别。 |
实践建议
推荐
- 值要活着熬过一次提交,就传 name。触发器是个 <button>;只有当控件在 <form> 里面时,Radix 才会渲染那个承载值的隐藏原生 <select>,而只有有名字的那个才送得出任何东西。
- invalid 只挑一种写法。触发器对 invalid 属性和 aria-invalid 一视同仁——包括 Field 从 error 设上的那一个——所以两个都设,就是一条边框有了两个事实来源。
- 长列表用 SelectGroup 和 SelectLabel 分段,不要拿一个禁用项当标题:禁用项仍然是选项,读屏软件会把它数进去,把这个列表播报成比它实际长一项。
避免
- 放在一个有 label 的 Field 里时,这里的 label 属性不会被播报——命名触发器的是 Field 的那几个字——所以一个和上面那句对不上的 label,是一段永远不会有人听见的死文字。
- 不要指望合起来的触发器把一个很长的选项显示出来:它会截断,好保住字段的高度,所以那个值的结尾只有在面板打开时才读得到。
- 不要把 contentClassName 当成触发器的。className 是触发器,contentClassName 是面板;把这两个搞混,就会得到一个 18rem 宽的下拉,盖在它正要为之选值的那个东西上面。
示例
default
两个分组,配等宽字体的组标题,中间用一根细线隔开。要用 SelectGroup 和 SelectLabel,别拿一个禁用项当标题:禁用项仍然是一个选项,所以读屏软件会把它数进去,报出一个比实际长一项的列表。这块面板从头到脚都是我们的,这正是重点——它不会在打开的那一刻就换掉字体、间距和选中色。选项超过十来个,这里就该变成 Combobox,因为一个没法过滤的列表,扫起来比一个能打字的更慢。
The list is ours, not the platform’s — open it.
invalid
这套底子上唯一一个不读 aria-invalid 的控件。Field 的 error 会自己把 Input、Textarea 和 NativeSelect 画上,因为这三个两种拼法都认——Select 的触发器只看 invalid,所以一行只给了 error、别的什么都没给,最后就是一条红色消息底下配一圈静止状态的边框。两个都传。注意这里用的是 placeholder,而不是 defaultValue:一个打开时就已经答好的 select,是一个其实没人答过的 select。
Choose a plan before inviting your team.
inside a form
name,以及它是干什么的。触发器是一个 button,而 Radix 只有在控件位于 form 里面时,才会渲染那个携带着值的隐藏原生 select——而且只有有名字的才发得出东西,所以一个没有 name 的 select 什么都不会提交,服务端看到的是一个没人填的字段。保存一下,下面那行会印出 FormData 实际收到的内容。
组成部分
在调用处组合,而不是通过 props 配置——所以这个组件没有预料到的布局,依然表达得出来。
SelectItem
一个选项。对勾标出选中的那个;填充标出高亮的那个。
同时接受 ComponentProps<typeof SelectPrimitive.Item> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。
SelectLabel
一组选项的等宽小标题。
必须放在 SelectGroup 里面——否则 Radix 会抛错,因为一个不属于任何分组的标题是在给虚无做标题,而辅助技术会把它当成一个选项念出来。
这个组件没有自己的属性。
SelectSeparator
分组之间的细线分隔。
这个组件没有自己的属性。
再导出
SelectRoot = SelectPrimitive.RootRadix Select 的根节点、分组和标签,带类型的原样透传。
SelectGroup = SelectPrimitive.Group键盘操作
| 按键 | 作用 |
|---|---|
| EnterSpace↓ | 展开列表。 |
| ↑↓ | 在选项之间移动。 |
| a–z | 首字母跳转——跳到下一个以该字母开头的选项。 |
| HomeEnd | 跳到第一个或最后一个选项。 |
| Escape | 不选中直接关闭。 |
无障碍
- 在有边界的框里——设备预览、内嵌控制台——用 `<OverlayContainer container={el}>` 包住这棵子树。面板会渲染进那个元素,按它的边界翻转,而不是按视口;框上设的 `dir` 和 `data-density` 也就跟着生效了。
- 选项列表是我们自己的,所以它不会在打开的一瞬间换掉字体、间距和选中色——那正是原生 select 会做的事。
- 键盘行为仍是平台的那一套:首字母跳转、方向键、Home 和 End、Escape 关闭且不选。
- label 必填,而且它是连着值一起被念出来的:触发器读作「Region, Australia」——值不是名字,而只有名字没有值也不是答案。在 Field 里面,名字那一半由 Field 的标签提供:触发器的 aria-labelledby 指向那个标签和那个值,而这里的 label 既不渲染也不播报,所以一个和上面那几个字对不上的 label,是没人会听到的文字。