Skip to content

fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) - #3978

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3961-composite-group-label
Aug 9, 2026
Merged

fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961)#3978
yinlianghui merged 1 commit into
mainfrom
claude/issue-3961-composite-group-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3961

复合/分组 field widget 的可见组标签,今天是零个元素的可访问名。按 PM 的范围裁决(issue 评论 5233281956)采 B 案一次做完:widget 声明 labelling,form 渲染器按声明改用 IDREF 关联。

缺陷:两种形态,同一个结局

真表单 + 每字段一行,读每个 label 的 for 与它解析到的元素(probe 未提交)。接线前:

address      for=…-form-item -> MISSING            byLabelText=0   byRole+name=0
geolocation  for=…-form-item -> MISSING            byLabelText=0   byRole+name=0
checkboxes   for=…-form-item -> div                byLabelText=0   byRole+name=0
radio        for=…-form-item -> div[radiogroup]    byLabelText=0   byRole+name=0
rating       for=…-form-item -> div                byLabelText=0   byRole+name=0
file         for=…-form-item -> div[role=button]   byLabelText=0   byRole+name=0

所以两种形态的结局是同一个:右边两列都是 0。这比 issue 正文对 B 类「比 A 类轻」的描述更严格。

接线后(同一 probe,同一形状):

address      for=(none) ownId=…-group-label   byLabelText=1(div[role=group])      byRole+name=[group:1]
geolocation  for=(none) ownId=…-group-label   byLabelText=1(div[role=group])      byRole+name=[group:1]
checkboxes   for=(none) ownId=…-group-label   byLabelText=1(div[role=group])      byRole+name=[group:1]
radio        for=(none) ownId=…-group-label   byLabelText=1(div[role=radiogroup]) byRole+name=[radiogroup:1]
rating       for=(none) ownId=…-group-label   byLabelText=1(div[role=group])      byRole+name=[group:1]
file         for=(none) ownId=…-group-label   byLabelText=1(div[role=button])     byRole+name=[button:1]
text/image/location/tags/color/code/qrcode/user  ← 逐字不变(`for` 仍指向真控件)

改动

packages/core —— 声明位。 ComponentMetalabelling?: 'control' | 'group',可选、增量,缺省即 'control'(所有现存组件今天的行为)。'group' 的含义写在注释里:渲染出的外层不是可 label 元素 —— 既包括真复合(一个容器下多个输入),也包括「唯一控件恰好不是可 label 元素」(file 的 div[role=button] dropzone)。这是声明而非猜测:host 无法从 widget 恰好渲染出的 DOM 推断它。

packages/components —— form 渲染器按声明分支。 group 字段的 FormLabelid、不发 htmlFor;widget 收到 aria-labelledby。单控件路径一个属性都没变(两处都是条件展开,连 key 都不进 props),所以没有任何字段多出第二条命名通道 —— #3290 / #3222 / #3952 反复钉的「一个事实一个作者」。⛔ ui/form.tsx(Shadcn no-touch)零触碰:FormLabel 在自己的 htmlFor 之后才展开 props,所以「给 label 一个 id」「拿掉它的 for」两半都能以普通 prop 传入。

resolveFieldLabelling 逐字镜像 renderFieldComponent 的解析:builtin 判定用原始 type(裸 select 走内建分支、根本不查注册表;field:select 才解析到注册 widget),之后才剥 field: 前缀。否则一个 widget 的声明会去改一个并不由它渲染的内建控件的 label。

packages/fields —— 六个 widget。FIELD_TYPES_SKIP_FALLBACK 并列的 FIELD_TYPES_GROUP_LABELLED 声明这六个。逐 widget 处置(按 issue 评论 5233264043 的方案表):

widget 处置 代码改动
address 只把 host idaria-labelledby 从首个子输入移到组容器,补 role="group"
geolocation 同上(latitude 保留 subId)
checkboxes 现有包裹 div 补 role="group"(名字已随 groupDomProps 到达)
rating 同上
radio 保留 Radix 的 radiogroup(更具体的角色,且是 aria-invalid 的正确承载者) (仅注释)
file 不硬套 group:唯一控件是 dropzone,它自己接 aria-labelledby (仅注释)

