表单
Field - 表单项
一行带标签的表单:标签、控件,和下面那一条消息——在 row 布局下,它就是那一行设置项。
用法
什么时候用它
import { Field } from '@misoto22/folio'说明
一行带标签的表单:标签、控件,以及底下那唯一一条消息。
看得见的消息只是可访问校验的一半——它还必须传到控件上。这里会把 aria-describedby、aria-required 和 aria-invalid 接到那个唯一的子控件上,让“必填”和“错误”是被播报出来的,而不只是画出来的(WCAG 1.3.1 / 3.3.1 / 4.1.2)。
早先的版本从 htmlFor 推导消息的 id,于是没传 htmlFor 的调用方得到的是一条渲染了但永远不会被播报的提示——这个故障在浏览器里完全看不出来,对读屏用户却是彻底的。现在没提供 id 时会自动生成。
hint 和 error 是同一个插槽,不是两条叠着的消息:当一个字段填错了,该读的是它错在哪。description 又是另一个插槽——它解释的是那个设置,不是那个输入框,而 layout="row" 正是把它放到标签底下,做成一行设置项。
这套接线是怎么到达控件的。 cloneElement 把那四个属性放到那个唯一的子元素上,然后每个控件再把它们转发到真正承载 role 的那个元素——对 Input、Textarea、NativeSelect、Checkbox、Switch 以及任何手写的宿主元素来说,那就是这个子元素本身;对 Select、Combobox、DatePicker、Slider、RadioGroup 和 ToggleGroup 来说,那是更深处的一个触发器、一个分组或一个滑块。这些复合控件过去把它们直接丢在地上,于是控件底下画着一条永远不会被播报的提示;一个看上去在接线、其实没接的包装层,比一个从没这么声称过的更糟。
标签自己的 id 是另走一路的,走 context,因为“名字”恰恰是一个 prop 带不动的东西:一个文字本身就是它的值的触发器,既由标签命名,也由它自己命名,所以 <Field label="Region"><Select/></Field> 播报出来是“Region, Australia”,而不是其中的哪一半。
有三样够不着,而每一样都是控件自己的标记问题,不是这套接线漏掉了什么。<label for> 只绑得到可加标签的元素,所以那些文字点不进 RadioGroup、ToggleGroup 和 Slider:前两个是 role="radiogroup",改由反向指回那个标签来命名,正如 <legend> 一样;第三个把 role="slider" 放在一个无 role 根节点底下的滑块上。required 是以 aria-required 到达控件的,而 DatePicker 那个普通 <button> 触发器、以及多值 ToggleGroup 的 role="toolbar",都没地方放它;在那里,星号是唯一的标记。还有 aria-invalid 到达的是 Slider 的根节点、而不是它的滑块,所以一个出错的滑块画出来是错的,播报出来却不是。
每个复合控件仍然收自己的 label prop——它是这个控件站在 Field 外面时的名字,而 Select、Combobox 和 DatePicker 在它为空时会发出警告。它不再是顶替这里这个 label 用的了。
任何接线都够不着的地方,这个字段会在开发环境里说出来,而不是一声不响地失败:子元素是一个标签绑不上去的宿主元素时,报 FIELD_CONTROL_NOT_LABELLABLE——就是那个接走了 id、把里面真正的控件晾在一边的 <div> 包装层;而根本没有一个可以接线的元素时,报 FIELD_CONTROL_NOT_WIRED。
结构
| 部件 | 说明 |
|---|---|
| 标签 | label,渲染成一个带着 htmlFor 和 id 的 Radix <Label>。触发器给自己命名靠的就是这个 id、连同它自己的值,一个组也是回过头来指向这个 id——这两件事 htmlFor 都做不到。 |
| 必填标记 | required 时跟在标签后面的那个 --danger 星号。它是 aria-hidden 的,但仍然待在标签的文字里面,所以无障碍名称的结尾是「Email *」。 |
| 控件插槽必填 | children——一个元素,就一个;Field 克隆它,给它加上 id、aria-describedby、aria-required 和 aria-invalid,而每个控件又会把这些转交给真正承载它 role 的那个元素:Select、Combobox 和 DatePicker 是触发器,组是根节点,Slider 是滑块。这就是全部的约定,其余都是排版。 |
| 设置说明 | description,落在标签底下的第二行,讲这个设置是做什么的——区别于 hint,hint 在控件底下,属于那个输入框。它有自己的 id,并排在消息前面一起进 aria-describedby,所以两样都有的一行,两样都会被念出来。 |
| 消息 | 控件下方唯一的一个 <p>:有 error 就是 error,否则是 hint,从不同时出现。aria-describedby 指的就是它的 id,颜色也随之在 --danger 和 --ink-3-aa 之间取。 |
| 行布局 | layout="row":标签和 description 排成一列放在行首,控件放在行尾,消息在两者下方。两列在纵轴上顶端对齐——是 items-start,不是 items-center——所以两行的 description 不会把开关拽到这段文字的半腰上,一列设置项里每个控件也都还和命名它的那几个字待在同一行上。 |
实践建议
推荐
- children 要就是控件本身,不要在外面套一层排版用的容器:接线是对这唯一一个子元素做 cloneElement,中间夹一个 <div>,id 和 aria-describedby 就落到那个 <div> 身上,标签最后命名的是一个盒子。
- 非法状态交给 error 一个人扛。Field 会在控件上设 aria-invalid,而 Input、Textarea、NativeSelect、Select 和 Combobox 都通过 isInvalid 认这两种写法,所以再传一个 invalid,就是让同一件事由两个可能互相矛盾的地方各说一遍。
- 只要行外面有东西要指名这个控件——表单库、滚动到第一个错误、一条测试——就把 htmlFor 和控件上同一个 id 一起写出来。自动生成的那个是 useId 的值,别的东西谁都猜不到。
- 设置页要用 layout="row" 配上 description 搭出来,不要用三个手写的 div。标签仍然沿着同一套接线抵达控件,所以行那头的开关,名字来自行这头的字。
避免
- 这里的 required 只是 aria-required 加一个星号,再没别的:它永远到不了控件自己的 required 属性,所以浏览器不会拦住提交,而这一行在你自己传 error 之前不会有任何标记。
- RadioGroup 或 ToggleGroup 上方那几个字命名得了它,却点不进去。这两个的根节点都是 <div role="radiogroup">,htmlFor 绑不上去,所以改成由这一组反过来指向那个标签——读者像点「Email」那样点它,什么都不会发生,和 <legend> 一模一样。
- 不要把 description 当第二个 hint 用。它解释的是这个设置,位置在标签底下;hint 解释的是这个输入框,位置在控件底下。拿一个顶另一个的那一行,句子读着没错,落点是错的。
- 除了 DatePicker,这里每一个控件都会把 required 播报出来;DatePicker 的触发器是个普通的 <button>,这个 role 没有地方安放 aria-required。在那里,那个星号就是全部的标记,而读屏用户遇到的是一个普普通通的选填字段。
示例
states
一行会长成的四种样子:必填、答错了、一个必须自己报名字的控件,以及一个更长的回答。hint 和 error 是同一个槽位、不是两条堆起来的消息——一个字段错了的时候,该读的是它错在哪,所以提示会让开,而不是排在错误下面。Region 那一行故意把 Region 说了两遍:Radix 的 select 根节点不渲染任何 DOM 节点,所以 label 绑不到东西上,控件自己的 label 属性是它唯一的名字。
We never share it.
Name is required.
Markdown is fine.
one message slot
hint 和 error 是同一行,而错误赢。把 @ 删掉,看着那个格式说明在读者最需要它的那一刻消失——这也正是为什么下面那条错误要把「一个好答案长什么样」重说一遍,而不是只说一句不行。传了 error,这一行需要的 invalid 状态也就齐了:字段会给 input 加上 aria-invalid,而 Input 两种拼法都认,所以再加一个 invalid,等于把同一个事实从两个可能互相打架的地方各说一遍。
The receipt goes here — use the address on the invoice.
naming a group
把 Field 套在 RadioGroup 外面,只会把那些字画出来、却什么都没绑上:这个组的根是一个 div,而 htmlFor 只够得着可加标签的元素,于是点那些字什么也不会发生,这个组被播报出来时压根没有名字。同样那些字要以 aria-label 的形式落到组上,那才是读屏软件真正会读的东西。ToggleGroup 需要同样的处理,而 Select、Combobox、DatePicker 和 Slider 各自出于同一个理由,都收一个必填的 label。
Weekend delivery is metro only.
a stable id
htmlFor,以及控件上那个同样的 id。不写它,字段会生成一个 useId 的值——一个这一行之外没人猜得到的字符串——于是一个表单库、一个测试,或者下面那个按钮,都没有办法够到那个出错的输入框。只要这一行之外有东西必须指名道姓地找这个控件,就传它;在这一行内部,生成的 id 已经把活干了。
The receipt goes here.
Enter a four-digit postcode.
类型
export type FieldLayout = 'stacked' | 'row'无障碍
- 没传 id 时会自己生成一个,所以标签永远指向某个东西。
- 把 aria-describedby、aria-required、aria-invalid 接到控件上,所以校验是被念出来的,不只是被画出来的。
- hint 和 error 是同一个位置,不是两条叠着:字段错了的时候,该读的是它哪里错了。
- description 排在消息前面一起进 aria-describedby,所以一行设置项先说这个设置是干什么的,再说它哪里错了。
- 每个控件都会把这套接线转交给承载它 role 的那个元素,所以 Select 或 Slider 底下那条 hint 是被念出来的,不只是被画出来的。
- row 布局把标签挪到行的另一头,关联关系一点没变,所以一行设置项里放 Switch 或 Select,名字来自行这头的那几个字。Slider 是例外,而且这个布局救不了它:role="slider" 长在滑块上,而 Field 的标签指向的是那个没有 role 的根节点,所以在那里,Slider 自己的 label 仍然是读者唯一听得到的名字。