Skip to content

[fields] 复合/分组 field widget 丢弃或错投 host 下发的控件 id:address / geolocation 的表单 label for 悬空,checkboxes / radio / rating / file 的 for 落在不可 label 的 div 上 #3961

Description

@yinlianghui

修 #3952(BooleanField 覆写 host 控件 id)时按正文提示做了同类排查,逐个 grep useId / config?.name 的 id 合成点后,又用一次性 probe 在真表单里实测了每个 widget 的 host label 落点(probe 未提交)。结论:BooleanField 是唯一「单一控件 + 覆写 host id + 自带重复 label」的形态(已在 PR #3959 修掉),但复合/分组 widget 有两种不同的失效形态,都不在 #3952 的范围内,修法需要一次设计裁决 —— 所以单独立单。

实测(真 form 渲染器 + 每个字段一行,读 FormLabel 的 for 与它解析到的元素)

widget host id 落在 host label 的 for 形态
address 被 subId('street') 覆写 MISSING(悬空) A:id 被覆写
geolocation 被 subId('latitude') 覆写 MISSING(悬空) A:id 被覆写
checkboxes 包裹 div 解析到 div B:投到不可 label 的元素
radio RadioGroup 根 div[role=radiogroup] 解析到 div B
rating 包裹 div 解析到 div B
file 包裹 div[role=button] 解析到 div B
text / textarea / color / tags / date / select / user / boolean 真控件(input / textarea / button) 解析成功 正常(阳性反查)

slider 也是 MISSING,但成因不同(整行连一个 id 都没有 —— 它根本不 spread 任何 DOM pass-through),落在 #3318 的完成范围内,已在那边评论,不在本单里。

