fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) - #3978
Merged
Conversation
…of an inert `for` (#3961) Six widgets rendered a visible group label that was the accessible name of NOTHING. Two shapes, one outcome: `address` / `geolocation` replaced the host's id with their own sub-input ids (#3343), so the label's `for` named an id no element carried; `checkboxes` / `radio` / `rating` / `file` kept the id but on a `div`, where `label for` is inert HTML (`HTMLLabelElement.control` is null — it activates nothing and contributes no name). The WAI-ARIA group pattern, driven by a declaration rather than by the host guessing at widget DOM: - core: `ComponentMeta.labelling?: 'control' | 'group'`. Optional and additive; absent means `'control'`. - components: the form renderer branches on it. A `'group'` field's `FormLabel` publishes an `id` and drops its `for`; the widget receives `aria-labelledby`. The single-control path emits not one changed attribute (conditional spreads), so no field gains a second naming channel. `ui/form.tsx` is untouched — `FormLabel` spreads props after its own `htmlFor`, so both halves travel as ordinary props. - fields: the six audited widgets declare `labelling: 'group'`. address / geolocation move only the host id to the group container; checkboxes / rating answer with `role="group"`; radio keeps Radix's more specific `radiogroup`; file takes the name on its dropzone with no invented group layer (one control, merely not a labelable element). No new key in the widget props contract: `aria-*` is already declared on it and forwarded by `toDomProps`, the channel `aria-required` (#3290) uses. Unchanged on purpose: sub-labels keep naming their own inputs (an `aria-labelledby` on the first sub-input would OVERRIDE its own label), `aria-describedby` stays on the first focusable sub-input (#3318), the sub-input ids of #3343 do not move, and standalone rendering emits no role and no IDREF. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
Collaborator
Author
|
PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3961 按 B 案裁决落地。 核验记录(head
衍生:#3975(multiselect 同形 XS,刻意不并入 —— 保持已验证面干净,合入后即派)、#3976(内建 select 分支,同文件串行)、#3318 附着评论 —— 三条处置均得当。 Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 19:56
This was referenced Aug 9, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3961
复合/分组 field widget 的可见组标签,今天是零个元素的可访问名。按 PM 的范围裁决(issue 评论 5233281956)采 B 案一次做完:widget 声明
labelling,form 渲染器按声明改用 IDREF 关联。缺陷:两种形态,同一个结局
真表单 + 每字段一行,读每个 label 的
for与它解析到的元素(probe 未提交)。接线前:for指向一个没有元素携带的 id —— 点「Shipping Address」什么都不发生,组标签完全不在可访问性树里。for解析成功,但label[for]指向不可 label 的元素在 HTML 里是惰性的 ——HTMLLabelElement.control返回 null,既不激活任何东西也不贡献可访问名。所以两种形态的结局是同一个:右边两列都是 0。这比 issue 正文对 B 类「比 A 类轻」的描述更严格。
接线后(同一 probe,同一形状):
改动
packages/core—— 声明位。ComponentMeta加labelling?: 'control' | 'group',可选、增量,缺省即'control'(所有现存组件今天的行为)。'group'的含义写在注释里:渲染出的外层不是可 label 元素 —— 既包括真复合(一个容器下多个输入),也包括「唯一控件恰好不是可 label 元素」(file 的div[role=button]dropzone)。这是声明而非猜测:host 无法从 widget 恰好渲染出的 DOM 推断它。packages/components—— form 渲染器按声明分支。 group 字段的FormLabel发id、不发htmlFor;widget 收到aria-labelledby。单控件路径一个属性都没变(两处都是条件展开,连 key 都不进 props),所以没有任何字段多出第二条命名通道 —— #3290 / #3222 / #3952 反复钉的「一个事实一个作者」。⛔ui/form.tsx(Shadcn no-touch)零触碰:FormLabel在自己的htmlFor之后才展开 props,所以「给 label 一个 id」「拿掉它的 for」两半都能以普通 prop 传入。resolveFieldLabelling逐字镜像renderFieldComponent的解析:builtin 判定用原始 type(裸select走内建分支、根本不查注册表;field:select才解析到注册 widget),之后才剥field:前缀。否则一个 widget 的声明会去改一个并不由它渲染的内建控件的 label。packages/fields—— 六个 widget。 与FIELD_TYPES_SKIP_FALLBACK并列的FIELD_TYPES_GROUP_LABELLED声明这六个。逐 widget 处置(按 issue 评论 5233264043 的方案表):id与aria-labelledby从首个子输入移到组容器,补role="group"role="group"(名字已随groupDomProps到达)radiogroup(更具体的角色,且是aria-invalid的正确承载者)aria-labelledbywidget props 契约零新 key:
aria-*已声明在契约上(ReactAriaAttributes),两个 strip 都不碰前缀,toDomProps按前缀转发整族 —— 与aria-required(#3290)同一条通道。刻意不改的四件事
aria-labelledby会覆盖控件的label[for],所以把组名放到首个子输入上会把「Street Address」替换成字段名 —— issue 否掉选项 2 的那个「可访问名拼接」结局。aria-describedby留在首个可聚焦子输入(29 个注册 field widget 在校验失败后从不携带aria-invalid—— #3306 全注册表守卫实测的账本 #3318 的刻意选择):描述/错误必须在焦点落到可聚焦控件时播报,组容器不可聚焦。只挪了id,没整块搬。role键在 host 真的给了名字时才出现 —— 无名的 group 对辅助技术没有信息量。验证
vitest run packages/core packages/components packages/fields→ 251 files / 3573 tests 全绿。vitest run packages/app-shell packages/plugin-form packages/plugin-grid packages/plugin-detail packages/react→ 496 files / 4632 passed / 1 skipped,0 失败。turbo run type-check→ 78/78 successful;node scripts/check-control-bytes.mjsOK。反向验证(先预判再跑,变异均已还原):
role="group"→ 预判 rating 的组名断言翻红、standalone 断言保持绿 → 实测 4 红 32 绿,standalone 行保持绿。sub-inputs keep their own labels的红因来自 RTL 的getLabels:元素带aria-labelledby时它优先用 IDREF 作为该元素的 label 列表,于是「Street Address」不再命名街道框 —— 正是这一改动要避免的可访问名覆盖。for」那条不变量捕获(form 侧仍不发for),所以那条断言单独不足以覆盖,两组断言都必须在。"labelable": false的 diff —— 漏声明的复合 widget 会被 [fields] a boolean field's visible form label is associated with nothing — BooleanField overrides the control id with the field name, so the form's labelfordangles #3952 那类「label 必须解析到真控件」的钉子当场抓住,而不是静默降级。checkboxes→ 预判声明位钉两条翻红 → 实测expected undefined to be 'group'+ 集合断言两条红。顺带发现(均已另立,不在本 PR 修)
把 probe 扩到 18 个字段类型跑了一遍,发现三条超出本单裁定六个 widget的同族账:
multiselect与 checkboxes 逐字同形(host id 落在包裹 div 上,for惰性),不在 issue 实测表与 PM 裁定的六个内 → 标Blocked-by: #3961,修法是本 PR 机制上的两行。select分支把 host id 展开到 Radix Select.Root —— 手写 form schema 里type: 'select'的 labelfor全部悬空(#3306 的内建侧同形) #3976 表单渲染器内建select分支把 host id 展开到 RadixSelect.Root(非 DOM 宿主,静默丢弃),手写 form schema 的裸type: 'select'全部悬空。field:select,走已修好的注册 widget)。aria-invalid—— #3306 全注册表守卫实测的账本 #3318 评论signature与 slider 同形(整行没有任何带 id 的元素,因为它不转发任何 DOM pass-through)→ 落在 29 个注册 field widget 在校验失败后从不携带aria-invalid—— #3306 全注册表守卫实测的账本 #3318 完成范围,按「attach, don't scatter」在那边留实测,未另立单。Generated by Claude Code