`ColorVariantPicker` 的 `div role="radiogroup"` 既无 `aria-label` 也无
`aria-labelledby`:组内每个色块各自有颜色名("Blue"),组本身完全匿名 ——
焦点进组只知道有八个颜色,不知道改的是哪个字段,旁边那行可见文本无人归属。
`DashboardWidgetInspector` 还多坏一档。它的 `Field` 包装器渲染
`Label htmlFor={id}` 并约定被包控件带同一个 id,而 picker 根本不收 id,于是
`htmlFor="widget-color"` 是**悬空 IDREF** —— 工具看到 `for` 就认为命名闭合,
指向的却是没有任何元素持有的 id,比「没有标签」更难发现。
`for` 修不了这个:`role="radiogroup"` 是容器不是 labelable 元素,`for` 指过去
是惰性 HTML,谁也命名不了(`form.tsx` 在 group-labelled 分支正是为此把 `for`
整个去掉)。改为 ARIA 命名,且在类型层强制、单一通道 —— `ariaLabelledBy` 与
`ariaLabel` 二选一,无名的颜色组不再可编译(#3997 给 `InspectorComboField`
定下的同一契约)。
三个调用点逐处核过,形态不同,处置也不同:
* `DashboardWidgetInspector`(悬空 `for` + 匿名组):`Field` 新增
`labelling="group"`,该分支只发 `id={groupLabelId(id)}` 不发 `htmlFor`,组用
`aria-labelledby` 应答。可见文本即可访问名,zh-CN 无需第二个待翻译串。
* `PageBlockInspector`(**无** `for`,仅匿名组):那个 `<Label>` 从未声明过关联,
没有悬空可修;同样的匿名组用同样的 IDREF 修好。抽成独立组件是为了用
`React.useId()` 逐实例铸 id —— `renderField` 是在循环里跑的函数,既不能调
hook,也会让按字段名派生的 id 在数组行之间相撞。
* `widgets.tsx` 的 `color-picker`(悬空 `for` + 匿名组,但宿主侧才修得了):
`MetadataField` 在得知该字段渲染的是色块组还是可 label 的
`input type="color"` **之前**就写好了 `Label htmlFor={id}`(分支由 widget 自己
按 schema/fieldSpec 决定),宿主因此发不出可供应答的 IDREF。此处按本文件既有
惯例自带 `aria-label`(文本取宿主自己的首选 label 源,与可见文本一致);宿主
那个仍然解析不到的 `for` 需要 per-widget `labelling` 声明,已另开单,不在此
PR 扩围,并由测试留下路标。
反向验证方向在跑之前已预判:两处 IDREF 通道任一断开 → 对应钉子红且指名,
其余绿。测得结果记在 PR 正文。
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #4010
ColorVariantPicker的色块行渲染在一个div role="radiogroup"里。每个色块早就有自己的颜色名("Blue"、"Success"),组本身一个名都没有 —— 焦点进组读到的是匿名 radiogroup:八个颜色,不知道改的是哪个字段;旁边那行可见文本无人归属。DashboardWidgetInspector还多坏一档:它的Field包装器渲染Label htmlFor={id}并约定被包控件带同一个 id,而 picker 根本不收 id,于是htmlFor="widget-color"是悬空 IDREF —— 静态检查看到for存在就以为命名闭合了,实际指向一个没有任何元素持有的 id。先复核前提(行号已漂移,结论成立)
分诊席的锚点是 08-10 的读数,此后 main 多次合并。按
origin/main@65e88e6c2逐条重测:Field包装器 ~:447Field id="widget-color":377color-variant-field.tsx:43-48props 无 id/aria 通道PageBlockInspector.tsx:438widgets.tsx:1790实测读数(真组件渲染,探针逐站点数)
改前(
origin/main@65e88e6c2):改后:
修法:ARIA 组命名,类型层强制且单一通道
for修不了这个:role="radiogroup"是容器不是 labelable 元素,for指过去是惰性 HTML,谁也命名不了。这正是本仓把 group-labelled field widget 迁到 WAI-ARIA group pattern 的理由(#3961 → #3990),也是form.tsx在该分支把for整个去掉的理由 —— 它的注释写得很直白:留着就是「一个 label 两条关联通道、其中一条是坏的」。所以 picker 改为通过 ARIA 拿名字,
ariaLabelledBy与ariaLabel二选一、且必选,无名的颜色组不再可编译(与 #3997 给InspectorComboField定下的同一契约)。刻意没有id变体:把for指向的 id 塞给容器只会让 IDREF「解析得到」而依然什么都不命名 —— 那是本单要消除的形状,不是要搬家的形状。三个调用点逐处审计:同形同修,异形不外推
DashboardWidgetInspector(悬空for+ 匿名组,两半齐全):Field新增labelling="group",该分支只发id={groupLabelId(id)}不发htmlFor;组用aria-labelledby应答。可见文本即可访问名,zh-CN 下无需第二个待翻译串。另外五个 field 逐字节不变。PageBlockInspector(异形:只有匿名组这一半):那个 label 从未带过for,没有悬空可修 —— 它只是无归属文本。匿名组这一半与 A 完全同形,用同样的 IDREF 修好。抽成独立组件ColorPropField是为了用React.useId()逐实例铸 id:renderField是在循环里跑的函数(会带keyPrefix递归进数组项),既不能在里面调 hook,按字段名派生的 id 也会在两行之间相撞 —— 相撞是静默的,两个 label 都解析到第一个组。widgets.tsx的color-picker(半异形:悬空for这一半只有宿主侧修得了):MetadataField在得知该字段渲染的是色块组、还是可 label 的input type="color"之前就写好了Label htmlFor={id},而这个分支是 widget 自己按schema/fieldSpec决定的。宿主因此发不出可供应答的 IDREF。匿名组这一半按本文件既有惯例自带aria-label(文本取宿主自己的首选 label 源,与可见文本一致,免得踩 WCAG 2.5.3);宿主那句仍解析不到的for需要 per-widgetlabelling声明,已另开 [app-shell] metadata-admin 的 SchemaForm 无条件给每个 widget 发Label htmlFor={id},但 17 个注册 widget 里只有 5 个消费 id —— 分组类控件面的for悬空且组无可访问名 #4871,本 PR 不扩围,并在测试里留了路标断言。顺带核到的第四处
widget-color:plugin-designer/src/DashboardEditor.tsx:337是原生select id="widget-color"—— 可 label 元素,关联正确,无缺陷,未动。反向验证(方向在跑之前预判,变异未提交)
两次都是普通 RED,且失败点指名:
ariaLabelledBy)→ 预判 A/B 的命名钉子红、C 绿、for那半的钉子绿。实测 9 红 20 绿,失败信息expected null to be 'widget-color-label';SchemaForm.colorPicker.labelling.test.tsx整文件绿(它走aria-label通道),leaves no for…/introduces no for…也绿 —— 它们守的是另一个事实。for放回去(Field的 group 分支同时发htmlFor)→ 预判只有 A 红。实测 2 红 27 绿,expected [ { text: 'Color Variant', … } ] to deeply equal [],B/C 全绿。两半由不同断言分别把守,是刻意的:变异 1 下
for那半仍然绿(它没被破坏),不会因为「什么都没产出」而假绿。测试
vitest run packages/app-shell/src/views/metadata-admin(消费半径全量):171 文件 / 1708 通过、1 skipped。pnpm --filter @object-ui/app-shell type-check(含tsconfig.test.json,即@ts-expect-error真的被编译):通过。中途它抓到我自己写错的一处 —— 联合类型不兼容报在元素上而非那个属性上,写在属性行的指令成了Unused '@ts-expect-error' directive并让编译失败,已改正并在注释里写明原因,这也反证了这些指令是有载荷的。turbo run type-check --concurrency=2:81/81 成功。node scripts/check-control-bytes.mjs:OK;改动文件另做了grep -naP自查,无控制字节。widgets.tsx/color-variant-field.tsx既有的any与 fast-refresh warning,均非本次新增)。changeset:
.changeset/color-variant-picker-radiogroup-name.md(app-shell patch)。Generated by Claude Code