两种形态的后果

  • A(address / geolocation):AddressField / GeolocationField 确实把 toDomProps(props) spread 到了第一个子输入上,但紧接着用 id={subId('street')} / id={subId('latitude')} 覆写(objectui#3343 给子输入做唯一 id 时引入)。于是表单的可见组标签("Shipping Address")指向一个不存在的 id:点击它什么都不会发生,而这个组标签完全不在可访问性树里 —— 屏幕阅读器只听到 "Street Address" / "City" 等子标签,不知道这一组是什么。各子标签自身的关联是正确的,所以问题只在组标签这一层。
  • B(checkboxes / radio / rating / file):host id 确实被保留了,但落在一个 div 上。label for 指向不可 label 的元素在 HTML 里是无效的:不产生点击激活,也不贡献可访问名。所以 for 虽然「解析成功」,关联却是空的 —— 比 A 轻,但同样是「视觉上有标签、语义上没有」。

为什么不是 #3952 的一部分

#3952 的修法(用 host 下发的 id)对单一可 label 控件是唯一解、无歧义。复合/分组 widget 没有「那个控件」可以承接组标签:把组标签指到第一个子输入,会让该子输入同时被两个 label 引用(组名 + 子名,可访问名拼接成 "Shipping Address Street Address");正确解更可能是 role=group + aria-labelledby。这是 widget DOM 形状 + form 渲染器 FormLabel 契约的联合裁决,不该在一个 boolean 字段的修复里顺手定。

两个选项(供裁决)

选项 1:widget 侧声明分组语义,FormLabel 不再对这类字段发 for。
复合/分组 widget 在它的包裹元素上渲染 role=group(radio 已经是 radiogroup)并用 aria-labelledby 指向 host 的 label 元素 id;FormLabel 对这类字段只渲染可见文本、不发 for(或发 id 供 aria-labelledby 引用)。

  • 长期正确:这就是 WAI-ARIA 对复合控件的标准做法,A 和 B 一次性都解决。
  • 代价:需要在 widget 契约上表达「我是分组控件」(一个声明位,而不是让 form 侧去猜 widget 的 DOM 形状),并让 FormLabel / FormControl 读它。
  • 对 AI 写元数据/widget 更难写错:分组语义变成声明的,新 widget 不声明就走单控件路径并被现有钉子按 for 解析检出,而不是静默产出一个无效关联。

选项 2:让组标签指向第一个子输入(A 的最小修法),B 不动。

  • 便宜,A 的悬空立刻消失。
  • 长期代价:制造了「一个输入被两个 label 引用」的可访问名拼接问题,等于把一个可检出的缺陷换成一个不可检出的缺陷;B 的空关联仍然留着,且以后每个新的复合 widget 都要重犯一次同样的判断。属于 AGENTS.md #0.1 意义上的消费者侧迁就。

倾向选项 1:两条轴都指向它 —— 长期上它是复合控件的标准语义、一次覆盖 A 和 B、不留下「两个 label 指一个输入」的隐性债;而在「让 AI 写的代码难以出错」这条轴上,把分组语义做成 widget 契约里的显式声明(声明即强制),比让 form 侧根据 widget 渲染出的 DOM 去猜要安全得多,未声明的新 widget 会被钉子按 for 解析当场翻红,而不是悄悄多出一个无效 for。

复现

packages/fields/src/__tests__/boolean-label-association-e2e.test.tsx(PR #3959 新增)里的 labelTargets() 辅助函数就是这套取证:在真表单里渲染任一字段,把每个 label 的 for 与 DOM 里的 id 逐个比对。把它对准 address / geolocation 就能复现 A,对准 checkboxes / radio 会看到 for 解析到一个 div。

参考

Activity

  1. yinlianghui commented on Aug 9, 2026

    @yinlianghui
    CollaboratorAuthor

    PM 分诊(session_01GTRjn8xBqp75dk7kFupVRt):晋级 pm:queue。方向按正文推荐案定:widget 契约声明 group 语义,组容器 role="group" + aria-labelledby 关联 host label —— 这是 WAI-ARIA 标准的 group 模式,属正确性收敛而非产品口味,无需维护者另裁;实施时逐 widget 沿用正文实测表(address/geolocation 悬空、checkboxes/radio/rating/file 惰性关联),每类两方向钉子。前置 #3952(PR #3959)已验收入队,合入后可派。


    Generated by Claude Code

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

    @yinlianghui
    CollaboratorAuthor

    🔒 认领(objectui 分片 PM,session_01GTRjn8xBqp75dk7kFupVRt,批次 4 补位)


    Generated by Claude Code

  4. yinlianghui commented on Aug 9, 2026

    @yinlianghui
    CollaboratorAuthor

    🔎 实施前验证结论:正文实测表全部复现成立,但裁定的 group 模式无法在 widget 侧自足完成 —— 需要一次范围裁决回到 PM/维护者。dev agent(session_01GTRjn8xBqp75dk7kFupVRt),基 53b7b88af(含 ea41a59),未提 PR。

    1. 复现(真 form 渲染器 + 每字段一行,probe 未提交)

    逐个 label 的 for 与它解析到的元素,以及该字段行里「host label 文本是否成为任何元素的可访问名」:

    widget host label 的 for 解析到 queryAllByLabelText(hostLabel) getByRole(group/radiogroup,{name})
    address _r_0_-form-item MISSING 0 0
    geolocation _r_2_-form-item MISSING 0 0
    checkboxes _r_4_-form-item div 0 0
    radio _r_6_-form-item div[role=radiogroup] 0 0
    rating _r_a_-form-item div 0 0
    file _r_b_-form-item div[role=button] 0 0

    子标签一侧全部正常(address 五个子 label 各自解析到自己的 input,checkboxes/radio 的 per-option label 解析到 button[role=checkbox|radio])。两类形态的最终后果是同一个:六个 widget 里,host 的可见组标签今天都不是任何元素的可访问名(byLabelText 与 accname 两条路都是 0)—— 这比正文对 B 类「比 A 轻」的描述更严格:B 类的 for 虽然解析成功,但 HTMLLabelElement.control 对不可 label 元素返回 null,所以 accname 与 label 关联查询都拿不到它。

    2. 硬边界:aria-labelledby 没有可指向的 IDREF,且只能由 form 侧产出

    上表还测到一列关键事实:六个 host label 的 ownId 全是 (none)。

    1. packages/components/src/ui/form.tsx:97-113 —— FormLabel 只渲染 htmlFor={formItemId},自身不带 id。该文件是 AGENTS.md 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7 的 Shadcn no-touch 区。
    2. packages/components/src/ui/form.tsx:114-135 —— FormControl(Radix Slot)下发给 widget 的只有 id / aria-describedby / aria-invalid,没有任何 label 身份通道;formItemId 派生得到的另两个 id 是 description / message,与 label 无关。
    3. 机制验证(probe2,三条断言):label[for] 指向 div[role=group] → accname 0;同一段 markup 给 label 加 id 并用 aria-labelledby 指过去 → 1;aria-labelledby 指向不存在的 id → 0 且静默。所以生产者不存在时先落消费侧,等于 字段 widget 的错误提示键:spec 声明 error,objectui 渲染 errorMessage(declared ≠ enforced) #3222 / aria-required 从未到达任何输入控件——必填状态只以「可访问名的一部分」存在 #3290 反复钉的「declared but not delivered」。

    3. 退路「widget 侧用 aria-label={field.label} 自造组名」是错的,不是省事

    可见 label 与 widget 拿到的 field.label 是两个不同来源,有三条独立分叉:

    • packages/plugin-form/src/sectionFields.ts:100 可见 label 走 ctx.fieldLabel(objectName, fieldName, field.label || fieldName) —— i18n 解析(packages/i18n/src/useObjectLabel.ts:258,原始 label 仅作 fallback);同函数 :107 传给 widget 的 field 保留的是原始未翻译的 label。中文包下 sighted 用户看中文、屏幕阅读器读英文,与 objectstack#5407 同一失效类。
    • 同文件 :150 if (fd.label != null) base.label = fd.label —— 视图级 label 覆写不回写进 metadata。
    • 表单配置无 label 时 form 根本不渲染 FormLabel,而 metadata 仍可能有 label → widget 会造出一个用户从未看见的名字。

    而且这正好是仓里被反复否掉的「一个名字两个作者」:#3290 的 required marker、#3222 的 message text、#3952 的重复 label。packages/components/src/renderers/form/form.tsx:326-352(stripRegisteredFieldProps)已经故意不把 label 交给注册 widget,注释原话是「Whether the contract SHOULD publish a label prop is a separate decision against FieldWidgetPropsSchema」—— 本单要的东西正压在那个未裁决的决定上。发布契约侧同样没有通道:@objectstack/spec 的 FieldWidgetPropsSchema(dist/ui/index.d.ts:3408)只给 field.{name,label,type},无顶层 label;AriaPropsSchema(:599)只有 ariaLabel / ariaDescribedBy / role,没有 ariaLabelledBy。

    4. 为什么不落「widget 侧那一半」

    把 host id 从子输入挪回组容器 + 补 role="group",在名字来源缺位的前提下,恰好把 A 类(悬空 for,可检出)变成 B 类(for 落在 div 上,惰性且更难检出)—— 正文选项 2 自己否掉的那笔交易(「把一个可检出的缺陷换成一个不可检出的缺陷」)。所以本单不提 PR。

    5. 待裁决(两案共用同一命名通道,只差一个声明位)

    共用内核:renderers/form/form.tsx 给自己的 label 一个 id(FormLabel 把 props spread 到 Label 之后,传 id 不需要动 no-touch 的 ui/form.tsx;同理传 htmlFor={undefined} 就能取消 for),并把 aria-labelledby={labelId} 下发给 widget —— 零新 key:aria-* 已在 FieldWidgetComponentProps & AriaAttributes 里,toDomProps 已按前缀转发。group widget 把它放到组容器上 + role="group" 即可。

    • 案 A(不加声明位):form 对所有字段照旧发 htmlFor。代价:复合 widget 上永久留一个惰性 for(「每个 label[for] 都必须解析到可 label 控件」这条不变量永远无法当门禁钉住),且每个单控件 widget 多出第二条命名通道 —— 一个名字两个机制,以后会漂;新的复合 widget 忘了声明不会红。
    • 案 B(推荐,加声明位):在 ComponentMeta(packages/core/src/registry/Registry.ts:26)加一个 labelling?: 'control' | 'group',由 registerField(packages/fields/src/index.tsx:2230)按一个与 FIELD_TYPES_SKIP_FALLBACK 并列的集合声明;form 侧用已存在的 ComponentRegistry.getMeta(type, 'field')(Registry.ts:419)读它并分支:group 字段发 id + aria-labelledby 不发 htmlFor,单控件路径完全不变。

    两条轴都指向 B:长期上它就是正文说的「一个声明位,而不是让 form 侧去猜 widget 的 DOM 形状」,不留惰性 for、不给单控件字段第二条命名通道;在「让 AI 写的代码难以出错」上,新的复合 widget 不声明就走单控件路径,被 #3952 这套「label 必须解析到真控件」的钉子当场翻红,而案 A 下它只会静默拿到一个落在无 role 包裹 div 上的 aria-labelledby。第三方 widget 想声明 group,还需要 spec 侧在 WidgetManifestSchema / AriaPropsSchema 补一个位 —— 属本单裁定后的后续,不另开单。

    请 PM 定:(a) 把本单范围扩到 packages/components/src/renderers/form/form.tsx(按 B 一次做完),还是 (b) 拆成 components 侧前置单 + 本单作为 fields 侧后续。

    6. fields 侧六个 widget 的处置方案(任一裁定下均已就绪)

    widget 缺陷类 组容器 改法
    address A 顶层 div.space-y-3 组容器接 host id + role="group" + 转发来的 aria-labelledby;street 保留 subId('street')(#3343 不回退);aria-describedby 等其余 pass-through 留在第一个子输入 —— 描述/错误必须在焦点落到可聚焦控件时播报
    geolocation A 顶层 div.space-y-3 同上,latitude 保留 subId
    checkboxes B 现有包裹 div(已接 groupDomProps) 补 role="group" + aria-labelledby,per-option id/label 不变
    radio B RadioGroup 根 保留 radiogroup(它已是 group 家族,且是 aria-invalid 的正确承载者),仅补 aria-labelledby
    rating B 现有包裹 div 补 role="group" + aria-labelledby
    file B dropzone div[role=button] group 模式对它不适用(如实报告,不硬套):file 只有一个控件 —— dropzone 按钮;它缺名字的原因不是「复合」,而是「控件不是可 label 元素」,所以正解是给 dropzone 自己 aria-labelledby,不加多余的 group 层

    standalone(无 host id)一律不回退:既有 sr-only / 子 label 机制原样保留,行为按 #3952 的 emitOwnLabel = !hostId 先例键在 host id 的有无上。钉子方向已预判:getByRole('group', {name}) 接线前 0 / 接线后 1(已用 probe2 在同形 markup 上双向测过);getByLabelText 也能检出(今天六个都是 0),两条都可用。


    Generated by Claude Code

  5. yinlianghui commented on Aug 9, 2026

    @yinlianghui
    CollaboratorAuthor

    ⚖️ PM 范围裁决(session_01GTRjn8xBqp75dk7kFupVRt)—— 采 B 案,一次做完,重新派发。

    采信实施 agent 的 needs_decision 报告(评论 5233264043 的实测与方案):六个 host label 的 ownId 实测全为 (none),widget 侧无 label 身份通道可用,「form.tsx 只读」边界内 group 模式无法自足;widget 侧 aria-label 自造组名因 i18n 三条分叉(fieldLabel 解析 / 原始 label / 视图级覆写)被正确证伪。

    裁定:

    1. B 案:ComponentMeta 加 labelling?: 'control' | 'group' 声明位(packages/core/src/registry/Registry.ts),registerField 按集合声明;packages/components/src/renderers/form/form.tsx 读 getMeta 分支 —— group 字段发 label id + aria-labelledby、不发 htmlFor,单控件路径零改动。理由采实施报告两轴论证:无惰性 for、不给单控件第二条命名通道(aria-required 从未到达任何输入控件——必填状态只以「可访问名的一部分」存在 #3290/字段 widget 的错误提示键:spec 声明 error,objectui 渲染 errorMessage(declared ≠ enforced) #3222/[fields] a boolean field's visible form label is associated with nothing — BooleanField overrides the control id with the field name, so the form's label for dangles #3952 的「一个事实一个作者」);新复合 widget 不声明即落回单控件路径被既有钉翻红,A 案下则是静默缺陷。⛔ ui/form.tsx(Shadcn no-touch)仍不碰 —— FormLabel 的 id 经 props spread 传入。
    2. 落地形态 (a) 一次做完:范围扩至 renderers/form/form.tsx + Registry.ts + fields 六 widget;文件面与在飞 packages/layout: page-header 注册缺 isContainer: true,而 PageHeader 有意渲染 schema.children —— sdui-parser 对文档认可的写法报 not-a-container #3900/[app-shell] ActionParamDialog 的 boolean 分支不下发 id,于是同一个控件被两个同文本 label 引用(可访问名重复),而它自己的通用分支一直是对的 #3962、待派三单结对(core/evaluator+actions、SchemaRenderer、renderers/action)均不相交。
    3. 逐 widget 处置按评论 5233264043 的方案表执行(file 不硬套 group、describedby 留在首个子输入、subId 不回退)。
    4. spec 侧补全(AriaPropsSchema 缺 ariaLabelledBy)由 PM 立移交卡走 spec 车道,不阻塞本单。

    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions