Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-admin): 检查器三个共享字段原子的 label 与控件建立编程关联 (#3994) - #3996
Merged
Conversation
`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>The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
commented
Aug 9, 2026
CollaboratorAuthor
PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3994 落地。 核验记录(head
concern 中「点 label 会展开下拉」为 button 可 label 元素的标准行为、与 PR3992 已接受形状一致 —— 同意非回归,维护者若要「聚焦不展开」属另一决定。out-of-scope #3997(第四原子 InspectorComboField + 18 处待核清单)由 PM 分诊(另评)。 Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 22:38
Uh oh!
There was an error while loading. Please reload this page.
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 10, 2026
…objectstack-ai#3997) (objectstack-ai#4012) 第四个字段原子,与 PR objectstack-ai#3996 修掉的 `_shared.tsx` 三个原子形状完全相同 —— `Label` 是控件的兄弟节点,没有 `htmlFor`,trigger 没有 `id` 也没有 `aria-label`。 它在自己的模块里,所以那三个修好之后它仍旧带着缺陷。标签与 `button[role=combobox]` 之间只有视觉邻接:焦点落上去读到匿名 combobox,可见标签是 一段无归属文本,`getByLabelText` 到不了它,点标签什么都不会发生。非测试调用点 18 处 (对象字段 / 数据集 / 仪表盘部件 / 应用导航 / 视图变体检查器),打开任一面板即渲染。 带 label 分支照 objectstack-ai#3994 的定型机制闭合:`React.useId()` 在原子内部生成 id,`Label` 补 `htmlFor`,id 落到 `PopoverTrigger asChild` 渲染出的那个 `Button`。⛔ 不落 `Popover` —— Radix `Popover.Root` 是纯 context provider,不渲染 DOM,给它的 id 会被静默丢弃、 `for` 随之悬空(objectstack-ai#3976 / objectstack-ai#3994 已为此付过两次学费)。 ## 无 label 分支:命名改成类型级要求,而不是消费端兜底 `label` 原本可选,无 label 分支是同缺陷更重一档 —— combobox 完全无名,而 18 处调用点 里有 5 处正是这么写的。没有采用宽松兜底(用 placeholder 合成名字会把「Select…」念成 字段名);改为「三条通道恰选其一」,零条和两条都不可编译: - `label` —— 原子渲染可见标签并自持关联。已传 label 的 13 处不变。 - `ariaLabel` —— 重复行里本就没有可见标签、加一个会破坏栅格:应用导航 URL 过滤条件的 `field = value` 行、数据集的 join 列表、依赖查找的「添加字段」选择器。 - `id` —— 外部 `Label htmlFor` 已经持有命名。`DashboardWidgetInspector` 的 `Field` 包装器渲染 `Label htmlFor={id}` 并把同一个 id 交给被包控件,其余字段都履行了这个约定 (`Input id`、`SelectTrigger id`),只有 dataset combo 落不下去,因为原子不收 id —— 那个 `for` 指向一个没有任何元素持有的 id,是悬空 IDREF,比无标签更糟,因为工具会报告 一个解析不到的关联。 两条通道同时给出是 objectstack-ai#3961/objectstack-ai#3978 要避免的重复播报,故也一并禁掉。两种错误都没有组件能 自行发现并报告的运行期症状 —— 无名 combobox 渲染、布局、提交值全都正常,只对看不见它 的用户是错的 —— 所以检查只能发生在编写期,否则就不存在。 ## 钉子与反向验证 - `_shared.labels.test.tsx`:第四原子直接加入既有的 `describe.each`(它已把「for 有 宿主 / 命中可聚焦控件 / 可访问名 / 单一命名通道 / 多实例不撞车」写成与组件无关的形 状),另加 trigger 落点、自定义值、`ariaLabel` 分支、外部 `id` 分支四组。 - `DashboardWidgetInspector.test.tsx`:真实调用点上钉 Dataset 标签解析到 combo trigger。 刻意只锁这一对,不做整面板「无悬空 for」扫描 —— `widget-color` 的 `ColorVariantPicker` 同样不收 id,那是另一个组件、本单范围外,已单独立单 objectstack-ai#4010。 - `InspectorComboField.naming.types.test.tsx`(新增,列入 `tsconfig.typetests.json`): 类型级断言。第一稿把 `@ts-expect-error` 写在 `_shared.labels.test.tsx` 里,变异实测 显示那是**假绿** —— 把命名改回可选,`tsc --noEmit` 完全通过,因为包的构建 tsconfig 排除 `**/*.test.tsx`、vitest 又擦除类型,没有任何编译器读它。这正是 objectstack-ai#3009 的失效模式, 故移入被 typetests 项目编译的独立文件;同一变异现在报 6 条错(4 条 assignability 断言 + 2 条 unused `@ts-expect-error`)。 反向验证三向(先预判后跑,变异未提交):撤 `htmlFor` → combo 8 行翻红,其中「mints exactly one owner for the id」按预判保持绿(id 还在,只是没人指向它);id 改落 `Popover` Root → 11 行翻红(多出 owner 计数、外部 id 分支、真实调用点钉);命名改回可选 → 运行期 全绿、信号只在 typetests。 Fixesobjectstack-ai#3997 Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 并闭合htmlFor⇄id。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。RadixSelect.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跑同一组保证:getAllByLabelText命中且就是那个可聚焦控件(select 命中 trigger、tagName BUTTON,不是包裹 div)toHaveAccessibleName,断在计算结果而不是标记上)null、toBe会 null === null 假绿getAllByLabelText返回 2 个且与getAllByRole逐个对齐(数组项场面);不同 label 兄弟各自解析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 全绿;sectionName9 条里凡调用nameBoxes()的 7 条红,不按标签定位的 2 条(「渲染本身不写入」「作者不输入就保持无名」)绿。共 15 红。实测逐条一致:
变异 2 —— 把 select 的 id 从
SelectTrigger移回Select(Root)。 预判:只有 select 的 9 条红(5 命名 + 2 多实例 + 2 decoy),text/number/checkbox 17 条绿,sectionName整个文件绿(它从不按标签定位 select)。这一条专门证 Root 静默丢弃机制,而不只是「少了个属性」。实测:
注意变异 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-admin→ 136 文件 / 1399 通过 | 1 skipped。vitest run packages/app-shell/src/views/studio-design→ 17 文件 / 114 通过。turbo run type-check --concurrency=2→ 78/78 successful。node scripts/check-control-bytes.mjs→ OK(3904 tracked text files)。边界
_shared.tsx+ 两个测试文件 + changeset;PageBlockInspector.tsx(PageBlockInspector 自身的 chrome 字面量未过 t() —— 列表「Add」按钮、Remove aria-label、Invalid JSON、两个 placeholder 在 zh-CN 下仍是英文 #3963/block-config.ts 的 8 个 placeholder 仍是英文显示文本 —— #3913 只搬了 label/addLabel/option,zh-CN 面板里的输入框提示还是 "lucide icon name" #3979 刚定型)一字未动,只改了它的测试定位器。BlockPropField没有格式/校验能力位 —— 所有标识符类字段(sectionname、tabkey、accordionvalue)只能靠 placeholder 陈述约定 #3912 的能力位面(pattern/validate),不碰 [components] 内建 select 的空选项/依赖门态分支把 host id 落在一个 div 上 —— label 的 for 指向不可 label 元素,仍是不关联的 #3991 的 components 内建 select 空态分支。@object-ui/app-shellpatch。顺手发现(未在本 PR 修)
同目录的
InspectorComboField.tsx是同族缺陷的第四个站点(label 兄弟节点、trigger 无 id),已另开 issue #3997 交 PM triage,本 PR 不夹带。