Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

[fields] 非 group-labelled widget 的只读替换显示丢掉 host 下发的整份 plumbing —— label 的 for 悬空、description 零消费者(email / url / phone 实测) #4788

Closed

Description

@yinlianghui

在 #4005(group-labelled 只读态 aria-describedby)的实测中量到的相邻一条,与那个 PR 无关也不由它引入:group-labelled 的七个 widget 走 toHostGroupProps,#3990 接上了名、#4005 接上了描述;非 group-labelled 但有「只读替换显示」的 widget 走的是另一条路,那条路上 host 下发的东西一件都没落地。

实测

真 form renderer + 裸注册(与 #3990 / #4005 的 probe 同一套),字段带 description: 'Some help',数「引用 …-form-item-description 这个 id 的元素个数」,并单独看 host label 的 for 解析到什么:

email    editable   descEl=SET  consumers=1  hostIdEl=input  for=RESOLVES
email    readonly   descEl=SET  consumers=0  hostIdEl=NONE   for=DANGLING
url      editable   descEl=SET  consumers=1  hostIdEl=input  for=RESOLVES
url      readonly   descEl=SET  consumers=0  hostIdEl=NONE   for=DANGLING
phone    editable   descEl=SET  consumers=1  hostIdEl=input  for=RESOLVES
phone    readonly   descEl=SET  consumers=0  hostIdEl=NONE   for=DANGLING

hostIdEl=NONE 是关键读数:只读态下 …-form-item 这个 id 整个文档里没有任何元素带。所以这条比 #3990 那族更重一档 —— 不只是「描述没人引用」,而是可见 label 的 for 指向一个不存在的 id,只读态的那个链接在无障碍树里没有名字。

机理

三个 widget 的只读分支都在 toDomProps(props) 之前提前 return,并且返回的元素上一个 prop 都不展开:

  • packages/fields/src/widgets/EmailField.tsx —— readonly 返回 mailto: 锚元素;
  • packages/fields/src/widgets/UrlField.tsx —— 返回外链锚元素(或非法 URL 时的 span 元素);
  • packages/fields/src/widgets/PhoneField.tsx —— 返回 tel: 锚元素。

可编辑分支各自 const domProps = toDomProps(props) 再铺到它渲染的 Input 上,id / aria-describedby 随之落地(上表 editable 行);只读分支拿不到其中任何一个。

与 #3990 的差别在于:这些字段不是 group-labelled,host label 保留的是普通 for,所以修法不是照搬 toHostGroupProps(那会给单控件面发一个不该有的 role="group");正确的形状要单独裁 —— 最小的一种是只读元素带上 host 的 id(for 自然解析),但锚元素 / span 元素不是 labelable 元素,for 落在上面依然是惰性 HTML,这正是 #3961 当初为复合字段换掉 for 的原因。故本条需要一次方向裁决,不要按 #4005 的做法直接推。

未实测(不要按本单推断)

  • 只量了 email / url / phone 三个。packages/fields/src/widgets/ 里有 32 个 widget 带 if (readonly) 提前 return 且不碰 toHostGroupProps(AvatarField BooleanField CodeField ColorField CurrencyField DateField DateTimeField ImageField LookupField NumberField SelectField TagsField TextField TextAreaField TimeField …),它们的只读面形态各异 —— 有的只读仍渲染真控件(那就没有这条),有的换成纯文本。接这条的人请逐个量,别按这三行外推。
  • 内建分支(BUILTIN_FIELD_TYPES = input / textarea / checkbox / switch / select)没有这个缺陷,已实测:只读态照样在表单渲染器的 FormControl 里渲染真控件,Slot 的 id / aria-describedby 正常落地,consumers=1、for=RESOLVES。

参考


备注:本单由 os-dev 在 #4005 的实施中实测发现并按 Prime Directive #10 立单(不认领、不扩围到 #4005 的 PR)。上一版正文里的 JSX 标签被 GitHub 的正文清洗吃掉了,已改写为不含尖括号的措辞。

Activity

  1. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    ContributorAuthor

    【PM 分诊】座位 repo:objectui(session_01GTRjn8xBqp75dk7kFupVRt):晋级 pm:queue —— email/url/phone 只读替换显示丢整份 host plumbing(id 无宿主、label for 悬空、链接在无障碍树无名),比 #4005 重一档,PR #4791 实测在案。

    机制方向预裁(留否决窗):不照搬 toHostGroupProps(正文判断正确:锚非 labelable、role=group 不对)—— 但 aria-labelledby/aria-describedby 是全局 ARIA 属性,link 是合法命名/描述承载:只读替换元素接 host id + aria-labelledby 指向 label(label 侧随之去 for 或保 for 并存皆量后定,单一命名通道纪律照 #3978)+ aria-describedby 指向 description;role 保持元素原生(link 等),⛔ 不发 group。这是 #3961/#3990/#4005 家族同一 IDREF 机制在非 group 面的直接延伸。派发前置:正文「未实测」段照办 —— 32 个带 readonly 提前 return 的 widget 不按三行外推,实施者先量本单三类 + 逐个扫描形态分组回传(同形一并修、异形回传)。


    Generated by Claude Code

  2. self-assigned this
    on Aug 16, 2026
  3. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    ContributorAuthor

    【认领】objectui 分片 PM(session_01GTRjn8xBqp75dk7kFupVRt)批次 11 派发,os-dev 实施。分支:claude/issue-4788-readonly-replacement-plumbing。按 2026-08-16 预裁评论的机制方向执行(全局 ARIA 属性 + host id,role 保持原生,⛔ 不发 group);先修实测在案的 email/url/phone 三个,再按正文「未实测」段逐个扫描 32 个 readonly 提前 return 的 widget 形态分组 —— 同形一并修、异形回传不外推。文件面:packages/fields/src/widgets/** + 对应测试(与在队 PR #4789 的 index.tsx/core 改动无交集)。


    Generated by Claude Code

  4. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    ContributorAuthor

    【os-dev 实测回报 —— 前提成立且更宽,但预裁的机制方向有两处被实测否决,停手待裁】

    worktree claude/issue-4788-readonly-replacement-plumbing,base 8a9deceea。probe 与 #3990 / #4005 同一套(真 form renderer + 裸注册,字段带 description: 'Some help'),跑完即删、未入仓。未提交任何实现代码。

    1. 前提成立,而且比正文更宽:32 个全部同形

    正文「有的只读仍渲染真控件(那就没有这条)」这一假设被证伪。逐个量下来,32 个非 group-labelled widget 的只读态无一例外:

                    readonly                                     editable
    text      for=DANGLING hostIdEl=NONE consumers=0   for=RESOLVES-LABELABLE hostIdEl=input    consumers=1
    number    for=DANGLING hostIdEl=NONE consumers=0   for=RESOLVES-LABELABLE hostIdEl=input    consumers=1
    boolean   for=DANGLING hostIdEl=NONE consumers=0   for=RESOLVES-LABELABLE hostIdEl=switch   consumers=1
    email     for=DANGLING hostIdEl=NONE consumers=0   for=RESOLVES-LABELABLE hostIdEl=input    consumers=1
    url       for=DANGLING hostIdEl=NONE consumers=0   for=RESOLVES-LABELABLE hostIdEl=input    consumers=1
    phone     for=DANGLING hostIdEl=NONE consumers=0   for=RESOLVES-LABELABLE hostIdEl=input    consumers=1
    …(其余 26 个只读行逐字相同)
    

    只读面形态分三类,但缺陷完全一致:锚 3 个(email / phone / url-合法)、纯文本/容器 26 个(span / div / pre / p / table)、display-only 3 个(formula / summary / auto_number / vector —— 无可编辑分支,整个 widget 就是替换显示)。

    2. 否决点一:控件通道的 host label 根本没有 id,aria-labelledby 无处可指

    预裁方向写的是「只读替换元素接 host id + aria-labelledby 指向 label」。实测 32 行全部 labelId=NONE:groupLabelId 只在 resolveFieldLabelling() === 'group' 时生成(form.tsx),控件通道的 FormLabel 只有 htmlFor,自身不发 id。所以「指向 label」的 IDREF 目标今天不存在 —— 这一半无法在 packages/fields/src/widgets/** 内实现,必须由 packages/components/.../form/form.tsx 先发布 label id(即预裁里那句「label 侧随之去 for」,但它是前置条件而不是后续动作)。

    3. 否决点二:「role 保持元素原生」对 26/32 是惰性的,而且测试层看不见这条禁令

    aria-labelledby 落在无 role 的 span / div 上命名的是 NOTHING —— generic 禁止作者名。这不是我的判断,是本仓 toHostGroupProps.ts 里 #3990 已经写下并被接受的结论(「so the pair is inert without a role that supports naming」),也正是那一族当初引入 role="group" 的唯一理由。

    更要命的是测试层判不出这件事。实测:

    span 元素 + aria-labelledby 指向 label   toHaveAccessibleName('Email')  PASSES
    a    元素 + aria-labelledby 指向 label   toHaveAccessibleName('Email')  PASSES
    a    元素 + aria-labelledby="labelId hostId"                            name = "Email user@example.com"
    queryAllByLabelText('Email') 命中数: 3(含那个 span)
    queryAllByRole('link', { name: 'Email' }): 1
    

    即:照预裁方向实施,26 个 span/div/pre 面会得到测试全绿、AT 完全惰性的幽灵修复,并且还会配一条钉子把它认证下来。这正是本仓视为一等失败的「declared but not delivered」形态(#3290 / #3222 家族),所以我没有推。

    4. 读数三:锚元素上「只指向 label」是信息回退

    email/url/phone 的只读锚今天的可访问名是内容(user@example.com)。给它 aria-labelledby 只指向 label,名字变成 Email —— 值从可访问名里消失了。复合 aria-labelledby="labelId hostId" 才得到 Email user@example.com(上表第三行实测)。这是一个需要明裁的取舍,不是实现细节。

    5. 另一条必须一起裁的:只放 host id 会掩盖现有信号

    只把 host id 放到替换元素上而 label 侧不动,for 从 DANGLING 变成 RESOLVES-但-不可-label(#3961 修的正是这个形态)。关联依旧断,但「悬空 for」这个可扫描信号没了 —— #3990 原话:「a for that resolves to nothing can at least be swept for」。所以 widget 侧单边落地比不动更坏。

    6. 选项与建议(两轴分析)

    建议 E,退而求其次 B;明确不建议 A。 两者都要维护者先裁三件事:(1) 只读控件通道的 label 是否去 for 改发 id(实测答案:必须,否则命名不可能,且单一命名通道纪律 #3978 也要求二选一);(2) 只读值显示是否接受 role="group"(预裁 ⛔ 了它,但实测 §3 表明不给 role 就等于不修);(3) 修在 host 一处(E)还是 32 个 widget(B)。

    裁完我可以一轮做完。附:上面每一行读数都可复跑,probe 已删未入仓。


    Generated by Claude Code

  5. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    ContributorAuthor

    【PM 处置 · 转决策箱】objectui 分片 PM(session_01GTRjn8xBqp75dk7kFupVRt):pm:queue → needs-user-decision。dev 零代码停手正确 —— PM 2026-08-16 预裁的机制方向被实测两点证伪(①控件通道 host label 32/32 不发 id,aria-labelledby 无 IDREF 目标,widgets 包内无法独立实现;②span/div 面 role 保持原生则命名在 ARIA 上惰性,且 jsdom 测试对此假绿 —— dev 用受控实验证明了这条幽灵修复路径),该预裁撤回。完整读数与选项分析见 dev 评论。

    三问待裁(dev 建议 A / A / E,PM 附议):

    1. 只读态 host label:去 for 改发 id(A,单一命名通道纪律)vs 双通道并存(B);
    2. 只读值面是否接受 role="group"(A,与 [fields] 只读 / 零选项的 group-labelled 选项 widget 提前 return,groupDomProps 没被展开 —— host label 的 IDREF 没有消费者,标签仍然什么都不命名(#3961 的残留,checkboxes 今天的 main 上就这样) #3990 七复合面同一主张、同一 helper;PM 原 ⛔ 基于错误前提撤回)vs 坚持原生(B,已被实测排除:测试绿而 AT 惰性)vs term/definition(C,语义主张更大);
    3. 机制落点:form.tsx host 一处包容器(E,覆盖 32 现存 + 任意第三方 widget,零「忘记 spread」入口;代价是每个只读注册字段多一层 DOM)vs 32 widget 各自 spread helper(B,代价是永远靠人记住的约定)。

    范围补充:D 组 display-only 四件(Formula/Summary/AutoNumber/Vector)同缺陷,任何裁决应算进同一批;C 组两件不可达已另行处置(#4811)。裁定后一轮可做完 —— 形态分组、probe 方法、实施路径已备。


    Generated by Claude Code

  6. os-zhuang commented on Aug 16, 2026

    @os-zhuang
    Contributor

    Maintainer ruling (2026-08-16, recorded by PM session session_01NYgmGheCzM6NrHZN436Cxf). The maintainer accepted the evening decision-inbox report's recommendations in full, verbatim: 「8931 我已经处理了,其他接受你的所有建议。」 The dev + PM recommendations (A / A / E) are ratified on all three questions:

    1. Readonly-state host label drops for and emits an id — the single-naming-channel discipline (fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) #3978); without a label id there is no IDREF target, which the measurement proved is the precondition, not a follow-up.
    2. role="group" is accepted on readonly value surfaces — the same semantic claim [fields] 只读 / 零选项的 group-labelled 选项 widget 提前 return,groupDomProps 没被展开 —— host label 的 IDREF 没有消费者,标签仍然什么都不命名(#3961 的残留,checkboxes 今天的 main 上就这样) #3990 already established for seven composite surfaces, and the measurement showed that "keep the native role" on span/div faces yields naming that is inert to AT while tests stay green (the ghost-fix path is rejected on the record).
    3. Mechanism lands in form.tsx, host-side, one wrapper (Option E): when readonly resolves to a registered field widget, the host wraps the widget output in a container carrying id + aria-labelledby (composite "labelId hostId" on anchor faces so the value stays in the accessible name) + aria-describedby + role="group", with aria-invalid stripped per the [fields] 只读态的 group-labelled 字段:description 的 IDREF 同样没有消费者(#3990 修的是「名」,这条是「描述」) #4005 boundary. All 32 current widgets and any future third-party widget are correct by construction — no "remember to spread" entry point. The extra DOM layer per readonly field is an accepted cost.

    Scope: the D-group display-only four (Formula / Summary / AutoNumber / Vector) are in the same batch, per the disposition. The earlier pre-ruling in comment 5306507639 stands withdrawn (falsified by measurement), as recorded.

    Label flipped needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  7. self-assigned this
    on Aug 16, 2026
  8. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    ContributorAuthor

    【认领】objectui 分片 PM(session_01GTRjn8xBqp75dk7kFupVRt)批次 12 派发,os-dev 实施。分支:claude/issue-4788-readonly-host-wrapper。

    按 2026-08-16 维护者裁定(A / A / E,评论 5307576062)执行:host label 去 for 改发 id;只读值面接受 role="group";机制落 packages/components/.../form/form.tsx host 一处包容器(id + aria-labelledby,锚面复合 "labelId hostId" 保值 + aria-describedby + role="group",aria-invalid 按 #4005 边界剥离);D 组 display-only 四件(Formula/Summary/AutoNumber/Vector)同批。

    文件面:packages/components/src/renderers/form/form.tsx + 对应测试与钉子;⛔ packages/fields/src/widgets/** 零改动(E 方案的结构卖点,验收按此对账)。⚠️ 在飞互斥声明:#4824+#4832(同批次另一 dev)也要动 form.tsx 的内置 FullscreenTextarea 段,已给对方挂「本 PR 落 main 后才动 form.tsx」前置门。


    Generated by Claude Code

  9. yinlianghui commented on Aug 16, 2026

    @yinlianghui
    ContributorAuthor

    【os-dev 实施完成 —— draft PR #4862】分支 claude/issue-4788-readonly-host-wrapper,base 1ef236e18。

    按 2026-08-16 维护者裁定(A / A / E,评论 5307576062)三条逐条落实:只读态 host label 去 for 改发 id;只读值面 role="group";机制落 packages/components/src/renderers/form/form.tsx 一处包容器,aria-invalid 按 #4005 边界剥离。packages/fields/src/widgets/ 下零改动(验收可按此对账:PR 改动文件共 4 个 —— form.tsx、两个新测试、一个 changeset)。

    前提复核:成立,读数与测量评论一致

    在 origin/main 1ef236e18 上复跑同一 probe。34 个注册的非 group-labelled 类型(含 D 组四件)只读行全部 for=DANGLING labelId=NONE hostIdEl=NONE consumers=0;修复后全部变为 for=NO-FOR labelId=SET hostIdEl=div[group] consumers=1 ariaInvalid=(absent),可访问名形如 Label email user@example.com。36 行 editable 读数改动前后 diff 为空。

    一处与测量评论表述的细微出入,如实记录:评论里的「32 个」是按 widget 源文件数,我按注册类型枚举得 34 个非 group-labelled 类型(markdown/html/richtext 共用 RichTextField、user/owner 共用 UserField、lookup/master_detail 共用 LookupField);另外 textarea / select 的裸拼写是 BUILTIN_FIELD_TYPES 成员,不查注册表,只读态照旧渲染真控件(实测 consumers=1),所以测的是它们的 field: 限定拼写。缺陷结论不变。

    抽样(裁定要求的四类)

    • email(锚面):group 名 Label email user@example.com;锚自身保持 role=link、名字仍来自内容 user@example.com,未被加第二个 aria-labelledby;
    • text(纯文本面):role="group" + 复合命名,不是无 role span 的惰性对;
    • boolean:只读面不再有 switch,组名含值;
    • formula(D 组):无可编辑分支,同一 host 门覆盖,toHaveAccessibleDescription('Some help') 绿。

    钉子测试按要求写的是容器归属(每个 IDREF 用 getElementById 解析、断言解析到的节点是正确 form item 内的正确元素),不是单靠 toHaveAccessibleName。

    反向验证(方向先判后跑)

    预判红(非反转),逐条点名 8 + 112 条应红、5 + 4 条应绿;摘掉包容器逻辑后实跑 Tests 120 failed | 9 passed (129),8 条名字与预判完全相同。git checkout 还原,未用 stash。

    验证读数

    vitest run packages/components packages/fields packages/plugin-form → 284 files / 3362 tests 全绿;turbo run type-check(components + fields)→ 12/12 successful;check-control-bytes OK;三个 changeset 门 OK。

    范围外(已另立单,未塞进本 PR)

    #4857 —— 七个 widget(formula / summary / auto_number / vector / grid / slider / signature)在可编辑态也丢 host id,for 同样悬空。本裁定的 host 门是 readonly,而 packages/plugin-form/src/ObjectForm.tsx:687 把计算型字段映射成 disabled 而非 readonly,所以 D 组在真实 ObjectForm 里仍绕开本 PR 的容器。该条需要一次独立的方向裁决(改映射 / 加 display-only 声明 / widget 侧展开),不宜按本单外推。


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions