Uh oh!
There was an error while loading. Please reload this page.
fix(fields): BooleanField 使用 host 下发的控件 id,可见 label 不再悬空 (#3952) - #3959
Merged
Conversation
表单里渲染 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_01GTRjn8xBqp75dk7kFupVRtThe latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
This was referenced Aug 9, 2026
yinlianghui
commented
Aug 9, 2026
CollaboratorAuthor
PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3952 落地。 核验记录(head
out-of-scope 三条(#3961 / #3318 评论 / #3962)处置全部得当,由 PM 另行分诊。concern 中 Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 17:45
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 9, 2026
Closed
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 10, 2026
… 的拼接 (objectstack-ai#3962) (objectstack-ai#3971) `ActionParamDialog` 的 boolean 分支渲染 `Label htmlFor={param.name}` 放在控件 旁边,却不给 widget 传 `id` —— 而同一文件下面几十行的通用分支一直传的是 `id={param.name}`。实测(objectstack-ai#3952 / PR objectstack-ai#3959 之后、真 dialog 渲染)一个 boolean param 产出两个 label 元素指向同一个控件:widget 自己那份 sr-only 的,和 dialog 的可见那份,文本相同。两个 `for` 都能解析,所以这不是 objectstack-ai#3341 / objectstack-ai#3952 那种悬空 label —— 点击一直是好的;坏的是名字:按 accname 规范,引用同一控件的多个 label 会拼接成可访问名,屏幕阅读器听到的是 "Confirm This Confirm This"。 两个缺陷,相隔一行: 1. 关联是隐式的。它成立只因为 `BooleanField` 的 id 回退链第二项落在 `config.name`,而 `paramToField` 把它设成了 `param.name` —— 两边靠另一个包的 内部实现恰好撞成同一个字符串。一个渲染了 `htmlFor` 的 host 必须自己发出它所 指的 id;widget 契约(`FieldWidgetDomProps`)声明 `id` 就是为这个。 2. 重复的 sr-only label。objectstack-ai#3952 / PR objectstack-ai#3959 让 `BooleanField` 在 host 下发 id 时 不再发自己的 label(`emitOwnLabel = !hostId`),理由正是「会下发 id 的 host 就是会渲染 label 的 host」。这个分支收不到 id,那条抑制从来没被触发。 修法是让 boolean 分支像它自己的通用分支那样传 `id={param.name}`:关联从隐式变 显式,widget 的 sr-only 副本被抑制,可访问名回到作者声明的那一份。通用分支未动。 钉子挂在两个既有测试文件上。命名一半在 `ActionParamDialog.ariaRequired.test.tsx` (4 条):boolean 控件 id 恰为 `param.name` 且 `label[for]` 恰好 1 个、活下来的 是可见那份而非 sr-only;可访问名恰为一份;必填 boolean 的名字仍不含 `aria-hidden` 的星号且带 `aria-required="true"`;以及不变方向的通用分支 host id / 单 label / 单名字。行为一半在 `ActionParamDialog.test.tsx`(1 条):点击可见 label 翻转开关并 round-trip 到 `resolve`。 刻意没有用 `getAllByLabelText` 做探针(issue 正文提议过): `@testing-library/dom` 10.4.1 的 `queryAllByLabelText` 最后一步是 `Array.from(new Set(matchingLabelledElements))`,并且对多 label 会把名字拆开 分别匹配,修前修后它都只解析出 1 个控件,当探针是空转的。可测量的那一半是名字 与 label 元素个数。 反向验证按预判成立:删掉 boolean 分支那一行 `id={param.name}` 后恰好 3 条命名钉 翻红,而「通用分支不变」与点击 label 那条保持绿 —— 修前 `for` 目标本来就能解析, 这个缺陷是重复而非悬空。变异未提交。 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#3952
缺陷
表单里渲染
boolean/checkbox字段时会发出两个同文本 label,而可见的那个指向一个不存在的 id。FormControl是 RadixSlot,它把FormLabel htmlFor已经指向的 id(_r_N_-form-item)交给控件;BooleanField却用字段名覆写掉:上面是在真表单里实测到的(修前跑本 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 机制只读不改。hostId || config.name || useId()。config.name这一项不是冗余:@object-ui/app-shell的ActionParamDialogboolean 分支渲染Label htmlFor={param.name}却不下发 id(与它自己的通用分支不同,那条是id={param.name}),全靠字段名派生的 id 成立(paramToField设name: param.name)。这条契约已单独钉住,以后想「清理成裸useId()」会直接翻红,而不是悄悄让控制台里每个 boolean action param 失联。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 个用例分三组:for都解析到存在的元素;可见 label 命名的就是 switch;label 文本在 DOM 中只出现一次;点击可见 label 后 switch 翻转;控件保留 host 的 id(且不等于字段名);两个 boolean 字段各归各的 label。ActionParamDialog契约。反向验证(先预判方向,再跑;变异不提交)
修前 7/11 红,修后 11/11 绿。另做两个隔离变异,确认每个钉子守的是哪一半:
for第一个变异里
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 未提交):forbutton[role=switch]subId('street')覆写subId('latitude')覆写只有
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自扫,零命中。@object-ui/fieldspatch(bug 修复,用户可见)。Generated by Claude Code