在 objectui#3222(把校验槽位改名为 spec 的 error 并接上生产者)中,按裁定单独评估了 required 是否应下沉到 widget props。结论是不下沉(理由见 PR #3289),但评估过程里暴露了一个独立的、真实的可访问性缺口,单独立此单。
事实
表单渲染器算出的 required(静态 required + requiredWhen CEL,form.tsx 的 ruleState.required)只用于一处:画 < FormLabel > 里的红星。
{required && (
< span className="text-destructive ml-1" aria-label="required" >*< /span >
)}
它没有进入 renderFieldComponent 的 props,< FormControl >(Radix Slot,注入 id / aria-describedby / aria-invalid)也不注入 aria-required。全仓 grep aria-required:0 处。
也就是说必填状态今天只通过一条路径到达屏幕阅读器:< label for > 关联把那个 aria-label="required" 的 span 并进控件的可访问名,于是字段被读成「Title required」。
为什么这不够好
- 它把一个状态塞进了名字。AT 有专门的 required 状态通道(
aria-required / 原生 required),会用一致的方式播报;混进名字里则依赖朗读顺序,且无法被「只列必填项」这类导航功能识别。
- 字段没有 label 时(紧凑布局、grid 内联编辑、
label 为空的表单)红星根本不渲染,必填信号完全消失。
requiredWhen 让必填是动态的,状态通道能表达变化,名字不能。
修法(便宜得出乎意料,且不需要改 widget 契约)
FieldWidgetComponentProps 已经 & AriaAttributes,aria-required 本来就是这份契约里已声明、已类型化的键;而每个 widget 都已经把剩余 props 转发到它渲染的控件上。内置分支同理(stripRendererOnlyProps 不剥 aria-*)。
所以大概是表单渲染器里的一行:
dependentValues: ruleRecord,
error: fieldState.error?.message,
+ 'aria-required': required || undefined,
—— 48 个 widget 一个都不用改。这正是 #3222 里拒绝把 required 布尔下沉的理由:下沉会给必填标记第二个作者(下一个 AI 写的 widget 自己画星号 → 双份显示),而 aria-required 直达 DOM,既拿到 a11y 收益又不新增契约键。
需要确认的点
- 红星的
aria-label="required" 是否同时保留?保留会让 AT 播报两次(名字里一次、状态一次)。倾向:把 span 改成 aria-hidden="true"(它是纯视觉冗余),让状态通道成为唯一来源。这是行为变更,需要拍板。
- 是否顺带给内置控件上原生
required?不建议 —— 会触发浏览器原生校验气泡,和 react-hook-form 的校验双轨。aria-required 只报状态、不触发原生校验,是正确的那个。
边界
packages/components/src/renderers/form/form.tsx 一行 + 测试。不动 packages/spec,不动任何 widget。
关联:objectui#3222、PR #3289。
在 objectui#3222(把校验槽位改名为 spec 的
error并接上生产者)中,按裁定单独评估了required是否应下沉到 widget props。结论是不下沉(理由见 PR #3289),但评估过程里暴露了一个独立的、真实的可访问性缺口,单独立此单。事实
表单渲染器算出的
required(静态required+requiredWhenCEL,form.tsx的ruleState.required)只用于一处:画< FormLabel >里的红星。它没有进入
renderFieldComponent的 props,< FormControl >(RadixSlot,注入id/aria-describedby/aria-invalid)也不注入aria-required。全仓 greparia-required:0 处。也就是说必填状态今天只通过一条路径到达屏幕阅读器:
< label for >关联把那个aria-label="required"的 span 并进控件的可访问名,于是字段被读成「Title required」。为什么这不够好
aria-required/ 原生required),会用一致的方式播报;混进名字里则依赖朗读顺序,且无法被「只列必填项」这类导航功能识别。label为空的表单)红星根本不渲染,必填信号完全消失。requiredWhen让必填是动态的,状态通道能表达变化,名字不能。修法(便宜得出乎意料,且不需要改 widget 契约)
FieldWidgetComponentProps已经& AriaAttributes,aria-required本来就是这份契约里已声明、已类型化的键;而每个 widget 都已经把剩余 props 转发到它渲染的控件上。内置分支同理(stripRendererOnlyProps不剥aria-*)。所以大概是表单渲染器里的一行:
dependentValues: ruleRecord, error: fieldState.error?.message, + 'aria-required': required || undefined,—— 48 个 widget 一个都不用改。这正是 #3222 里拒绝把
required布尔下沉的理由:下沉会给必填标记第二个作者(下一个 AI 写的 widget 自己画星号 → 双份显示),而aria-required直达 DOM,既拿到 a11y 收益又不新增契约键。需要确认的点
aria-label="required"是否同时保留?保留会让 AT 播报两次(名字里一次、状态一次)。倾向:把 span 改成aria-hidden="true"(它是纯视觉冗余),让状态通道成为唯一来源。这是行为变更,需要拍板。required?不建议 —— 会触发浏览器原生校验气泡,和 react-hook-form 的校验双轨。aria-required只报状态、不触发原生校验,是正确的那个。边界
packages/components/src/renderers/form/form.tsx一行 + 测试。不动packages/spec,不动任何 widget。关联:objectui#3222、PR #3289。