Skip to content

fix(a11y): 给两处表单控件补上 label→control 关联 (#3341) - #3380

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-3341-label-control-association
Aug 5, 2026
Merged

fix(a11y): 给两处表单控件补上 label→control 关联 (#3341)#3380
yinlianghui merged 3 commits into
mainfrom
claude/issue-3341-label-control-association

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3341

两处表单控件的 label 与控件之间根本没有程序化关联,导致控件的可访问名错误或缺失。这与 #3299(PR #3340)修的「必填状态不进 a11y 树」是相邻但不同类的缺陷:即便 aria-required 已经到位,可访问名仍然是错的。

前置核对(对当前 origin/main)

两处缺陷均仍然成立,行号已按合并后代码重新核对:

  • packages/plugin-detail/src/InlineCreateRelated.tsx L208-L227:label 元素无 htmlFor,相邻的 Input 无 id,两者也不是包裹关系。
  • packages/components/src/custom/action-param-dialog.tsx select 分支 L181-L193:Label htmlFor={param.name} 指向的 id 从未被渲染出来 —— 该分支的控件是 Radix SelectTrigger,没有设 id。其余 textarea / number / date / text 分支的 Input / Textarea 都设了 id={param.name},只有 select 漏了。

修法

  • InlineCreateRelated:label 加 htmlFor、Input 加 id。id 用 React.useId 做前缀命名空间(inline-create-{uid}-{fieldName})—— field.name 只在单个实例内唯一,而一个详情页每个相关列表挂一个该组件,两个列表都提供 name 字段时裸用 field.name 会产生重复 id,htmlFor 会一律解析到第一个输入框。
  • custom action-param-dialog:SelectTrigger 加 id={param.name},让本就存在的 htmlFor 落到实处。SelectTrigger 渲染的是 role="combobox" 的 button,而 button 属于 labelable element,因此普通的 htmlFor / id 配对就是这里的正确关联方式,不需要aria-labelledby(Radix 也没有在 trigger 上设 aria-labelledby,那个是 SelectItem 用的)。

未动 spec,未动 widget props 契约。

测试

新增 11 条断言,两处各一个测试文件,覆盖验收口径:getByLabelText 命中控件 + toHaveAccessibleName 等于 label 文本。

反向验证(方向为事前预测的「常规红」):把两处 htmlFor/id 还原成修复前的样子,11 条全红;恢复后全绿。失败信息正是缺陷本身 —— Found a label with the text of: Notes, however no form control was found associated to that label

一处与工单正文不符、按实测如实记录的细节:工单说可访问名「退化到 placeholder(Enter name)」。实测在本测试环境下修复前的可访问名是空字符串,不是 placeholder —— dom-accessibility-api 根本没有实现 placeholder 兜底(两个版本里 placeholder 出现次数均为 0),真实浏览器才会按 HTML-AAM 兜底到 placeholder。两种情况下「可访问名不是 label」这个结论一致,测试钉的也是这一条;但测试头注释里写的是实测到的空字符串,而不是照抄工单的 placeholder 说法。

另外两条口径已在注释中写明,避免后来者踩坑:

  • 精确字符串的 getByLabelText 用的是非必填字段。RTL 的 label-text 查询读的是原始 textContent,尊重 aria-hidden,所以必填字段的 label 文本是 Name*;必填字段改为在计算出的可访问名上断言(那里 * 被正确排除)。这两个通道的差异是真实存在的,不是测试瑕疵。
  • 「点击 label 聚焦输入框」改为断言 DOM 的 labels 集合。jsdom 会把 label 上的点击转发给控件的 activation behaviour,但不移动焦点,写 toHaveFocus() 等于在测 jsdom 而不是本组件。
pnpm exec vitest run packages/plugin-detail packages/components --maxWorkers=2
Test Files 118 passed (118)
Tests 965 passed (965)
turbo run type-check --filter=@object-ui/components --filter=@object-ui/plugin-detail
Tasks: 13 successful, 13 total
turbo run lint --filter=@object-ui/components --filter=@object-ui/plugin-detail
✖ 760 problems (0 errors, 760 warnings) # 全部为既有 warning

消费半径已扫过:两个组件除各自的测试与 barrel 导出外,没有其他包的 fixture 引用,不存在跨包 fixture 需要一并改。

已附 changeset(patch;按仓库约定不声明 major)。


Generated by Claude Code

InlineCreateRelated's create-tab label had no htmlFor and its Input no id;
the custom action-param-dialog's select branch pointed htmlFor at an id that
was never rendered on the SelectTrigger.
Refs #3341
11 assertions across the two surfaces; all 11 go red when the htmlFor/id
pairs are reverted. Measured pre-fix accessible name is the empty string
(dom-accessibility-api implements no placeholder fallback), recorded in
each test header rather than restating the issue's placeholder claim.
Refs #3341
@vercel

vercelBot commented Aug 5, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 5, 2026 11:30am

Request Review

The issue said the name degraded to the placeholder. That holds in a real
browser (HTML-AAM last resort) but not in the test harness, where
dom-accessibility-api implements no placeholder fallback and the name
computed to ''. Say both, so the source comments and the test headers
cannot be read as contradicting each other.
Refs #3341
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-DPCx4NSq.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.47KB3.09KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)477.32KB104.75KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)227.18KB55.72KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)9.48KB3.27KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)38.53KB10.71KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.05KB1.53KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)44.98KB12.37KB
plugin-charts (index.js)60.83KB17.25KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.01KB28.86KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)231.29KB57.04KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.54KB26.97KB
plugin-gantt (index.js)162.25KB39.55KB
plugin-grid (index.js)185.08KB49.04KB
plugin-kanban (index.js)47.89KB13.18KB
plugin-list (index.js)105.02KB25.36KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.55KB10.59KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.34KB2.82KB
plugin-view (index.js)83.67KB20.43KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)2.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@yinlianghui
yinlianghui marked this pull request as ready for review August 5, 2026 11:42
@yinlianghui
yinlianghui added this pull request to the merge queueAug 5, 2026
Merged via the queue into main with commit 53811d1Aug 5, 2026
16 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3341-label-control-association branch August 5, 2026 11:42
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 6, 2026
…aceholder (objectstack-ai#3410)
InlineCreateRelated's "Link Existing" search Input had no label of any kind
— no `<label>`, no `aria-label`, no `aria-labelledby` — so its accessible
name fell through to the placeholder: the HTML-AAM last resort, gone the
moment the user types, and absent entirely under `dom-accessibility-api`
(measured pre-fix name here: the empty string).
Visually hidden `<label htmlFor>` + a `React.useId`-namespaced id, the same
shape objectstack-ai#3341/PR objectstack-ai#3380 left on the create tab, rather than `aria-label` — the
name stays a real label element on the same expression as the placeholder
instead of a detached string that can drift. The id uses a hyphenated
`link-search` segment so it cannot collide with a create-tab field named
`search`. The magnifier is explicitly `aria-hidden` (lucide already defaults
to that for childless icons; spelled out so the intent is local).
Rendered copy is unchanged: the placeholder string is byte-identical.
Refs objectstack-ai#3381
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude