Uh oh!
There was an error while loading. Please reload this page.
fix(fields): group-labelled widget 的只读/零选项分支也消费 host label 的 IDREF (#3990) - #4002
Merged
Merged
Conversation
…3990) 七个 `labelling: 'group'` widget 只在可编辑分支消费 host 递下来的 `aria-labelledby` + host id;字段级 `readonly: true` 与零可选项两种状态走的是 提前 return,渲染在那个容器之前,于是 label 发布了 id 却没有任何元素引用它。 实测(改前的 main,真 form renderer + 裸注册,逐字段数引用该 id 的元素): multiselect readonly+value consumers=0 byLabelText=0 named=0 multiselect readonly+empty consumers=0 byLabelText=0 named=0 multiselect zeroOptions consumers=0 byLabelText=0 named=0 multiselect editable consumers=1 byLabelText=1 named=1 七类在每个只读态上都是同一读数,改后都是 1。不是 #3961/#3975 的回归:那之前 同样这些状态发的是指向无人携带 id 的 `for`,一样什么都不命名 —— 形状从悬空 `for` 变成无人消费的 IDREF,严重程度未变。 每个只读面现在只带两个「整字段」键(host `id` 与 `aria-labelledby`)加上让它们 生效的 `role="group"`,统一走新的 `toHostGroupProps`。窄化是刻意的:只读面没有 可聚焦控件,`aria-describedby` / `aria-required` / `disabled` / 焦点回调在那里 无人宣读,而 `div` 上的 `name` 正是 #3291 扫的泄漏。 `radio`(可编辑 `radiogroup`)与 `file`(可编辑 `button`,即 dropzone)在只读态 答的是 `group`,因为那两个面里一个 radio、一个 dropzone 都不剩;共享的 「无可选项」框对 checkboxes / radio / multiselect 同样答 `group`,单选 `select` 不属于 group-labelled,继续什么都不发。 standalone(网格内联编辑器、裸 SDUI 节点)不递这两个键,输出保持逐字节不变, 包括 `EmptyValue` 占位符本身。
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。#3990 落地 —— group-labelled a11y 家族(#3961→#3975→#3990)三态全收口。 核验记录(head
concerns 采信:单成员 group 语义偏弱但是唯一不撒谎的可命名角色(已注释);「只递 id 的第三方 host」新增行为面方向正确。out-of-scope #4005(只读态 describedby 无消费者,Blocked-by 本单;首次噪声对照行已自行作废 —— 证据卫生好)由 PM 分诊(另评)。本单合入后 #3986(select multiple 路径)解锁可派。 Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 22:53
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 9, 2026
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 17, 2026
…EF (objectstack-ai#4005) (objectstack-ai#4791) objectstack-ai#3990 / PR objectstack-ai#4002 让七个 group-labelled widget 的只读面消费了 host label 的 IDREF(名),并**刻意**停在那里 —— 当时的理由写在 `toHostGroupProps` 的注释 里:只读面没有可聚焦控件,而 `aria-describedby` 是「焦点落到可聚焦元素上时 宣读」的通道。那个理由在 objectstack-ai#4002 落地的同一刻就过期了:它本身给这些面发了 `role="group"`,而 group 在 ARIA 1.2 里**是**合法的描述承载者(进入 group 即 宣读其名与描述,不要求里面有可聚焦控件)。于是可见的帮助文本在只读态继续 「发了 id 没人引用」,恰好持续到有地方可放它为止。 实测(`origin/main` c252227,真 form renderer + 裸注册,数引用 `…-form-item-description` 的元素个数),七类全量、含正文列为「未实测」的 四类: ``` descEl consumers consumerTags address editable desc=YES SET 1 [input] address readonly desc=YES SET 0 [] geolocation editable desc=YES SET 1 [input] geolocation readonly desc=YES SET 0 [] checkboxes editable desc=YES SET 1 [div[group]] checkboxes readonly desc=YES SET 0 [] radio editable desc=YES SET 1 [div[radiogroup]] radio readonly desc=YES SET 0 [] rating editable desc=YES SET 1 [div[group]] rating readonly desc=YES SET 0 [] file editable desc=YES SET 1 [div[button]] file readonly desc=YES SET 0 [] multiselect editable desc=YES SET 1 [div[group]] multiselect readonly desc=YES SET 0 [] ``` 四类未量 widget 的只读面**都**照常渲染 FormDescription,无一因形态特殊而豁免。 改动:`toHostGroupProps` 两键扩三键,加 `aria-describedby`。`aria-invalid` / `aria-required` **不**回铺 —— 它们是控件通道状态(报告使用者的编辑可能哪里 出错,报给他要编辑的那个元素),只读面不可编辑,铺回去等于宣读一个背后没有 动作的状态,正是 objectstack-ai#3291 / objectstack-ai#3318 划的线。该边界双向钉死。 第三个键**不是**无条件的:helper 新增一个必填的 `surface` 判别参数,让每个 调用点(以及下一个 widget 作者)必须说明自己是哪种面,由编译器强制,而不是 继承一个恰好在那里的默认值: - `'instead-of-the-inputs'` —— 七个只读分支 + 零选项框(`OptionsEmptyState`)。 该面根本不渲染字段自己的输入框,没有别的元素能承载描述。 - `'above-the-inputs'` —— address / geolocation 的**可编辑**容器。它们的子输入 各自已在 `domProps` 展开里拿到 `aria-describedby`,容器不得再拿一次。 第二类是本改动最容易造成的回归:这两个 widget 的可编辑容器与只读面共用同一个 helper,一个无条件发 `aria-describedby` 的版本会让帮助文本被宣读两次 —— objectstack-ai#3318 明确拒绝过的双通道,`composite-group-label-e2e.test.tsx` 早有钉子。 钉子(`composite-group-label-readonly-e2e.test.tsx`):七类只读态消费计数 0 → 1 且落在与名同一个元素上、空值占位面同判、零选项框同判、一张表单里七个 字段各自的描述都恰好一个消费者;边界双向 —— 只读面同时断言三键在场与 `aria-invalid`/`aria-required`/`name` 不在场(缺了正向断言,一个什么都不发的 实现也能通过三条 not),并加测 required 字段、以及**真实**失败校验之后 (实测可达:只读 required 空值提交确实产出 FormMessage)仍无 aria-invalid; 可编辑态不回归(七类消费计数恒为 1,address/geolocation 容器仍无 `aria-describedby` 且消费者仍是容器内的 input);standalone 仍一个属性都不发。 无描述时的形态如实钉住而非粉饰:FormControl 的 Slot 无条件下发描述 IDREF, 所以没有 `description` 的字段今天就已经让**每个**可编辑控件指向一个不存在的 元素(实测 desc=NO:七类 + 内建 input + email 各一处)。只读面照抄这一点是与 可编辑态**对齐**,不是新增一类缺陷(解析不到的 IDREF 对辅助技术是惰性的, 而没人消费的描述是直接丢失)。 正文声明作废的内建分支对照行已重量:`input`/`textarea`/`checkbox`/`switch`/ `select` 只读态照样在 FormControl 里渲染真控件,`consumers=1`,**没有**本缺陷。 正文那行读数量的是 `type: 'text'` —— 它既不在 BUILTIN_FIELD_TYPES 里也未注册, 那个字段根本没渲染出控件,故为噪声(正文已自行作废,此处为重量结论)。 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#3990
结论
七个
labelling: 'group'widget 只在可编辑分支消费 host 递下来的aria-labelledby+ hostid。字段级readonly: true与零可选项两种状态走的是提前 return,渲染在那个容器之前 —— 于是 form renderer 让 label 发布了自己的
id、撤掉了for,而文档里没有任何元素引用这个 id。可见标签是 0 个元素的可访问名。不是 #3961 / #3975 的回归:改那两单之前,同样这些状态发的是指向「无人携带该 id」的
for,一样什么都不命名。形状从悬空for变成无人消费的 IDREF,严重程度未变 ——是那次修法没有覆盖到的状态(#3961 的钉子全部在可编辑态取样)。
逐 widget 实测(正文警告「写法各异不可推断」,所以七类逐个量)
一次性 probe(未提交,已删),真 form renderer + 裸注册
labelling: 'group',逐字段数「引用 host label 已发布 id 的元素个数 / byLabelText / 被命名的 role」:
即:七类在每个只读态上读数完全一致(全 0),并非只有正文实测过的
multiselect / checkboxes;可编辑态与整表单
mode: 'view'两列改前改后都是 1(view不走 widget 的 readonly 分支,与正文一致)。
处置(沿既定 group 机制)
新增
packages/fields/src/widgets/toHostGroupProps.ts:只读面统一只带两个「整字段」键 —— host
id与aria-labelledby—— 外加让它们生效的role="group",一处拼写。窄化是刻意的,不是偷懒:只读面没有可聚焦控件,
aria-describedby/aria-required/disabled/ 焦点回调在那里无人宣读;而name落到 div 上正是#3291 在扫的泄漏;把整份
toDomProps铺下去还会用 host 的className盖掉 widget自己的(
EmptyValue/ FileField / AddressField 的className都留在props里)。role必须随行:role-less 的 div / span 上aria-labelledby命名的是 nothing(
generic禁止 author name),这正是本单量到的形状。逐 widget 落点:
EmptyValue本身OptionsEmptyState框EmptyValue本身EmptyValue本身EmptyValue本身EmptyValue本身两处 role 与可编辑态故意不同,因为渲染的根本不是同一个面:
radio可编辑答radiogroup,只读面里一个 radio 都不剩;file可编辑答button(dropzone),只读面里没有 dropzone,只有文件名。共享的「无可选项」框对 checkboxes / radio /
multiselect 答
group;单选select不属于 group-labelled(label 仍是可用的for),该框对它一个属性都不多发 —— 这条有阳性对照钉。
顺带把 AddressField / GeolocationField 的可编辑容器也换成同一个
toHostGroupProps(它们此前手写
id+ 条件 role +aria-labelledby,语义与输出完全等价),这样同一文件里只读面与可编辑面不可能对同一个问题给出两种答案。
无缺口/豁免
没有豁免项:七类的每个只读渲染路径都找到了现成的承载元素,没有新增任何 DOM 节点。
一处如实记录的取舍:hosted 且空值时
EmptyValue就是整块只读面,于是它自己带上这三个属性,它原有的
aria-label(“No value”)按 accname 被aria-labelledby压过 ——这是想要的结果:在它此前的
genericrole 上 author name 本就被禁止、从未暴露,所以真实选择是「字段名」还是「没有名字」。standalone 时该占位符不带 role,行为不变。
钉子(64 个,新文件
composite-group-label-readonly-e2e.test.tsx)getAllByRole('group', { name })前 0 / 后 1,且byLabelText同元素;rating 无空态);
id以-form-item结尾)、label 无for、字段内无悬空或 inert 的for、只读面不带name/aria-invalid;OptionsEmptyState框本身(testId 断言);以及 readonly 与零选项同时成立时 readonly 分支胜出且仍被命名;
group)与只读角色 group,任何「统一角色」的后续改动都必须来这里说明它改了什么;
SelectField零选项框无 role / 无 IDREF / 无 id(共享组件新增 prop 的阳性对照)。
反向验证(先预判再跑,变异未提交)
只读钉 + 「七类同表」+ 漂移钉 = 6 条,不动 EmptyValue / 零选项 / standalone。
实测:
Tests 6 failed | 58 passed,失败项与预判逐条一致。OptionsEmptyState的hostGroupProps。预判只红「checkboxes: the unfillable-options box is the named group」1 条。
实测:
Tests 1 failed | 63 passed。两次都是经典方向(撤掉即翻红),没有出现计数型或反转型的情况 —— 因为这些钉断言的是
「被命名的元素个数从 0 到 1」这个谓词,而不是某个下游计数。
验证
pnpm --workspace-concurrency=2 --filter '@object-ui/fields^...' build→ 通过(先行,新 worktree 的依赖产物)。
pnpm exec vitest run packages/fields --maxWorkers=2→Test Files 73 passed (73),Tests 1160 passed (1160)。pnpm exec vitest run packages/components/src/renderers/form --maxWorkers=2→
Test Files 33 passed (33),Tests 182 passed (182)(group-labelling 是 form renderer与 widget 的联合机制,声明位那侧一并跑过)。
pnpm exec turbo run type-check --concurrency=2→Tasks: 78 successful, 78 total。node scripts/check-control-bytes.mjs→ OK(3902 个跟踪文本文件);另对本 PR 触碰的文件做了越界自扫(
grep -naP控制字符类)→ 无命中。_前缀丢弃变量与any)。changeset:
@object-ui/fieldspatch。边界:只动
packages/fields/src/widgets/**+ 测试 + changeset;未碰 form.tsx / index.tsx的声明集合(机制已定型),未碰
content/docs/releases/。Generated by Claude Code