fix(components): 只读注册字段的替换显示接上 host 的命名与描述通道 (#4788) - #4862
Conversation
…ibed host container (#4788) A registered field widget's readonly branch renders a replacement display and returns before its DOM pass-through, so nothing the form renderer handed down reached an element. Measured on origin/main at 1ef236e with a real form, one field per row, description set: the host control id was on NO element in the document, so the visible label's `for` dangled and the readonly surface had no accessible name at all, while the rendered help text had zero consumers. All 34 registered non-group-labelled types read identically, the four display-only ones (formula / summary / auto_number / vector) included. Per the maintainer's 2026-08-16 ruling (A / A / E), the mechanism lands once in the host: when a readonly field resolves to a registered field widget, the form renderer wraps its output in a container carrying the host id, `role="group"`, a composite `aria-labelledby` (label id + own id, so the VALUE stays in the accessible name) and `aria-describedby`, and the label publishes an id in place of its `for`. `aria-invalid` is dropped at that boundary — control-channel state has no meaning on a surface the reader cannot edit (#3291 / #3318 / #4005). No widget file changed. That is the structural claim: there is no "remember to spread the host props" entry point left for the next widget author to miss, and the measurement showed why a widget-side fix was the wrong shape — an `aria-labelledby` on a role-less span names nothing while jsdom's `toHaveAccessibleName()` passes it. Builtin types, editable fields, group-labelled widgets and label-less fields are untouched, byte for byte. Co-authored-by: Claude <noreply@anthropic.com>
) The first spelling of "the widget output sits INSIDE that group" asserted non-empty `textContent`, and measured RED on exactly two of the 36 types: `image` and `signature` render an `img` whose value lives in its `alt`, so their text is empty while their accessible names read `Label image a.png` and `Label signature Signature`. The corrected assertion is the stronger one and the one this issue is actually about: the group's accessible name must extend past the label, which is what the composite `aria-labelledby="labelId hostId"` exists to guarantee. It holds for every face — text, anchor, placeholder copy, and the two image ones. Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
|
【PM 验收 · ACCEPT】objectui 分片 PM(session_01GTRjn8xBqp75dk7kFupVRt)对 #4788 裁定(A/A/E,评论 5307576062)的实施验收: 实物核验:4 文件对账相符 —— form.tsx(+204/-12,机制段与裁定逐条对上:gate 反向验证:预判先写死(神经化 wrapper、只留 label-id 半),8+112 红、5+4 绿,实测 EXACT match 含逐测试名与第三条失败形态。probe 前后:34 个注册非 group 类型 + D 组四件全部 记账:「32 → 34」为枚举口径差(源文件 vs 注册类型),前提实质不变;image/signature 断言自纠(41c3add)是诚实读数;风险五条(DOM 层 data-slot 定位符、只读行间距对齐 editable 的视觉变更、复合名对大面统一施加、第三方 widget 假想缺口、group-labelled 排除)已在 changeset/PR 声明,均为裁定接受的代价。新 finding #4857(七 widget editable 态同族悬空 + D 组经 处置:undraft + auto-merge(SQUASH)。落 main 后 #4824 dev 的 form.tsx 前置门解锁。 Generated by Claude Code |
…validation state and names its control (#4824, #4832) The third and last of the three surfaces the 2026-08-16 ruling put in one batch. Its gate cleared: #4788, which was rewriting this file's readonly path, landed on main as PR #4862 and is merged into this branch, so this edit no longer races it -- and the two edits touch different regions of form.tsx. `error` is read off the PRE-strip props and forwarded by name, exactly the way `label` and `mobile_fullscreen` already are: stripRendererOnlyProps discards it for the DOM, correctly, because the INLINE control takes its aria-invalid from FormControl's Slot. The dialog's control reaches no Slot -- it is built from scratch here -- which is why it was announcing nothing at all while the inline control announced true for the same field. The primitive decides what to do with it; this branch only spreads the fourth children argument onto the control it renders, so it cannot name the aria-hidden FormMessage outside the modal. form-fullscreen-textarea-dialog-aria.test.tsx: 7 failed | 4 passed -> 11 passed. Whole tree: 287 files / 3400 tests pass; type-check 12/12. Co-authored-by: Claude <noreply@anthropic.com>
Fixes #4788
按 2026-08-16 维护者裁定(A / A / E,评论 5307576062)实施:只读态 host label 去
for改发 id;只读值面接受role="group";机制落packages/components/src/renderers/form/form.tsx一处包容器。packages/fields/src/widgets/下零改动 —— 这是 E 方案的结构卖点。缺陷(复跑的实测读数)
注册 field widget 的只读分支渲染的是「替换显示」(mailto 锚、格式化 span、chip 行、预览表),并且在自己的 DOM pass-through 之前提前 return,所以 host 下发的东西一件都没落到元素上。在
origin/main1ef236e18上复跑测量评论的 probe(真 form renderer + 裸注册,每字段带description: 'Some help',一行一个字段):hostIdEl=NONE是关键读数:只读态下…-form-item这个 id 整个文档里没有任何元素带,所以可见 label 的for悬空、只读面在无障碍树里没有名字,同时渲染出来的帮助文本零消费者。D 组四件(formula / summary / auto_number / vector)没有可编辑分支,整个 widget 就是替换显示,两列读数相同。修复后同一 probe 的只读列(36 行,逐字相同):
36 行 editable 读数改动前后 diff 为空。
修法
form renderer 在「readonly + 有 label + 非 group-labelled + 解析到注册 field widget」时,把 widget 的渲染结果包进一个容器,同时 label 发 id 去
for。FormControl是 Radix Slot,于是它原本注给 widget 根元素的id/aria-describedby/aria-invalid改注到这一层,widget 自己渲染的标记一个字节都没变。容器上:
role="group"—— 与 [fields] 只读 / 零选项的 group-labelled 选项 widget 提前 return,groupDomProps没被展开 —— host label 的 IDREF 没有消费者,标签仍然什么都不命名(#3961 的残留,checkboxes 今天的 main 上就这样) #3990 为七个复合面确立的同一语义主张。没有它,aria-labelledby落在无 role 的 span / div 上命名的是 NOTHING(generic禁止作者名);aria-labelledby是复合的"labelId hostId"—— label 的 id 加容器自身的 id。group不是 name-from-content 角色,自引用是把值留在可访问名里的唯一办法:email 面读作Email user@example.com而不是丢掉屏幕上唯一那行内容(accname §2F:经aria-labelledby到达的节点无论什么 role 都按内容取名);aria-describedby原样转发。ARIA 1.2 下 group 是描述载体,不需要可聚焦控件 —— 即 [fields] 只读态的 group-labelled 字段:description 的 IDREF 同样没有消费者(#3990 修的是「名」,这条是「描述」) #4005 的同一条理由用在第二个面上;aria-invalid剥掉。它是控件通道状态,报告使用者自己的编辑可能错在哪;只读显示不可编辑,也不可能被阅读它的人弄成非法。这正是 字段 widget 把渲染器专用 props 原样展开到 DOM——schema="[object Object]"出现在真实输入框上 #3291 / 29 个注册 field widget 在校验失败后从不携带aria-invalid—— #3306 全注册表守卫实测的账本 #3318 / [fields] 只读态的 group-labelled 字段:description 的 IDREF 同样没有消费者(#3990 修的是「名」,这条是「描述」) #4005 划的那条线,测试从两侧钉住。aria-required根本到不了这一层 —— 它照旧随 widget props 走,理由相同。为什么落 host 而不是 33 个 widget
测量评论用受控实验否掉了 widget 侧的写法:
aria-labelledby落在无 role 的 span / div 上在 ARIA 上惰性,而 jsdom 里toHaveAccessibleName()对这份标记答 PASS。照那条路走会得到 26 个惰性面 + 一条把它们认证下来的钉子测试 —— 本仓视为一等失败的「declared but not delivered」。落在 host 一处之后,「下一个作者忘了 spread」这个入口不存在了,当下 34 个和将来任意第三方 widget 一律正确。也因此本 PR 的断言全部写成 DOM 归属:每个 IDREF 都用
getElementById解析,再断言解析到的节点是正确 form item 内的正确元素,而不是靠toHaveAccessibleName—— 它分不出真关联和惰性关联。未改变的路径(逐条钉住)
内建类型(input / textarea / checkbox / switch / select)、可编辑字段、group-labelled widget、无 label 渲染的字段:逐字节不变。
两个需要说清的后果
data-slot="readonly-field-group"作为稳定定位器(ADR-0054 C4)。一处如实记录的测试自我修正
readonly-host-plumbing-e2e.test.tsx里「widget 输出在容器内」这条,第一版断言写的是textContent非空,对image/signature变红:它们的只读面渲染的是 img,值在alt里,文本为空。断言换成了更强也更贴题的一条 —— 容器的可访问名必须超出 label 本身(Label image a.png/Label signature Signature),这正是复合命名要保证的事。读数在先,断言在后。验证(全部实跑)
新增两个测试文件:
packages/components/src/renderers/form/__tests__/form-readonly-host-group.test.tsx—— host 机制钉子(合成 widget:锚面 / 纯文本面 / display-only 面 / group-labelled 面 / 裸名 SDUI 组件),含容器归属、控件通道边界、五条「什么都没变」;packages/fields/src/__tests__/readonly-host-plumbing-e2e.test.tsx—— 34 个真 widget 全量扫(每个三条),加 email / text / boolean / formula 四类抽样,再加四条可编辑态回归。反向验证(方向先判后跑,预判写在跑之前)
先 commit,再把包容器逻辑临时摘掉(
withReadonlyHostGroup恒等返回,label 发 id 那半故意留着,所以唯一被删的就是容器);git checkout对着文件路径还原,未用 stash。预判:方向是红,不是反转 —— 断言的是一个被实验删掉的容器的存在与归属;而「什么都没变」那组断言的是该容器在未受影响路径上的不存在,所以必须保持绿。逐条点名 8 条应红、5 条应绿,并预测 fields 侧 112 红 / 4 绿。
实跑,与预判逐条一致:
保持绿的 9 条正是「什么都没变」那 5 条 + fields 侧 4 条可编辑态回归。
范围外
for悬空、description 零消费者(formula / summary / auto_number / vector / grid / slider / signature 实测) #4857:七个 widget(formula / summary / auto_number / vector / grid / slider / signature)在可编辑态也丢 host id,for同样悬空 —— readonly 门管不到,且真实 ObjectForm 把计算型字段映射成disabled而非readonly。已另立单,未在本 PR 修。packages/fields/src/widgets/任何源码。content/docs/releases/**。Generated by Claude Code