Skip to content

fix(metadata-admin): 检查器三个共享字段原子的 label 与控件建立编程关联 (#3994) - #3996

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3994-inspector-shared-field-labels
Aug 9, 2026
Merged

fix(metadata-admin): 检查器三个共享字段原子的 label 与控件建立编程关联 (#3994)#3996
yinlianghui merged 1 commit into
mainfrom
claude/issue-3994-inspector-shared-field-labels

Conversation

@yinlianghui

@yinlianghui yinlianghui commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3994

缺陷

packages/app-shell/src/views/metadata-admin/inspectors/_shared.tsx 的三个通用字段原子 —— InspectorTextField / InspectorNumberField / InspectorSelectField —— 都把 Label 渲染成控件的兄弟节点,却既没有 htmlFor、也没有 id,更没有 aria-label 兜底。标签与控件之间只有视觉邻接,没有可编程关联:焦点落进去时辅助技术读到的是匿名「编辑框 / combobox」,可见的字段名是上方一段无归属文本,点标签什么都不会发生。修前实测三者的 getByLabelText('Group')(与屏幕阅读器同一条 for→id 链)全部 0 命中。

不是 dormant:这三个原子被 16 个非测试模块消费(页面块 / 流程节点 / 报表 / 数据集 / 权限 / 对象字段各检查器,以及 studio-design 的对象分组检查器),打开任意检查器面板即渲染。

修法

三者各自 React.useId() 生成 id 并闭合 htmlForid

id 在原子内部生成、不由调用方传入(issue 把这个小决定留给实施者论证):这些原子渲染在数组项循环里(record:details.sections[i]page:tabs.items[i]),每项重复同一个 label —— 那正是调用方传 id 会撞车的地方,而撞车是静默的:两个 label 都能解析,只是都指向第一个控件,任何「存在一个名为 X 的控件」的断言仍会通过。useId() 结构上不可能撞车,是「让 AI 写的代码难以写错」的那一侧。将来的 aria-describedby(#3912 的校验消息)属于同一个原子,可以从这个私有 id 派生;只有当关联必须从外部建立时才需要暴露 id,而现在没有任何调用点需要。

select 的 id 落点:SelectTrigger,不是 Select。Radix Select.Root 自己不渲染任何 DOM 元素,交给它的 id 会被静默丢弃、label 的 for 随之悬空 —— 就是 #3976(PR #3992)在隔壁 packages/components 内建 select 分支修掉的同一机制。trigger 渲染真正的 button[role=combobox],是可 label 元素,一对 for/id 即完成命名,不需要第二条 aria-labelledby 通道(测试把「只有 for、没有第二条通道」钉住了)。disabled 仍留在 Root(对 trigger / items / 隐藏 native mirror 的单一权威),禁用态下依然有可访问名。

InspectorCheckboxField 本来就是对的(包裹式 label,合法关联、无需 id),一字未动 —— issue 把它列为反例参照,这里把它变成测试里的阳性对照。

消费面 16 个文件无需改动:id 是组件内部生成的,调用点不感知。

钉子

新增 _shared.labels.test.tsx(26 条)。三原子共用一组 describe.each 跑同一组保证:

  • label 的 for 在文档里有宿主(先断言恰好产出 1 个 for,否则修前会「没有 label 可查」而假绿)
  • getAllByLabelText 命中且就是那个可聚焦控件(select 命中 trigger、tagName BUTTON,不是包裹 div)
  • 可访问名等于 label(toHaveAccessibleName,断在计算结果而不是标记上)
  • 只有 for 一条命名通道:先断言 forId 为真,否则修前两侧都是 nulltoBe 会 null === null 假绿
  • id 全文档唯一宿主
  • 同 label 两实例 id 不同、getAllByLabelText 返回 2 个且与 getAllByRole 逐个对齐(数组项场面);不同 label 兄弟各自解析
  • 以标签定位到的控件是真控件:文本框输入只从该实例提交、number 提交数字、select 的 trigger 就是显示选中项 label 的那个元素;禁用 select 仍有名

PageBlockInspector.sectionName.test.tsx 的变通处随之收紧。原注释把「必须按 placeholder 定位,因为 InspectorTextField 的 Label 与 input 无关联、getByLabelText 到不了」写下来当作理由 —— 那正是本缺陷的机械症状。定位改为 getAllByLabelText('Name (i18n key)')(与 AT 同一条链);snake_case 约定不再搭便车藏在定位器里,单独一条断言;「一个分区一个 name 框」顺带成为消费侧的 id 不撞车检查(两个分区重复同一 label,撞车会让它返回 1 个)。

反向验证(先写预判再跑,两个变异均未提交)

预判方向都是朴素翻红,但形状不同,分别跑:

变异 1 —— 只从 InspectorTextField 撤掉 htmlFor/id 预判:_shared.labels.test.tsx 中该原子的 5 条命名 + 2 条多实例 + 1 条「输入提交」共 8 条红,其余两原子与 checkbox 全绿;sectionName 9 条里凡调用 nameBoxes() 的 7 条红,不按标签定位的 2 条(「渲染本身不写入」「作者不输入就保持无名」)绿。共 15 红。

实测逐条一致:

Tests  15 failed | 20 passed (35)
x 'InspectorTextField' — … emits no label pointing at an id nothing carries
x 'InspectorTextField' — … resolves the label to the focusable control, not to a wrapper
x 'InspectorTextField' — … gives the control the label as its accessible name
x 'InspectorTextField' — … keeps `for` as the only naming channel
x 'InspectorTextField' — … mints exactly one owner for the id
x 'InspectorTextField' — instances in one panel … gives two identically-labelled instances distinct ids
x 'InspectorTextField' — instances in one panel … resolves differently-labelled siblings to their own controls
x the labelled element is the live control … InspectorTextField — typing into the label-located box commits from that instance
x PageBlockInspector … renders one name box per section
x … the placeholder keeps stating the snake_case convention
x … typing a name commits it to `properties.sections[i].name`
x … names the right section when several exist
x … the committed name satisfies the renderer guard that reaches the i18n lookup
x … a freshly added section offers an empty name box
x … opens the name box empty and leaves the label alone

变异 2 —— 把 select 的 id 从 SelectTrigger 移回 Select(Root)。 预判:只有 select 的 9 条红(5 命名 + 2 多实例 + 2 decoy),text/number/checkbox 17 条绿,sectionName 整个文件绿(它从不按标签定位 select)。这一条专门证 Root 静默丢弃机制,而不只是「少了个属性」。

实测:

Tests  9 failed | 26 passed (35)
Test Files  1 failed | 1 passed (2)
x 'InspectorSelectField' — … emits no label pointing at an id nothing carries
x 'InspectorSelectField' — … resolves the label to the focusable control, not to a wrapper
x 'InspectorSelectField' — … gives the control the label as its accessible name
x 'InspectorSelectField' — … keeps `for` as the only naming channel
x 'InspectorSelectField' — … mints exactly one owner for the id
x 'InspectorSelectField' — instances in one panel … gives two identically-labelled instances distinct ids
x 'InspectorSelectField' — instances in one panel … resolves differently-labelled siblings to their own controls
x the labelled element is the live control … InspectorSelectField — the id lands on the trigger that surfaces the value
x the labelled element is the live control … InspectorSelectField — a disabled field is still named

注意变异 2 下「emits no label pointing at an id nothing carries」是靠 resolves 为假翻红(label 的 for 还在,只是没有宿主),正是缺陷的原貌。

验证

  • 依赖先行:pnpm --workspace-concurrency=2 --filter '@object-ui/app-shell^...' build 绿。
  • 消费面全量回归:vitest run packages/app-shell/src/views/metadata-admin136 文件 / 1399 通过 | 1 skipped
  • 另一处消费面(studio-design 的对象分组检查器):vitest run packages/app-shell/src/views/studio-design17 文件 / 114 通过
  • turbo run type-check --concurrency=278/78 successful
  • node scripts/check-control-bytes.mjs → OK(3904 tracked text files)。
  • eslint 三个改动文件:0 error(5 条 react-refresh、1 条 no-explicit-any 均为既有告警)。

边界

顺手发现(未在本 PR 修)

同目录的 InspectorComboField.tsx 是同族缺陷的第四个站点(label 兄弟节点、trigger 无 id),已另开 issue #3997 交 PM triage,本 PR 不夹带。

`InspectorTextField` / `InspectorNumberField` / `InspectorSelectField` 都把 `Label`
渲染成控件的兄弟节点,却既没有 `htmlFor`、也没有 `id`,更没有 `aria-label` 兜底 ——
标签与控件之间只有视觉邻接。修前实测:三者的 `getByLabelText('Group')`(与屏幕阅读器
同一条 for→id 链)全部 0 命中,焦点落进去读到的是匿名「编辑框 / combobox」,可见的
字段名成了一段无归属文本,点标签什么都不会发生。这三个原子被 16 个非测试模块消费
(页面块 / 流程节点 / 报表 / 数据集 / 权限 / 对象字段各检查器,以及 studio-design 的
对象分组检查器),打开任意检查器面板即渲染。

三者各自 `React.useId()` 生成 id 并闭合 `htmlFor` ⇄ `id`。id 在原子内部生成而非由调用方
传入是有意的:这些原子渲染在数组项循环里(`record:details.sections[i]`、
`page:tabs.items[i]`),每项重复同一个 label —— 那正是调用方传 id 会撞车的地方,而撞车
是静默的(两个 label 都能解析,只是都指向第一个控件)。`useId()` 结构上不可能撞车;
多实例互不串 id 是钉住的,不是假设的。

select 的 id 落在 `SelectTrigger` 而非 `Select`:Radix `Select.Root` 自己不渲染任何
DOM 元素,交给它的 id 会被静默丢弃、label 的 for 随之悬空 —— 就是 #3976(PR #3992)
在隔壁修掉的同一机制。trigger 渲染真正的 `button[role=combobox]`,是可 label 元素,
一对 for/id 即完成命名,不需要第二条 `aria-labelledby` 通道;`disabled` 仍留在 Root
(对 trigger / items / 隐藏 native mirror 的单一权威),禁用态下依然有可访问名。

`InspectorCheckboxField` 本来就是对的(包裹式 label,合法关联、无需 id),一字未动,
在测试里作为阳性对照 —— 两个反向变异下它必须保持绿。

钉子 `_shared.labels.test.tsx`(26 条):三原子共用 `describe.each` 跑同一组保证 ——
label 的 for 有宿主、`getAllByLabelText` 命中且就是那个可聚焦控件(select 命中 trigger、
tagName BUTTON)、可访问名等于 label、只有 for 一条命名通道(先断言 forId 为真,
否则修前 null === null 会假绿)、id 全文档唯一;同 label 两实例 id 不同且各自归属、
不同 label 兄弟各自解析;以标签定位到的控件是真控件(输入提交只来自该实例、number
提交数字、select 的 trigger 就是显示选中项 label 的那个元素)。

`PageBlockInspector.sectionName.test.tsx` 的变通处随之收紧:原注释把「必须按 placeholder
定位,因为 `InspectorTextField` 的 Label 与 input 无关联,`getByLabelText` 到不了」写下来
当作理由,那正是本缺陷的机械症状。定位改为 `getAllByLabelText('Name (i18n key)')`
(与 AT 同一条链),`snake_case` 约定不再搭便车藏在定位器里,单独一条断言;
「一个分区一个 name 框」顺带成为消费侧的 id 不撞车检查。

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 10:29pm

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-CLMhO5dH.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.62KB 106.67KB
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.82KB 10.79KB
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.67KB 30.69KB
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.86KB 27.24KB
plugin-gantt (index.js) 162.79KB 39.67KB
plugin-grid (index.js) 187.97KB 49.79KB
plugin-kanban (index.js) 48.53KB 13.38KB
plugin-list (index.js) 109.73KB 26.55KB
plugin-map (index.js) 17.05KB 5.33KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.99KB 7.42KB
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。#3994 落地。

核验记录(head 40a930f2e,基 0cbdca888,实物核验 + CI 亲读):

  1. 三原子修复精确:各自 useId() + htmlFor⇄id 闭合;Select 的 id 落 SelectTrigger 而非 Root(引 PR3992 同机制并注释)—— 教训跨 PR 传承到位;disabled 留 Root 单一权威。
  2. useId 的选择论证成立:原子渲染在数组项循环里,调用方传 id 正是静默撞车点(两 label 都解析都指向第一个控件)—— 组件内部生成正确;拒绝加 dormant 的可选 id prop 也正确(无消费需求不造 API)。
  3. 测试收紧三处 + 覆盖不丢:placeholder 定位改 getByLabelText;搭便车藏在定位器里的 snake_case 约定单独成钉;「一分区一框」追加不等断言成为消费侧撞车检查 —— 收紧的每一步都想到了被顺走的覆盖。
  4. 26 条新钉防假绿设计:先断言 for 恰好 1 个(修前无 label 可查会假绿)、forId 为真(防 null===null)、单一命名通道、id 唯一宿主;阳性对照(包裹式 checkbox)双变异下保持绿。
  5. 反向验证两变异:15 红 / 9 红逐条命中预判;变异②专证 Root 静默丢弃机制(for 在而无宿主)而非只证属性缺失。
  6. 门与规程:metadata-admin 全量 1400 + studio-design 消费面 114 测试绿;type-check 78/78;控制字节门 + 自扫;changeset patch;fable 0;16 消费文件零改动确认;PR 正文 issue 号笔误已自纠并回读。
  7. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 22:35:43Z)。

concern 中「点 label 会展开下拉」为 button 可 label 元素的标准行为、与 PR3992 已接受形状一致 —— 同意非回归,维护者若要「聚焦不展开」属另一决定。out-of-scope #3997(第四原子 InspectorComboField + 18 处待核清单)由 PM 分诊(另评)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 22:38
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit dffeeef Aug 9, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3994-inspector-shared-field-labels branch August 9, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants