Uh oh!
There was an error while loading. Please reload this page.
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
|
yinlianghui
commented
Aug 9, 2026
CollaboratorAuthor
PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3976 落地。 核验记录(head
Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 21:57
Uh oh!
There was an error while loading. Please reload this page.
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 10, 2026
… (objectstack-ai#3996) `InspectorTextField` / `InspectorNumberField` / `InspectorSelectField` 都把 `Label` 渲染成控件的兄弟节点,却既没有 `htmlFor`、也没有 `id`,更没有 `aria-label` 兜底 —— 标签与控件之间只有视觉邻接。修前实测:三者的 `getByLabelText('Group')`(与屏幕阅读器 同一条 for→id 链)全部 0 命中,焦点落进去读到的是匿名「编辑框 / combobox」,可见的 字段名成了一段无归属文本,点标签什么都不会发生。这三个原子被 16 个非测试模块消费 (页面块 / 流程节点 / 报表 / 数据集 / 权限 / 对象字段各检查器,以及 studio-design 的 对象分组检查器),打开任意检查器面板即渲染。 三者各自 `React.useId()` 生成 id 并闭合 `htmlFor` ⇄ `id`。id 在原子内部生成而非由调用方 传入是有意的:这些原子渲染在数组项循环里(`record:details.sections[i]`、 `page:tabs.items[i]`),每项重复同一个 label —— 那正是调用方传 id 会撞车的地方,而撞车 是静默的(两个 label 都能解析,只是都指向第一个控件)。`useId()` 结构上不可能撞车; 多实例互不串 id 是钉住的,不是假设的。 select 的 id 落在 `SelectTrigger` 而非 `Select`:Radix `Select.Root` 自己不渲染任何 DOM 元素,交给它的 id 会被静默丢弃、label 的 for 随之悬空 —— 就是 objectstack-ai#3976(PR objectstack-ai#3992) 在隔壁修掉的同一机制。trigger 渲染真正的 `button[role=combobox]`,是可 label 元素, 一对 for/id 即完成命名,不需要第二条 `aria-labelledby` 通道;`disabled` 仍留在 Root (对 trigger / items / 隐藏 native mirror 的单一权威),禁用态下依然有可访问名。 `InspectorCheckboxField` 本来就是对的(包裹式 label,合法关联、无需 id),一字未动, 在测试里作为阳性对照 —— 两个反向变异下它必须保持绿。 钉子 `_shared.labels.test.tsx`(26 条):三原子共用 `describe.each` 跑同一组保证 —— label 的 for 有宿主、`getAllByLabelText` 命中且就是那个可聚焦控件(select 命中 trigger、 tagName BUTTON)、可访问名等于 label、只有 for 一条命名通道(先断言 forId 为真, 否则修前 null === null 会假绿)、id 全文档唯一;同 label 两实例 id 不同且各自归属、 不同 label 兄弟各自解析;以标签定位到的控件是真控件(输入提交只来自该实例、number 提交数字、select 的 trigger 就是显示选中项 label 的那个元素)。 `PageBlockInspector.sectionName.test.tsx` 的变通处随之收紧:原注释把「必须按 placeholder 定位,因为 `InspectorTextField` 的 Label 与 input 无关联,`getByLabelText` 到不了」写下来 当作理由,那正是本缺陷的机械症状。定位改为 `getAllByLabelText('Name (i18n key)')` (与 AT 同一条链),`snake_case` 约定不再搭便车藏在定位器里,单独一条断言; 「一个分区一个 name 框」顺带成为消费侧的 id 不撞车检查。 Co-authored-by: Claude <noreply@anthropic.com>
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 freeto 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