Skip to content

fix(a11y): 用真 label 命名 InlineCreateRelated 的 Link 页签搜索框 (#3381) - #3410

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3381-link-search-label
Aug 5, 2026
Merged

fix(a11y): 用真 label 命名 InlineCreateRelated 的 Link 页签搜索框 (#3381)#3410
yinlianghui merged 1 commit into
mainfrom
claude/issue-3381-link-search-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3381

前提复核(对 origin/main 2409e1d)

issue 成立,行号已按 PR #3380 之后的现状复核:packages/plugin-detail/src/InlineCreateRelated.tsx 的 Link 页签搜索框在 L279-L285(正文写的是 ~L253-260,PR #3380 把它推后了约 26 行)。该 Input 确实没有任何 label(无 label 元素、无 aria-label、无 aria-labelledby),而同文件 Create 页签在 #3380 之后已经是「label 文本即可访问名」——正文描述的「唯独此处例外」属实。

改了什么

按 PM 裁定取视觉隐藏 label + htmlFor/React.useId(否决 aria-label):

  • 新增 searchLabel = \Search ${objectName}`,**label 文本与 placeholder 同一表达式**(placeholder 只多一个省略号)。这正是不用 aria-label` 的理由:可访问名留在真实 label 元素上、且与可见文案同源,不会各自漂移。
  • 新增 searchInputId = \inline-create-${instanceId}-link-search`instanceId复用 #3341 引入的React.useId(一个详情页每个相关列表挂一个该组件)。用带连字符的 link-search段是为了和fieldDomId(...)的后缀(元数据字段名,惯例[a-z0-9_]+)**不可能**撞上——包括一个字面叫 search` 的字段。
  • Search 装饰图标按 PM 要求核了现状:lucide-react 1.25.0 对「无 children 且无 a11y prop」的图标默认就加 aria-hidden="true"(dist/cjs/lucide-react.js:92),即现状已经是隐藏的。仍然显式补上——这是依赖的默认值而非我们的声明,写出来意图留在本地、也不随图标库升级改变。见「反向验证」里对这一条的诚实标注。
  • 零新 i18n 键:该 placeholder 本身的英文硬编码属另一族,未展开(既有 detail.searchRecords 文案是 Search records…,与本框的 Search {objectName}… 不同源,消费它反而会把 label 和 placeholder 拆开,与本单方向相反)。渲染文案逐字节未变

测试

新增 packages/plugin-detail/src/__tests__/InlineCreateRelated.linkSearchLabel.test.tsx(9 条)。除常规的 getByLabelText / toHaveAccessibleName / labels 集合 / 每实例 id 不撞车外,两条专门钉验收里那个「分水岭」:

  • 输入之后(fireEvent.change 写入 North,真实浏览器里 placeholder 已不显示)可访问名仍是 Search Contact,并顺带断言该框确实是活的搜索框(过滤掉了不匹配的记录);
  • 可访问名 ≠ placeholder:label 是 Search Contact、placeholder 是 Search Contact…,末尾省略号让两者可区分,因此「名字不是把 placeholder 读回来」这一点在任何实现下都可断言——包括真会做 placeholder 兜底的浏览器。这一条正面解决了正文提到的「兜底跨实现不一致」。

命令与结果(仓根跑,已确认输出里点名了本文件;--reporter=verbose):

pnpm exec vitest run --maxWorkers=2 --reporter=verbose packages/plugin-detail/src/__tests__/InlineCreateRelated.linkSearchLabel.test.tsx
✓ |dom| packages/plugin-detail/src/__tests__/InlineCreateRelated.linkSearchLabel.test.tsx > … (9 条全绿)
Test Files 1 passed (1)
Tests 9 passed (9)
pnpm exec vitest run --maxWorkers=2 packages/plugin-detail/
Test Files 48 passed (48)
Tests 435 passed (435)
pnpm --workspace-concurrency=2 --filter @object-ui/plugin-detail type-check → 通过(无输出)
pnpm exec eslint <两个改动文件> → 0 errors(2 个既有的 no-explicit-any warning,在未改动的 L43/L62)
node scripts/check-control-bytes.mjs → OK(3605 个文件)

反向验证(方向事先声明,两个方向都记下)

预期主方向:把源码改回 origin/main → 新测试转红。 实测 9 条全红,toHaveAccessibleName 的 Received 是空串:

Expected element to have accessible name:
Search Contact
Received:
← 空
→ Unable to find a label with the text of: Search Contact

这正好复现了 issue 正文的测量结论:dom-accessibility-api 不实现 placeholder 兜底,所以测试环境下修前的可访问名是空串(真实浏览器才会兜到 Search Contact…)。

一条要诚实标注的例外:「装饰图标不进 a11y 树」这条并不由本 PR 的显式 aria-hidden 支撑。 单独做了定向反验——保留 label 修复、只删掉 Search 上显式的 aria-hidden——该条仍然绿(lucide 的默认值兜住了)。也就是说这条断言钉的是结果(图标不被朗读),不是我们新加的那个属性;它的价值在于将来图标库换掉/改默认时会报警。全量反验里它也变红,但那只是因为前置的 getByLabelText 先失败,不构成独立证据。

另外首版里「字段名叫 search 不撞车」那条,在修前是空绿(修前搜索框根本没有 id,id 集合平凡地不重复)——已按「替换整条 fixture」的做法改成读取两个页签各自的真实 id 再比对,修前会因为 getByLabelText 找不到而红。顺带记一笔:Radix Tabs 1.1.x 的 tab 是 mousedown 激活的,fireEvent.click 切不过去(测试里已写明)。

范围

严格限于 packages/plugin-detail/src/InlineCreateRelated.tsx + 新增测试 + changeset(patch,仅 @object-ui/plugin-detail)。消费半径已扫:该组件只在 packages/plugin-detail/src/index.tsx 导出,仓内没有别的 fixture/测试按 placeholder 定位这个框。未改 props/spec/渲染文案。


🤖 Generated with Claude Code

https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt


Generated by Claude Code

…aceholder
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 #3341/PR #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 #3381
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@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 9:37pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-Dv9gN-y2.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)478.45KB105.05KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)228.08KB55.90KB
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)61.04KB17.31KB
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.79KB57.15KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.54KB26.97KB
plugin-gantt (index.js)162.55KB39.57KB
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 21:44
@yinlianghui
yinlianghui added this pull request to the merge queueAug 5, 2026
Merged via the queue into main with commit b17ce4cAug 5, 2026
17 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3381-link-search-label branch August 5, 2026 21:44
Sign up for freeto 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.

InlineCreateRelated 的 Link 页签搜索框只有 placeholder、没有任何 label:可访问名靠 placeholder 兜底,输入后即消失

2 participants

@yinlianghui@claude