Skip to content

fix(fields): name the multiselect chip group by IDREF instead of an inert for (#3975) - #3983

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

fix(fields): name the multiselect chip group by IDREF instead of an inert for (#3975)#3983
yinlianghui merged 1 commit into
mainfrom
claude/issue-3975-multiselect-group-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3975

这是 #3961 的余账,不是它的回归

#3961 的 probe 覆盖了六个 widget 并修好;在已经带着 #3978 修复的同一棵树上把 probe 扩到整张 widget 表重跑,multiselect 落在与 checkboxes 逐字相同的失效形态上:

checkboxes   for=(none) ownId=…-group-label   byLabelText=1(div[role=group])   ← #3961 已修
multiselect  for=…-form-item ownId=(none)     byLabelText=0                    ← 本单

MultiSelectField 把 host 的 id 留在 chip 行的包裹 div 上,于是 host label 的 for 「解析成功」却指向一个 div —— HTML 里 label 的 for 指向不可 label 的元素是惰性的(HTMLLabelElement.control 返回 null:既不产生点击激活,也不贡献可访问名)。视觉上有「Tags」标签,屏幕阅读器只听到 "Alpha" / "Beta",不知道这一组是什么。

修法:没有新机制,只是把 #3961 的声明用到第七个 widget

  • packages/fields/src/index.tsxFIELD_TYPES_GROUP_LABELLEDmultiselect —— 于是 form renderer 发布 label 的 id、撤掉那个死掉的 for;
  • MultiSelectField 的 chip 行在(且仅在)收到 aria-labelledby 时应答 role="group",判据与 CheckboxesField 同一条件式。

aria-* 早已在 widget props 契约里声明并由 toDomProps 转发(aria-required #3290 用的就是这条通道),所以契约没有新增 key。

钉子(两个方向,而且两个文件是互补的,不是重叠)

反向验证先预判后跑,两次变异均未提交:

变异 group-labelling-declaration.test.ts composite-group-label-e2e.test.tsx
撤掉集合里的 multiselect (该类型的用例 + 「恰好是审计集合」断言) 绿
撤掉容器的 role 绿 (4 条用例)

e2e 文件在自己的 beforeAll 里用 labelling: 'group' 硬编码裸注册(刻意避开 React.lazy,见 AGENTS.md 测试纪律),所以它结构上看不见真实声明集合 —— 这正是那两个文件必须同时存在的原因,也是「撤掉集合项 → 钉翻红」这一朴素预期在此处不成立的地方(它翻红的是声明那一文件)。补充确认:另有一条一次性 probe(未提交)走真实 registerAllFields() 惰性注册路径,在两次变异下都翻红,合起来说明真实注册路径也确实通了。

新增的钉:表单内 getByRole('group', { name: host label }) 接线前 0 / 后 1(并入 HOSTED 表,连带拿到它已有的四条方向性断言)、chip 各自保名不被 group 名吞掉、以及 standalone 不回退(chip 行仍是无 rolediv)。

刻意不变的部分

  • 每个 chip 的可访问名仍来自它自己的文本内容;group 名在上一层,不覆盖它(往 chip 上挂 aria-labelledby顶掉它自己的名字,这正是本系列在 address 的 street 输入框上拒绝过的那种覆盖);
  • standalone 渲染(grid 的行内单元格编辑器、裸 SDUI 节点 —— 没人下发 id,也没有 host label 可指)不发 role、不发 IDREF,与之前逐字一致。

顺手实测到、但不在本 PR 范围的一条

field:select + multiple: true 会委派给 MultiSelectField,而 select 必须保持未声明(单选是可 label 的),所以按字段类型键入的声明碰不到它。实测(本 PR 之后):for=…-form-item 解析到 div,byRoleGroupNamed=0,byLabelText=0 —— 同一失效类的另一条路径。已另立 issue,不夹带。

验证

  • pnpm exec vitest run packages/fields packages/components/src/renderers/form --maxWorkers=2 → 103 files / 1253 tests 全绿
  • turbo run type-check(fields + components)→ 11 successful, 11 total
  • node scripts/check-control-bytes.mjs OK;改动文件另做了一次超出该 gate 扫描面的自查

Generated by Claude Code

…nert `for` (#3975)

The residual of #3961, not a regression of it. That issue's probe audited six
widgets; re-running the same probe over the whole widget map afterwards — on the
tree that already carried #3961's fix — put `multiselect` on the byte-identical
failure shape as `checkboxes`:

    checkboxes   for=(none) ownId=…-group-label   byLabelText=1(div[role=group])
    multiselect  for=…-form-item ownId=(none)     byLabelText=0

`MultiSelectField` keeps the host's id on the chip row's wrapper `div`, so the
host label's `for` "resolved" to a `div` — where `label for` is inert HTML
(`HTMLLabelElement.control` is null: it activates nothing and contributes no
accessible name). Visually a "Tags" label; a screen reader heard only
"Alpha" / "Beta" and nothing about what the set was for.

No new mechanism — #3961's declaration, applied to one more widget:

- `FIELD_TYPES_GROUP_LABELLED` gains `multiselect`, so the form renderer
  publishes its label's `id` and drops the dead `for`;
- the chip row answers `role="group"` when (and only when) it was handed an
  `aria-labelledby`, the same conditional `CheckboxesField` uses.

Pins, both directions. The two files fail apart by design and were measured
apart: dropping the set entry turns `group-labelling-declaration.test.ts` red
(the per-type case + the "exactly the audited set" assertion) while the e2e file
stays GREEN — it registers widgets raw with `labelling: 'group'` hardcoded in
its own `beforeAll`, so it structurally cannot see the real declaration.
Dropping the `role` inverts that: four e2e cases red, the declaration test green.
Neither half is covered by the other's test, which is why both exist.

Unchanged on purpose: each chip keeps its own accessible name from its text
content (an `aria-labelledby` on a chip would OVERRIDE it, the same override
this family refuses for address's street box), and standalone rendering — the
grid's inline cell editor, a bare SDUI node — emits no role and no IDREF.
@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 8:53pm

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-D-OF9nGo.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.99KB 56.47KB
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.54KB 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.80KB 59.48KB
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) 23.71KB 7.95KB
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。#3975 落地。

核验记录(head a7bd0a3c4,基 cdc0e44c8,实物核验 + CI 亲读):

  1. XS 单按 PR3978 机制精确执行:集合 +multiselect、chip 容器条件 role="group"(与 CheckboxesField 同条件式),契约零新 key;⛔ form.tsx 零触碰(在飞 [components] 表单渲染器内建 select 分支把 host id 展开到 Radix Select.Root —— 手写 form schema 里 type: 'select' 的 label for 全部悬空(#3306 的内建侧同形) #3976 面)。前提复核发现正文一处细节不符(chip 是 toggle button 非 role=checkbox)—— 如实修正取样方式而非硬套。
  2. 反向验证的诚实处置正确:报告模板预设「撤集合项 → 新钉翻红」不成立 —— 两个测试文件互补而非重叠(e2e 裸注册看不见真实声明集合),dev 如实记录并用两次变异证明归因干净分离,另加一次性 probe 走真实惰性注册路径补上盲格。
  3. 并入 e2e 文件的选择(派发词二选一)论证成立:同失效类并入自动继承四条方向性断言,且防文件与声明集合脱节。
  4. 门与规程:fields+form 面 1253 测试绿;消费半径按规则枚举(role=group 全仓 grep、dom-leak 白名单核对、26 个 multiselect 测试文件过目);type-check 绿;控制字节门 + 自扫;changeset patch;fable 0;PR 正文 sanitizer 检查 + 回读。
  5. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 20:59:52Z)。

out-of-scope 两条(#3986 select multiple 路径分叉、#3990 只读/零选项态收口)由 PM 分诊(另评)。


Generated by Claude Code

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[fields] multiselect 的 host label 也落在不可 label 的包裹 div 上 —— 与 #3961 同一失效类,但不在其审计的六个 widget 内

2 participants