fix(components): 内建 select 分支把 host id/aria-* 落到真实 trigger,label for 不再悬空 (#3976) - #3992
Merged
Merged
Conversation
…不再悬空 (#3976) 手写 form schema 里的 `{ name: 'status', label: 'Status', type: 'select', options: [...] }`, 可见的「Status」标签指向一个没有任何元素携带的 id。修前实测(probe,未提交): `label for="_r_1_-form-item"` 在文档里找不到宿主,`getByLabelText(/status/i)` 0 命中, trigger 按钮上没有 id、没有 aria-describedby、没有 aria-invalid,连 `required: true` 的 aria-required 也没有 —— 点标签什么都不会发生,屏幕阅读器读到的是一个匿名 combobox: 没有字段名、没有错误消息关联、没有必填状态。 成因是内建 select 分支把整个 DOM pass-through 展开到 Radix `Select.Root`。Root 自己不渲染 任何 DOM 元素,不认识的 prop 被静默丢弃 —— 而 `FormControl`(Radix Slot)下发的 id / aria-describedby / aria-invalid 与调用点下发的 aria-required 全都走这个展开点。 这正是 #3306 在 widget 侧修掉的同一机制(`SelectField` 把 pass-through 落到 `SelectTrigger`); 两条路径分叉是因为 `BUILTIN_FIELD_TYPES` 含 'select':对象驱动的 field:select 解析到注册 widget,而手写的裸 type: 'select' 根本不查注册表,内建分支的同形缺陷一直留着。 修法是抽出 `BuiltinSelectControl`。组件边界不是风格选择而是必要条件:Slot 注入的是分支 返回的那个元素,只有组件才能接住这些 prop 并重新落位(与同文件 `BuiltinSelectEmptyState` 成为组件是同一理由)。pass-through 落到 `SelectTrigger` 渲染的 button[role=combobox], 并保留 #3306 的两个白名单例外:`name` 留在 Root(Root 真正消费的唯一 key,转发给参与表单 提交的隐藏 select),`disabled` 留在 Root(单一权威,同时禁用 trigger / items / 隐藏 select)。 `ref` 随 pass-through 走,内建 select 不再是唯一一个 react-hook-form 无法聚焦的内建控件; 作者写的 className 现在也能到达 trigger(修前它到不了任何元素),与分支自己的触控高度用 cn() 合并而非互相覆盖。渲染哪个组件没有变,只变了 host props 的落点。 双路径各自钉住,互不干扰: - `packages/components/.../form-builtin-select-host-id.test.tsx`(12 条):内建路径的 for 解析到 trigger、可访问名、aria-required(必填有/可选无)、aria-invalid 修前 false 修后 true、aria-describedby 闭合到消息元素 id、name 仍只在 Root(trigger 上没有)、 disabled 仍由 Root 单一权威、只有 for 没有第二条 aria-labelledby 通道、两个 select 各自命名;外加分叉钉——注册表里确实存在 field:select 时裸 select 仍走内建分支—— 以及注册 widget 路径拿到 host props 的阳性对照。 - `packages/fields/.../select-label-association-e2e.test.tsx`(13 条):用真 `SelectField` 把 field:select 与裸 select 并排跑同一组保证(describe.each), 注册路径是必须保持绿的阳性对照,分叉钉断言 SelectField 的 trigger locator 不出现 在裸 select 的 DOM 里。 反向验证按预判成立(先写预判再跑,变异未提交):把 pass-through 还原展开到 `Select.Root` 后 13 红 12 绿,与预判逐条一致 —— 红的全是两个文件里内建路径的命名与状态钉,绿的是 name/disabled 留在 Root、两条分叉钉,以及整条 field:select 注册路径(#3306 那半从未 经过 Root)。 同文件的 group labelling 分支(PR #3978)未触碰,diff 不相交;内建 select 的 `resolveFieldLabelling` 仍是 'control',因为 trigger 是可 label 的 button,测试里把 「只有 for、没有 aria-labelledby」钉住了。
|
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。#3976 落地。 核验记录(head
Generated by Claude Code |
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 #3976
缺陷(实测,probe 未提交)
手写 form schema 里一个
{ name: 'status', label: 'Status', type: 'select', options: [...], required: true }:可见的「Status」标签指向一个没有宿主的 id:点标签什么都不会发生,屏幕阅读器读到的是一个匿名 combobox —— 没有字段名、没有错误消息关联、也没有必填状态。
成因
内建
select分支把整个 DOM pass-through 展开到 Radix 的Select.Root。Root 自己不渲染任何 DOM 元素,不认识的 prop 被静默丢弃 —— 而FormControl(Radix Slot)下发的id/aria-describedby/aria-invalid,以及调用点下发的aria-required,全都走这个展开点。这正是 #3306 在 widget 侧修掉的同一机制(
SelectField现在把 pass-through 落到SelectTrigger)。两条路径会分叉,是因为BUILTIN_FIELD_TYPES含'select':对象驱动的field:select解析到注册 widget(#3306 已修),而手写的裸type: 'select'根本不查注册表,内建分支的同形缺陷就一直留着。修法
抽出
BuiltinSelectControl。组件边界不是风格选择而是必要条件:Slot 注入的是分支返回的那个元素,只有组件才能接住这些 prop 并重新落位(与同文件BuiltinSelectEmptyState当初成为组件是同一理由)。pass-through 落到SelectTrigger渲染的button[role=combobox],并保留 #3306 的两个白名单例外:name留在 Root —— 这是 Root 真正消费的唯一一个 key,它转发给参与表单提交的隐藏select[aria-hidden=true];放到非提交控件的按钮上毫无用处。disabled留在 Root —— Root 是单一权威(同时禁用 trigger、items 和隐藏 select),再给 trigger 一份就是给同一状态两个作者。ref同样随 pass-through 走:react-hook-form 给每个字段一个ref,这个分支原先把它丢在 Root 上,所以内建 select 是唯一一个 RHF 无法聚焦的内建控件;现在和input/textarea一样落到真元素上。作者写的className也终于能到达 trigger(修前它到不了任何元素),与分支自己的触控高度用cn()合并而不是互相覆盖。渲染哪个组件没有变,只变了 host props 的落点。双路径各自钉住,互不干扰
packages/components/src/renderers/form/__tests__/form-builtin-select-host-id.test.tsx(12 条):内建路径的for解析到 trigger、可访问名、aria-required(必填有 / 可选无)、aria-invalid修前false修后true、aria-describedby闭合到消息元素的 id、name仍只在 Root(trigger 上没有)、disabled仍由 Root 单一权威、只有for没有第二条aria-labelledby通道、两个 select 各自命名;外加分叉钉(注册表里确实存在field:select时裸select仍走内建分支)与注册 widget 路径的阳性对照。packages/fields/src/__tests__/select-label-association-e2e.test.tsx(13 条):用真SelectField,以describe.each把field:select与裸select并排跑同一组保证。注册路径是必须保持绿的阳性对照;分叉钉断言SelectField的 trigger locator 不出现在裸select的 DOM 里。反向验证(先写预判再跑,变异未提交)
预判:把 pass-through 还原展开到
Select.Root后,components 8 红 4 绿、fields 5 红 8 绿。实跑 13 failed | 12 passed (25),逐条与预判一致 —— 红的全是两个文件里内建路径的命名与状态钉,绿的是name/disabled留在 Root、两条分叉钉,以及整条field:select注册路径(#3306 那半从未经过 Root)。文件已按 md5 校验还原。验证
pnpm exec vitest run packages/components/src/renderers/form packages/fields packages/components/src/__tests__/form-renderers.test.tsx packages/components/src/__tests__/action-param-dialog-label-association.test.tsx packages/components/src/__tests__/action-param-dialog-aria-required.test.tsx packages/plugin-form --maxWorkers=2→ 142 files / 1644 tests passed(除强制范围外,按「规则消费半径」把 form 渲染器的其他消费者一并扫了:ObjectForm(plugin-form)、form-renderers、以及同机制的ActionParamDialogselect 分支 两处表单控件缺 label→control 关联:InlineCreateRelated 的 label 无 htmlFor/id,custom action-param-dialog 的 select 分支 htmlFor 指向不存在的 id #3341 的两个钉)。pnpm exec turbo run type-check --concurrency=2→ 78 successful, 78 total。node scripts/check-control-bytes.mjs→ OK(3882 个文本文件)。边界
packages/components/src/renderers/form/form.tsx的内建 select 分支 + 两个测试文件 + changeset(@object-ui/componentspatch)。resolveFieldLabelling仍是'control',因为 trigger 是可 label 的button—— 测试里把「只有for、没有第二条aria-labelledby」钉住了。ui/下的 Shadcn 副本(no-touch),未碰content/docs/releases/。顺带记录(不在本 PR 内修)
finding):选项为空 / 依赖门态时,内建 select 渲染的是空状态div,host id 落在这个div上,FormLabel的for指向不可 label 元素 —— 与本单机制不同(那里根本没有控件),影响很小,已按 observation 记录待分诊。Generated by Claude Code