From 433d4f3d864cfbee8443182a5549dffe2ca6d711 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 21:17:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(components):=20=E5=86=85=E5=BB=BA=20select?= =?UTF-8?q?=20=E5=88=86=E6=94=AF=E6=8A=8A=20host=20id/aria-*=20=E8=90=BD?= =?UTF-8?q?=E5=88=B0=E7=9C=9F=E5=AE=9E=20trigger,label=20for=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=82=AC=E7=A9=BA=20(#3976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手写 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」钉住了。 --- .changeset/builtin-select-host-id-3976.md | 35 +++ .../form-builtin-select-host-id.test.tsx | 272 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 133 +++++++-- .../select-label-association-e2e.test.tsx | 199 +++++++++++++ 4 files changed, 613 insertions(+), 26 deletions(-) create mode 100644 .changeset/builtin-select-host-id-3976.md create mode 100644 packages/components/src/renderers/form/__tests__/form-builtin-select-host-id.test.tsx create mode 100644 packages/fields/src/__tests__/select-label-association-e2e.test.tsx diff --git a/.changeset/builtin-select-host-id-3976.md b/.changeset/builtin-select-host-id-3976.md new file mode 100644 index 0000000000..7c223640c6 --- /dev/null +++ b/.changeset/builtin-select-host-id-3976.md @@ -0,0 +1,35 @@ +--- +"@object-ui/components": patch +--- + +Built-in `select` fields: the form's label, validation message and required state now reach the control + +A hand-written form field `{ name: 'status', label: 'Status', type: 'select', options: [...] }` +rendered a visible "Status" label that pointed at nothing. Measured before the fix: +`