Uh oh!
There was an error while loading. Please reload this page.
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
|
yinlianghui
commented
Aug 16, 2026
【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 |
Uh oh!
There was an error while loading. Please reload this page.
…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>
…bjectstack-ai#4832) (objectstack-ai#4864) * fix(components,fields): the fullscreen long-text dialog announces validation state and names its control (objectstack-ai#4824, objectstack-ai#4832) The dialog's editor is built from scratch by the host, so none of the wiring the inline control gets from the form renderer reached it. Measured with the field genuinely invalid: the rich-text dialog announced aria-invalid="false" while the same field's inline control announced true, the textarea dialog carried no such attribute at all, and every dialog control had an empty accessible name. FullscreenEditor now takes the field's `error` (required) and owns the answer: it renders the message in a dialog-local node and hands `children` a required fourth argument carrying aria-labelledby (the dialog title's text), aria-invalid and a single-IDREF aria-errormessage. The host spreads it and never learns an id, so it cannot name a node outside the dialog -- the forbidden shortcut of pointing at the host's FormMessage, which Radix aria-hidden's for as long as the dialog is open. TextAreaField and RichTextField are migrated here; the form renderer's built-in textarea branch follows in this branch's next commit. Co-authored-by: Claude <noreply@anthropic.com> * test(components): pin the built-in textarea dialog's validation state and name (objectstack-ai#4824, objectstack-ai#4832) The third of the three surfaces the maintainer's 2026-08-16 ruling put in one batch. Its implementation is NOT in this commit: the form.tsx edit is gated behind in-flight objectstack-ai#4788, which is rewriting the same file's readonly path, and racing it on one file is what the gate exists to prevent. So this file is RED on purpose, and it is the acceptance criterion for the remaining work rather than a claim that the work is done. It is also the only one of the three that can be measured against a REAL FormMessage -- this branch renders inside FormControl with the form's own message node as a sibling -- so it carries the assertion the other two cannot: the id the dialog control names is not the form item's message id, and the node it resolves to is inside the dialog. Measured on this commit: 7 failed | 4 passed. The four that pass are the ones that do not depend on the missing plumbing. Co-authored-by: Claude <noreply@anthropic.com> * fix(components): the built-in textarea's fullscreen dialog announces validation state and names its control (objectstack-ai#4824, objectstack-ai#4832) The third and last of the three surfaces the 2026-08-16 ruling put in one batch. Its gate cleared: objectstack-ai#4788, which was rewriting this file's readonly path, landed on main as PR objectstack-ai#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> --------- 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