Skip to content

fix(fields): BooleanField 使用 host 下发的控件 id,可见 label 不再悬空 (#3952) - #3959

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3952-boolean-label-for
Aug 9, 2026
Merged

fix(fields): BooleanField 使用 host 下发的控件 id,可见 label 不再悬空 (#3952)#3959
yinlianghui merged 1 commit into
mainfrom
claude/issue-3952-boolean-label-for

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3952

缺陷

表单里渲染 boolean / checkbox 字段时会发出两个同文本 label,而可见的那个指向一个不存在的 id。FormControl 是 Radix Slot,它把 FormLabel htmlFor 已经指向的 id(_r_N_-form-item)交给控件;BooleanField 却用字段名覆写掉:

label for="_r_a_-form-item"  ->  target exists? false    (可见 label)
label for="notifications"    ->  target exists? true     (widget 自己的 sr-only)
switch id = "notifications"

上面是在真表单里实测到的(修前跑本 PR 的钉子,两字段用例直接打印出 {"forId":"_r_a_-form-item","resolves":false}),与 issue 正文的取证一致。用户可见的后果是点击可见 label 不会翻转开关 —— switch/checkbox 行最常用的操作方式失效,而且这是所有 app 里所有生成表单上的每个 boolean 字段。可访问名只是靠 widget 那个 sr-only label 侥幸成立。

修法(落在 widget 侧,不动 form 侧)

id 本来就是 widget 契约 FieldWidgetDomProps 声明的键,toDomProps 一直在转发它 —— 丢弃已送达值的是消费者。所以按 AGENTS.md #0.1 在消费者处改,packages/components/src/renderers/form/form.tsx 的 FormControl 机制只读不改。

  1. id 链:hostId || config.name || useId() config.name 这一项不是冗余:@object-ui/app-shellActionParamDialog boolean 分支渲染 Label htmlFor={param.name}下发 id(与它自己的通用分支不同,那条是 id={param.name}),全靠字段名派生的 id 成立(paramToFieldname: param.name)。这条契约已单独钉住,以后想「清理成裸 useId()」会直接翻红,而不是悄悄让控制台里每个 boolean action param 失联。
  2. host 下发了 id 就说明 host 自己渲染了 label,widget 不再重复发 sr-only label。standalone(FieldEditWidget 的表格内联编辑、裸 SDUI 节点)没有 host label,sr-only label 是控件唯一的可访问名,原样保留。

第 2 条刻意按 host id 判断,而不是按 config.label 判断:表单内命名是 host 对每个 widget 的职责(没有 label 的表单行里,文本字段同样无名),而原来那个 'Checkbox' 兜底在表单内给出的是一个错误的可访问名,不是安全网。

钉子

新增 packages/fields/src/__tests__/boolean-label-association-e2e.test.tsx(真表单 + 真 react-hook-form,范式同 widget-aria-invalid-e2e.test.tsx),11 个用例分三组:

  • 表单内(host):每个 label 的 for 都解析到存在的元素;可见 label 命名的就是 switch;label 文本在 DOM 中只出现一次;点击可见 label 后 switch 翻转;控件保留 host 的 id(且不等于字段名);两个 boolean 字段各归各的 label。
  • standalone:switch / checkbox 两个变体的 sr-only label 仍是控件的可访问名 —— 回归守卫。
  • 按字段名关联的 host:上面第 1 条的 ActionParamDialog 契约。

反向验证(先预判方向,再跑;变异不提交)

修前 7/11 红,修后 11/11 绿。另做两个隔离变异,确认每个钉子守的是哪一半:

变异 悬空 for getAllByLabelText label 文本计数 按文本定位的用例
只恢复 id 覆写(保留 label 抑制) 绿
只恢复无条件内部 label(保留 host id) 绿 绿

第一个变异里 getAllByLabelText 不是返回了错节点,而是抛 found a label ... however no form control was found associated to that label —— 即 switch 完全没有可访问名,正是 issue 正文预警的「哪天 widget 内部 label 被去掉就会静默失名」。

诚实记一笔:getAllByLabelText 长度为 1 这条守重复 label 那一半 —— 两个 label 指向同一个控件时它仍然只解析出一个元素(实测),重复由 label 文本计数那条守。测试里原本写反了的注释已按实测改正。standalone 两个钉子在三种状态下都绿,它们是回归守卫,不是缺陷钉。

同类排查(其它合成 id 的 widget)

按正文提示逐个 grep useId / config?.name 的 id 合成点,再用一次性 probe 在真表单里实测每个 widget 的 host label 落点(probe 未提交):

widget host id 落在 host label 的 for 处置
boolean button[role=switch] 解析成功 本 PR 修复
address subId('street') 覆写 MISSING(悬空) 另开 issue,修法需定夺
geolocation subId('latitude') 覆写 MISSING(悬空) 同上
slider 完全没转发,整行无任何 id MISSING(悬空) 落在 #3318 的完成范围内,已在该 issue 评论
checkboxes / radio 包裹 div / radiogroup 根 解析到 div(不可 label 的元素) 另开 issue
rating / file 包裹 div 解析到 div 另开 issue
text / textarea / color / tags / date / select / user 真控件(input / textarea / button) 解析成功 无需改动(阳性反查)

只有 BooleanField 属于「单一控件 + 覆写 host id + 自带重复 label」这一种形态,修法无歧义;address / geolocation 是复合控件(5 个和 2 个子输入各带自己的可见子 label),「组标签该指向谁」是设计裁决(指向第一个子输入 vs. role=group + aria-labelledby),不在本 issue 范围内,已另开 issue 附实测表与两个选项。

验证

  • pnpm exec vitest run packages/fields packages/components/src/renderers/form --maxWorkers=2 → 100 files / 1176 tests 全绿。
  • 消费半径外扩:ActionParamDialog(×3)+ paramToField + packages/plugin-detail → 66 files / 559 tests 全绿(BooleanField 的另两个 host)。
  • pnpm exec turbo run type-check --concurrency=2 → 78/78 successful。
  • node scripts/check-control-bytes.mjs → OK(3849 个文件);改动文件另做 grep -naP 自扫,零命中。
  • changeset:@object-ui/fields patch(bug 修复,用户可见)。

Generated by Claude Code

表单里渲染 boolean/checkbox 字段时会发出**两个**同文本 label,而可见的那个指
向一个不存在的 id:`FormControl`(Radix `Slot`)把 `FormLabel htmlFor` 已经指
向的 id 交给控件,widget 却用字段名把它覆写掉 —— `label for="_r_3_-form-item"`
在 DOM 里无目标,switch 挂的是 `id="notifications"`。后果是点击可见 label 不会
翻转开关(switch/checkbox 行最常用的操作方式),而可访问名只是靠 widget 自己那
个 sr-only label 侥幸成立。

修法落在 widget 侧:`id` 本来就是 widget 契约(`FieldWidgetDomProps`)声明的键、
`toDomProps` 也一直在转发,丢弃已送达值的是消费者,所以按 AGENTS.md #0.1 在
消费者处改,不动 form 侧的 FormControl 机制。

- id 链改为 `hostId || config.name || useId()`。`config.name` 这一项不是冗余:
  app-shell `ActionParamDialog` 的 boolean 分支渲染 `<Label htmlFor={param.name}>`
  却**不**下发 id(与它自己的通用分支不同),全靠字段名派生的 id 成立 —— 已按
  这条契约钉住,以后想「清理成裸 useId()」会直接翻红。
- host 下发了 id 就说明 host 自己渲染了 label,widget 不再重复发 sr-only label;
  standalone(`FieldEditWidget` 的表格内联编辑、裸 SDUI 节点)没有 host label,
  sr-only label 是控件唯一的可访问名,原样保留。

钉子(`boolean-label-association-e2e.test.tsx`,真表单 + 真 react-hook-form):
表单内每个 label 的 `for` 都必须解析到存在的元素、可见 label 点击后 switch 翻转、
label 文本在 DOM 中只出现一次、控件保留 host 的 id、两个 boolean 字段各归各的
label;另有 standalone 两个变体与「按字段名关联」的回归守卫。

反向验证(先预判方向再跑,变异不提交):修前 7/11 红。两个隔离变异确认各钉子
守的是哪一半 —— 只恢复 id 覆写:悬空 `for` 与 `getAllByLabelText` 翻红,后者报
「no form control was found associated to that label」,即 switch 完全失去可访问
名(正文预警的那个后果);只恢复无条件内部 label:label 计数与按文本定位的钉子
翻红,悬空检查保持绿。standalone 两个钉子在三种状态下都绿 —— 它们是回归守卫,
不是缺陷钉。

Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@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 5:16pm

Request Review

@github-actions github-actions Bot added the tests label Aug 9, 2026
@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-CSaVY2Jh.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) 482.64KB 106.39KB
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.45KB 56.31KB
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.53KB 10.71KB
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) 44.98KB 12.37KB
plugin-charts (index.js) 61.17KB 17.35KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.02KB 30.47KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 236.63KB 59.02KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.63KB 49.66KB
plugin-kanban (index.js) 48.30KB 13.28KB
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) 20.15KB 6.72KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
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。#3952 落地。