widget props 契约零新 key:aria-* 已声明在契约上(React AriaAttributes),两个 strip 都不碰前缀,toDomProps 按前缀转发整族 —— 与 aria-required(#3290)同一条通道。

刻意不改的四件事

  1. 子标签仍命名自己的输入。 aria-labelledby覆盖控件的 label[for],所以把组名放到首个子输入上会把「Street Address」替换成字段名 —— issue 否掉选项 2 的那个「可访问名拼接」结局。
  2. aria-describedby 留在首个可聚焦子输入(29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318 的刻意选择):描述/错误必须在焦点落到可聚焦控件时播报,组容器不可聚焦。只挪了 id,没整块搬。
  3. AddressField / GeolocationField 子输入使用硬编码固定 id(street/city/latitude…)——同表单出现两个该类型字段即产生重复 DOM id #3343 的子输入 id 不动。
  4. standalone 逐字不变(inline grid editor、裸 SDUI 节点:没人下发 id、也没有 host label 可指):不发 role、不发 IDREF。role 键在 host 真的给了名字时才出现 —— 无名的 group 对辅助技术没有信息量。

验证

  • vitest run packages/core packages/components packages/fields251 files / 3573 tests 全绿
  • 消费半径(渲染这六个 widget 的其它 host):vitest run packages/app-shell packages/plugin-form packages/plugin-grid packages/plugin-detail packages/react496 files / 4632 passed / 1 skipped,0 失败
  • turbo run type-check → 78/78 successful;node scripts/check-control-bytes.mjs OK。
  • 新增钉子:fields 侧 e2e 36 例(每个 widget 两方向 + 组名不吞子名 + standalone 不回退)、声明位 25 例、components 侧 form 分支 9 例。

反向验证(先预判再跑,变异均已还原):

  1. 撤掉 rating 的 role="group" → 预判 rating 的组名断言翻红、standalone 断言保持绿 → 实测 4 红 32 绿,standalone 行保持绿。
  2. 把 address held-back 的两个键放回街道输入 → 预判组名断言 + 「子标签保持自己的名字」翻红 → 实测 5 红,其中 sub-inputs keep their own labels 的红因来自 RTL 的 getLabels:元素带 aria-labelledby 时它优先用 IDREF 作为该元素的 label 列表,于是「Street Address」不再命名街道框 —— 正是这一改动要避免的可访问名覆盖。⚠️ 同时实测到:这个方向不会被「无悬空 for」那条不变量捕获(form 侧仍不发 for),所以那条断言单独不足以覆盖,两组断言都必须在。
  3. 「不声明会响」演示:e2e 里让 checkboxes 漏声明 → 预判组名 3 条 + label 关联不变量翻红 → 实测 5 红,含 "labelable": false 的 diff —— 漏声明的复合 widget 会被 [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 那类「label 必须解析到真控件」的钉子当场抓住,而不是静默降级。
  4. 真实声明集合里删掉 checkboxes → 预判声明位钉两条翻红 → 实测 expected undefined to be 'group' + 集合断言两条红。

顺带发现(均已另立,不在本 PR 修)

把 probe 扩到 18 个字段类型跑了一遍,发现三条超出本单裁定六个 widget的同族账:


Generated by Claude Code

…of an inert `for` (#3961)

Six widgets rendered a visible group label that was the accessible name of
NOTHING. Two shapes, one outcome: `address` / `geolocation` replaced the
host's id with their own sub-input ids (#3343), so the label's `for` named an
id no element carried; `checkboxes` / `radio` / `rating` / `file` kept the id
but on a `div`, where `label for` is inert HTML (`HTMLLabelElement.control` is
null — it activates nothing and contributes no name).

The WAI-ARIA group pattern, driven by a declaration rather than by the host
guessing at widget DOM:

- core: `ComponentMeta.labelling?: 'control' | 'group'`. Optional and
  additive; absent means `'control'`.
- components: the form renderer branches on it. A `'group'` field's
  `FormLabel` publishes an `id` and drops its `for`; the widget receives
  `aria-labelledby`. The single-control path emits not one changed attribute
  (conditional spreads), so no field gains a second naming channel.
  `ui/form.tsx` is untouched — `FormLabel` spreads props after its own
  `htmlFor`, so both halves travel as ordinary props.
- fields: the six audited widgets declare `labelling: 'group'`. address /
  geolocation move only the host id to the group container; checkboxes /
  rating answer with `role="group"`; radio keeps Radix's more specific
  `radiogroup`; file takes the name on its dropzone with no invented group
  layer (one control, merely not a labelable element).

No new key in the widget props contract: `aria-*` is already declared on it
and forwarded by `toDomProps`, the channel `aria-required` (#3290) uses.

Unchanged on purpose: sub-labels keep naming their own inputs (an
`aria-labelledby` on the first sub-input would OVERRIDE its own label),
`aria-describedby` stays on the first focusable sub-input (#3318), the
sub-input ids of #3343 do not move, and standalone rendering emits no role
and no IDREF.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 9, 2026 7:46pm

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-B2p3T6Di.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 483.34KB 106.60KB
core (index.js) 3.00KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 227.92KB 56.45KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.53KB 10.71KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 61.49KB 17.48KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.39KB 30.67KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 237.03KB 59.17KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.29KB 27.16KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.90KB 49.79KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.73KB 26.55KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 22.31KB 7.59KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 2.71KB 1.34KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3961 按 B 案裁决落地。

核验记录(head a02ad8567,基 62c644168,实物核验 + CI 亲读):

  1. B 案完整落地(实物核验 13 文件全在裁决面):ComponentMeta.labelling 声明位、FIELD_TYPES_GROUP_LABELLED 审计集合、form 侧 resolveFieldLabelling 逐字镜像渲染解析(裸 select 走内建分支不受 widget 声明影响 —— 有钉);ui/form.tsx 零触碰确认(diff 不含该文件),单控件路径零改动(条件展开连 key 都不进 props)。
  2. 逐 widget 处置与前次报告方案表一致:address/geolocation 只挪 id、describedby 留首个子输入(29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318 的刻意选择被尊重);radio 保留 radiogroup、file 不硬套 group(零代码仅注释,注释防「清理」误删展开点 —— 合理)。
  3. 「不声明会响」从承诺变断言:双演示(e2e 漏声明 5 红含 labelable:false diff + 真实集合删项 2 红)+ components 侧正向钉。这正是 B 案胜过 A 案的核心论证的实测化。
  4. 反向验证三次 + 机制深挖:变异 2 的红因追到 @testing-library/dom getLabels 的 aria-labelledby 优先机制(RTL 报错文案有误导,已在 PR 写明真实机制);同一变异下另一组断言不被捕获的方向如实记录并保留双组 —— 处置正确。
  5. 命名裁定:'group' 字面对 file 场景的不精确已由 dev 诚实备注 —— 维持现名:core 注释已把语义写成「渲染外层不可 label ⇒ host 以 IDREF 关联」,改名收益不抵二次 churn;唯一读点/产点已记录,未来若扩声明面再议。
  6. 门与规程:强制面 3573 测试绿 + 消费半径 4633 绿(#5046 教训覆盖);type-check 78/78;控制字节门 + 未 tracked 新文件门外自扫;changeset core minor(增量 API)+ components/fields patch 论证成立;fable 0(trailer 去型号名处置正确);又一次无 token curl 假绿自查并弃用 —— 纪律执行到位。
  7. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 19:52:35Z、Type Check 19:52:45Z)。基落后 main 数合:改动面与其间合入的 PR3969/3970 无交集,交合并队列重算,无需 update-branch。

衍生:#3975(multiselect 同形 XS,刻意不并入 —— 保持已验证面干净,合入后即派)、#3976(内建 select 分支,同文件串行)、#3318 附着评论 —— 三条处置均得当。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 19:56
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit c3b01a7 Aug 9, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3961-composite-group-label branch August 9, 2026 19:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants