Skip to content

fix(app-shell): ActionParamDialog 的 boolean 分支下发控件 id,可访问名不再是两份 label 的拼接 (#3962) - #3971

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3962-actionparam-boolean-id
Aug 9, 2026
Merged

fix(app-shell): ActionParamDialog 的 boolean 分支下发控件 id,可访问名不再是两份 label 的拼接 (#3962)#3971
yinlianghui merged 1 commit into
mainfrom
claude/issue-3962-actionparam-boolean-id

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3962

ActionParamDialog 的 boolean 分支渲染 Label htmlFor={param.name} 放在控件旁边,却给 widget 传 id —— 而同一文件下面几十行的通用分支一直传的是 id={param.name}。本 PR 让 boolean 分支与它自己的通用分支对齐,一行。

修前实测(真 dialog 渲染,#3952/PR #3959 之后)

一个 boolean param(name: confirmed,label: "Confirm This")产出两个 label 元素指向同一个控件:

controlId=confirmed  role=checkbox
labels=[
  { text: "Confirm This", for: "confirmed", srOnly: true  },   widget 自己发的
  { text: "Confirm This", for: "confirmed", srOnly: false }    dialog 发的可见 label
]

两个 for 都能解析,所以这不是 #3341 / #3952 那种悬空 label —— 点击一直是好的。坏的是名字:按 accname §2D,引用同一控件的多个 label 会拼接成可访问名,屏幕阅读器听到的是 "Confirm This Confirm This"。

两个缺陷,相隔一行

  1. 关联是隐式的。 它成立只因为 BooleanField 的 id 回退链第二项落在 config.name,而 paramToField 把它设成了 param.name —— 两边靠另一个包的内部实现恰好撞成同一个字符串。一个渲染了 htmlFor 的 host 必须自己发出它所指的 id;widget 契约(FieldWidgetDomProps)声明 id 就是为这个。
  2. 重复的 sr-only label。 [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 / PR fix(fields): BooleanField 使用 host 下发的控件 id,可见 label 不再悬空 (#3952) #3959BooleanField 在 host 下发 id 时不再发自己的 label(emitOwnLabel = !hostId),理由正是「会下发 id 的 host 就是会渲染 label 的 host」。这个分支收不到 id,那条抑制从来没被触发。

修后:关联从隐式变显式,widget 的 sr-only 副本被抑制,可访问名回到作者声明的那一份 "Confirm This"。通用分支未动。

钉子(两个方向)

ActionParamDialog.ariaRequired.test.tsx 新增 4 条(命名一半):

  • boolean 控件的 id 恰为 param.name,且 label[for] 恰好 1 个,而且活下来的那个是可见的那份(不是 sr-only —— 只数个数会让一个只有屏幕阅读器可见名字的 checkbox 行蒙混过关);
  • 可访问名恰为 Confirm This,不是它的两倍;
  • 必填 boolean 的可访问名仍不含星号(aria-hidden* 不能被抑制重复的动作带进名字里),且控件带 aria-required="true";
  • 不变方向:通用分支的 host id / 单 label / 单名字照旧 —— 将来若有人把 id 归属搬回 widget,这条也会红。

ActionParamDialog.test.tsx 新增 1 条(行为一半):点击可见 label 翻转开关并 round-trip 到 resolve

刻意没有用 getAllByLabelText 做探针(issue 正文提议过):@testing-library/dom 10.4.1 的 queryAllByLabelText 最后一步是 Array.from(new Set(matchingLabelledElements)),并且对多 label 会把名字拆开分别匹配 —— 修前修后它都只解析出 1 个控件,当探针是空转的。可测量的那一半是名字label 元素个数,钉子就钉在这两处。

反向验证(先预判,后跑)

预判:删掉 boolean 分支那一行 id={param.name}(只删 24 空格缩进那条,通用分支的同名行不动),恰好 3 条命名钉翻红(2 个 label / 名字翻倍 / 必填那条也因名字翻倍红),而「通用分支不变」与新增的点击 label 钉保持绿 —— 因为修前 for 目标本来就能解析,这个缺陷是重复而非悬空。

实测与预判逐条一致。带修:Test Files 2 passed (2) / Tests 46 passed (46)。删掉那一行后(id={param.name} 出现次数 2 → 1,通用分支那条完好):

× gives the boolean control the host id and exactly one label element
    AssertionError: expected [ label …(2) , …(1) ] to have a length of 1 but got 2
× announces the label once, not twice concatenated
    expect(element).toHaveAccessibleName()
× still names a required boolean param without folding in the asterisk
    expect(element).toHaveAccessibleName()
Test Files  1 failed | 1 passed (2)
Tests  3 failed | 43 passed (46)

「通用分支不变」与点击 label 那条在变异树里如预判保持绿。变异随后由 git checkout -- + 重新 git apply 还原(未用 git stash,共享栈),未提交。

验证

  • 仓根 flock 串行 + NODE_OPTIONS=--max-old-space-size=4096:
    • pnpm exec vitest run packages/app-shell --maxWorkers=2Test Files 308 passed (308) / Tests 2842 passed | 1 skipped (2843)
    • pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build → 退出 0
    • pnpm exec turbo run type-check --concurrency=2Tasks: 78 successful, 78 total,退出 0
  • node scripts/check-control-bytes.mjs → OK(3856 个跟踪文本文件)。改动的 4 个文件另做了一次越过门禁盲区的自扫:用 grep -naP 匹配 C0 控制字符区间(NUL 到退格、垂直制表、换页、以及 SO 到 US),即除制表符/换行/回车之外的全部控制字节,无命中。
  • changeset 三道门:presence / no-major / fixed-group 全绿。

消费半径

getLazyFieldWidget 在仓内只有两个非测试调用点。另一个 host(plugin-gridBulkActionDialog)id 下发是对的,但缺 aria-hidden 的星号与 aria-required,已另立 #3967,不在本 PR 范围

边界

只动 boolean 分支 + 两个既有测试文件 + changeset。未碰 BooleanField 本体(#3959 已定型)、未碰 releases/、未碰 #3913 在飞的 metadata-admin 面。

… 的拼接 (#3962)

`ActionParamDialog` 的 boolean 分支渲染 `Label htmlFor={param.name}` 放在控件
旁边,却不给 widget 传 `id` —— 而同一文件下面几十行的通用分支一直传的是
`id={param.name}`。实测(#3952 / PR #3959 之后、真 dialog 渲染)一个 boolean
param 产出两个 label 元素指向同一个控件:widget 自己那份 sr-only 的,和 dialog
的可见那份,文本相同。两个 `for` 都能解析,所以这不是 #3341 / #3952 那种悬空
label —— 点击一直是好的;坏的是名字:按 accname 规范,引用同一控件的多个 label
会拼接成可访问名,屏幕阅读器听到的是 "Confirm This Confirm This"。

两个缺陷,相隔一行:

1. 关联是隐式的。它成立只因为 `BooleanField` 的 id 回退链第二项落在
   `config.name`,而 `paramToField` 把它设成了 `param.name` —— 两边靠另一个包的
   内部实现恰好撞成同一个字符串。一个渲染了 `htmlFor` 的 host 必须自己发出它所
   指的 id;widget 契约(`FieldWidgetDomProps`)声明 `id` 就是为这个。

2. 重复的 sr-only label。#3952 / PR #3959 让 `BooleanField` 在 host 下发 id 时
   不再发自己的 label(`emitOwnLabel = !hostId`),理由正是「会下发 id 的 host
   就是会渲染 label 的 host」。这个分支收不到 id,那条抑制从来没被触发。

修法是让 boolean 分支像它自己的通用分支那样传 `id={param.name}`:关联从隐式变
显式,widget 的 sr-only 副本被抑制,可访问名回到作者声明的那一份。通用分支未动。

钉子挂在两个既有测试文件上。命名一半在 `ActionParamDialog.ariaRequired.test.tsx`
(4 条):boolean 控件 id 恰为 `param.name` 且 `label[for]` 恰好 1 个、活下来的
是可见那份而非 sr-only;可访问名恰为一份;必填 boolean 的名字仍不含
`aria-hidden` 的星号且带 `aria-required="true"`;以及不变方向的通用分支 host id /
单 label / 单名字。行为一半在 `ActionParamDialog.test.tsx`(1 条):点击可见 label
翻转开关并 round-trip 到 `resolve`。

刻意没有用 `getAllByLabelText` 做探针(issue 正文提议过):
`@testing-library/dom` 10.4.1 的 `queryAllByLabelText` 最后一步是
`Array.from(new Set(matchingLabelledElements))`,并且对多 label 会把名字拆开
分别匹配,修前修后它都只解析出 1 个控件,当探针是空转的。可测量的那一半是名字
与 label 元素个数。

反向验证按预判成立:删掉 boolean 分支那一行 `id={param.name}` 后恰好 3 条命名钉
翻红,而「通用分支不变」与点击 label 那条保持绿 —— 修前 `for` 目标本来就能解析,
这个缺陷是重复而非悬空。变异未提交。

Co-authored-by: Claude <noreply@anthropic.com>
@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 6:37pm

Request Review

@github-actions github-actions Bot added the tests label Aug 9, 2026
@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-B-f0fwAx.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) 482.58KB 106.41KB
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.45KB 56.31KB
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) 44.98KB 12.37KB
plugin-charts (index.js) 61.17KB 17.35KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.02KB 30.47KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 236.63KB 59.02KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.63KB 49.66KB
plugin-kanban (index.js) 48.30KB 13.28KB
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.02KB 0.55KB
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。#3962 落地。

核验记录(head d33e5d396,实物核验[以 GitHub merge-base 文件清单为准:4 文件全在声明面] + CI 亲读):

  1. 一行修 + 论证完整:boolean 分支补 id={param.name} 与 generic 分支对齐;PR 正文把缺陷拆成两半(隐式关联靠他包回退链撞串 + 无 host id 导致 sr-only 重复发出 → accname §2D 拼接)—— 定性精确,这不是悬空 label 而是重复命名,与 两处表单控件缺 label→control 关联:InlineCreateRelated 的 label 无 htmlFor/id,custom action-param-dialog 的 select 分支 htmlFor 指向不存在的 id #3341/[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 的区分写得很清楚。
  2. 探针选择的论证有价值:刻意不用 issue 建议的 getAllByLabelText(RTL 经 Set 去重 + 多 label 拆分匹配,修前修后都返回 1,空转)—— 改钉「label 元素个数 + 可访问名不翻倍」,并加「幸存的是可见那份」防「只剩 sr-only 名」蒙混。
  3. 反向验证:预判删行后恰 3 条命名钉红、点击钉与 generic 钉绿(因为缺陷是重复非悬空)—— 实测逐条一致(3 failed | 43 passed)。
  4. 门与规程:app-shell 全量 2843 测试绿;type-check 78/78;控制字节门 + 盲区自扫;changeset patch 三门绿;fable 0;⛔ releases/ 未触碰;边界(BooleanField 本体/metadata-admin 面)零触碰。
  5. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 18:43:41Z)。
  6. 流程注记:dev 的最终报告消息迷失于等锁循环,但 PR 正文即完整报告 —— 交付实物齐全,按实物收单。

消费半径 out-of-scope:#3967(BulkActionDialog 缺 aria-hidden 星号与 aria-required)由 PM 分诊。


Generated by Claude Code

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

Labels

Projects

None yet

2 participants