核验记录(head 45e1e6500,基 65bb513dc,实物核验 + CI 亲读):

  1. 实现与裁定一致:id 链 hostId || config?.name || generatedId(实物 grep 确认),host id 在场时抑制内部 sr-only label(emitOwnLabel = !hostId);form 侧 FormControl 按边界只读未改 —— 缺陷确为消费者丢弃已下发值(AGENTS.md #0.1 形状)。
  2. 前提实测:修前钉 7/10 红(label for 指向不存在 id、getAllByLabelText 抛「无关联控件」= 开关无可访问名),修后 11/11 绿。
  3. 反向验证超额且诚实:两次隔离变异定位各钉承重面;dev 发现自己一条测试注释的预判错误(getAllByLabelText 长度断言抓不到双 label 指一控)并当场纠正注释而非掩盖 —— 正确处置。standalone 两钉如实标注为回归护栏而非 bug 钉。
  4. 同类排查完整:16 个 widget 逐个实测分类 —— 修 BooleanField;address/geolocation 覆写、checkboxes/radio/rating/file 惰性关联 → 立 [fields] 复合/分组 field widget 丢弃或错投 host 下发的控件 id:address / geolocation 的表单 label for 悬空,checkboxes / radio / rating / file 的 for 落在不可 label 的 div 上 #3961(带实测表+两案推荐);slider 零透传 → 按 filing 纪律评论既有 29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318 而非开孪生单;text 等 7 个健康作为阳性对照。name-fallback 中间项经实测保留(console ActionParamDialog 依赖),并有钉子记录保留理由。
  5. 消费半径外扩:app-shell(ActionParamDialog×2 + paramToField)+ plugin-detail 共 66 files / 559 tests 全绿(补充而非替代规定命令)。
  6. 门与规程:规定面 100 files / 1176 tests 绿;type-check 78/78;控制字节门 + 盲区自查零命中;changeset patch 三门绿;提交文案控制词 0;⛔ releases/ 未触碰。
  7. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 17:22:15Z、Type Check 17:20:18Z、Lint 17:19:58Z;coverage/dependabot path-filter skipped 计绿)。
  8. 分支落后 main 一合(65bb51369becd2)已核:改动面单 widget + 两新文件,与其间合入的 PR3954/3956 无交集,merge queue 会以合并 ref 重算 —— 无需 update-branch。

out-of-scope 三条(#3961 / #3318 评论 / #3962)处置全部得当,由 PM 另行分诊。concern 中 widget: 'checkbox' 不可达分支的观察记入 #3439 家族参考,不另立单 —— 同意 dev 判断。


Generated by Claude Code

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

Labels

Projects

None yet

2 participants