Skip to content

fix(components): 内建 select 分支把 host id/aria-* 落到真实 trigger,label for 不再悬空 (#3976) - #3992

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3976-builtin-select-host-id
Aug 9, 2026
Merged

fix(components): 内建 select 分支把 host id/aria-* 落到真实 trigger,label for 不再悬空 (#3976)#3992
yinlianghui merged 1 commit into
mainfrom
claude/issue-3976-builtin-select-host-id

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3976

缺陷(实测,probe 未提交)

手写 form schema 里一个 { name: 'status', label: 'Status', type: 'select', options: [...], required: true }:

label for="_r_1_-form-item"   ->  文档里没有任何元素带这个 id
getByLabelText(/status/i)     ->  0 命中
trigger 按钮                   ->  无 id、无 aria-describedby、无 aria-invalid、无 aria-required

可见的「Status」标签指向一个没有宿主的 id:点标签什么都不会发生,屏幕阅读器读到的是一个匿名 combobox —— 没有字段名、没有错误消息关联、也没有必填状态。

成因

内建 select 分支把整个 DOM pass-through 展开到 Radix 的 Select.Root。Root 自己不渲染任何 DOM 元素,不认识的 prop 被静默丢弃 —— 而 FormControl(Radix Slot)下发的 id / aria-describedby / aria-invalid,以及调用点下发的 aria-required,全都走这个展开点。

这正是 #3306widget 侧修掉的同一机制(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 修后 truearia-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.eachfield: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=2142 files / 1644 tests passed(除强制范围外,按「规则消费半径」把 form 渲染器的其他消费者一并扫了:ObjectForm(plugin-form)、form-renderers、以及同机制的 ActionParamDialog select 分支 两处表单控件缺 label→control 关联:InlineCreateRelated 的 label 无 htmlFor/id,custom action-param-dialog 的 select 分支 htmlFor 指向不存在的 id #3341 的两个钉)。
  • pnpm exec turbo run type-check --concurrency=278 successful, 78 total
  • node scripts/check-control-bytes.mjs → OK(3882 个文本文件)。

边界

  • 只动 packages/components/src/renderers/form/form.tsx 的内建 select 分支 + 两个测试文件 + changeset(@object-ui/components patch)。
  • 与 PR fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) #3978 刚落的 group labelling 分支(同文件另一区块)diff 不相交;内建 select 的 resolveFieldLabelling 仍是 'control',因为 trigger 是可 label 的 button —— 测试里把「只有 for、没有第二条 aria-labelledby」钉住了。
  • 未碰 ui/ 下的 Shadcn 副本(no-touch),未碰 content/docs/releases/

顺带记录(不在本 PR 内修)


Generated by Claude Code

…不再悬空 (#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」钉住了。
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 9, 2026 9:19pm

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-g7pL1X4B.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 483.62KB 106.67KB
core (index.js) 3.00KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 227.99KB 56.47KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.82KB 10.79KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.49KB 17.48KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.39KB 30.67KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.80KB 59.48KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.29KB 27.16KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.97KB 49.79KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.73KB 26.55KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.95KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3976 落地。

核验记录(head 433d4f3d8,基 cdc0e44c8,实物核验 + CI 亲读):

  1. 前提实测 + 机制根因:probe 证实裸 type:'select' 的 label for 零解析、trigger 无 id/aria-*;根因是 pass-through 展开到 Radix Root(非 DOM 宿主静默丢弃)。
  2. 组件边界的必然性论证成立:FormControl 是 Radix Slot,只有组件才能承接并重新寻址注入的 props(与 BuiltinSelectEmptyState 同理)—— BuiltinSelectControl(forwardRef)不是风格选择;field:select 从不向辅助技术播报校验状态 —— aria-invalid / aria-describedby / aria-required 被 Radix Select.Root 静默丢弃 #3306 的 Root 白名单例外(name 喂隐藏原生 select、disabled 单一权威)保留正确;顺带修复 ref(RHF 无法聚焦)与 className(cn 合并),均有据。
  3. 双路径 25 钉不互扰:内建 12 钉(含 aria-invalid 提交前后、describedby 闭合到消息 id、双 select 各自命名)+ fields 侧 describe.each 并排六保证 + 分叉钉(裸 select 的 DOM 不含 SelectField locator)—— 双路径不再静默漂移。
  4. 反向验证:13 红 12 绿逐条命中预判(含 field:select 全绿 = field:select 从不向辅助技术播报校验状态 —— aria-invalid / aria-describedby / aria-required 被 Radix Select.Root 静默丢弃 #3306 半边从未经 Root);reporter 失效一次自纠重跑而非用空输出交差。
  5. 诚实的不立单记录:span 泄漏疑点 probe 证伪后不开投机单 —— 正确;[components] 内建 select 的空选项/依赖门态分支把 host id 落在一个 div 上 —— label 的 for 指向不可 label 元素,仍是不关联的 #3991(空状态 div 承 id)finding 持有。
  6. 门与规程:强制面 + 消费半径 1644 测试绿;type-check 78/78;控制字节门;changeset patch;fable 0;ui/ 零触碰(实物核验)。
  7. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 21:25:33Z)。

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants