From 40a930f2edef12c177ff46822e9a142b0956f9de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 22:25:16 +0000 Subject: [PATCH] =?UTF-8?q?fix(metadata-admin):=20=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=99=A8=E4=B8=89=E4=B8=AA=E5=85=B1=E4=BA=AB=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=8E=9F=E5=AD=90=E7=9A=84=20label=20=E4=B8=8E=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E5=BB=BA=E7=AB=8B=E7=BC=96=E7=A8=8B=E5=85=B3=E8=81=94?= =?UTF-8?q?=20(#3994)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../inspector-shared-field-labels-3994.md | 41 +++ .../PageBlockInspector.sectionName.test.tsx | 31 +- .../inspectors/_shared.labels.test.tsx | 298 ++++++++++++++++++ .../metadata-admin/inspectors/_shared.tsx | 41 ++- 4 files changed, 401 insertions(+), 10 deletions(-) create mode 100644 .changeset/inspector-shared-field-labels-3994.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/_shared.labels.test.tsx diff --git a/.changeset/inspector-shared-field-labels-3994.md b/.changeset/inspector-shared-field-labels-3994.md new file mode 100644 index 000000000..ebfdbb376 --- /dev/null +++ b/.changeset/inspector-shared-field-labels-3994.md @@ -0,0 +1,41 @@ +--- +"@object-ui/app-shell": patch +--- + +Metadata-admin inspectors: the shared text / number / select field labels now name their control + +The three generic field atoms of every scoped inspector — `InspectorTextField`, +`InspectorNumberField`, `InspectorSelectField` — rendered a `Label` as a plain sibling of +their control, with no `htmlFor`, no `id` and no `aria-label` fallback. Label and control +were adjacent only visually: assistive tech announced an anonymous "edit box" / "combobox" +while the visible field name sat above it as unowned text, and clicking the label did +nothing. Measured before the fix, `getByLabelText('Group')` — the same `for`→id chain a +screen reader walks — found zero matches for all three. + +These atoms are consumed by 16 non-test modules (page-block, flow-node, report, dataset, +permission and object-field inspectors, plus the object-group inspector in Studio design), +so every inspector panel rendered nameless inputs the moment it opened. + +Each atom now mints its own id with `React.useId()` and closes the pair. The id is minted +inside the atom rather than taken as a prop deliberately: these atoms render in loops over +array items (`record:details.sections[i]`, `page:tabs.items[i]`) where every item repeats +the same label, which is precisely where a caller-supplied id collides — and a collision is +invisible, because both labels would still resolve, to the first control. `useId()` cannot +collide by construction; per-instance uniqueness is pinned rather than assumed. + +For the select the id lands on `SelectTrigger`, never on `Select`: Radix's `Select.Root` +renders no DOM element of its own, so an id handed to it is silently dropped and the +label's `for` dangles — the same mechanism objectui#3976 fixed one directory over. The +trigger renders the real `button[role=combobox]`, a labelable element, so one `for`/`id` +pair names it with no second `aria-labelledby` channel. `disabled` stays on Root (single +authority over trigger, items and the hidden native mirror) and a disabled select is still +named. + +`InspectorCheckboxField` was already correct — it uses a wrapping `label`, a valid +association that needs no id — and is untouched, serving as the positive control in the +tests. + +Follow-on for test authors: `PageBlockInspector.sectionName.test.tsx` located its section +name boxes by placeholder *because* `getByLabelText` could not reach them. That workaround +is gone; the boxes are located by their label, and the `snake_case` placeholder convention +keeps its own dedicated assertion. diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx index 21fefd12a..1043e475e 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.sectionName.test.tsx @@ -89,13 +89,19 @@ function committedSections(onPatch: ReturnType): Array` unassociated with the `` - * (no `htmlFor`/`id`), so `getByLabelText` cannot reach it — and locating by - * placeholder doubles as proof the snake_case hint reaches the DOM, which is - * the only convention affordance this field has. + * The section-name boxes, in section order. Located by their LABEL — the same + * `for`→id chain assistive tech walks, so the locator is now the accessible + * name and not a proxy for it. + * + * This used to locate by placeholder, because `InspectorTextField` rendered its + * `