Uh oh!
There was an error while loading. Please reload this page.
fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) - #3978
Merged
Conversation
…of an inert `for` (#3961) Six widgets rendered a visible group label that was the accessible name of NOTHING. Two shapes, one outcome: `address` / `geolocation` replaced the host's id with their own sub-input ids (#3343), so the label's `for` named an id no element carried; `checkboxes` / `radio` / `rating` / `file` kept the id but on a `div`, where `label for` is inert HTML (`HTMLLabelElement.control` is null — it activates nothing and contributes no name). The WAI-ARIA group pattern, driven by a declaration rather than by the host guessing at widget DOM: - core: `ComponentMeta.labelling?: 'control' | 'group'`. Optional and additive; absent means `'control'`. - components: the form renderer branches on it. A `'group'` field's `FormLabel` publishes an `id` and drops its `for`; the widget receives `aria-labelledby`. The single-control path emits not one changed attribute (conditional spreads), so no field gains a second naming channel. `ui/form.tsx` is untouched — `FormLabel` spreads props after its own `htmlFor`, so both halves travel as ordinary props. - fields: the six audited widgets declare `labelling: 'group'`. address / geolocation move only the host id to the group container; checkboxes / rating answer with `role="group"`; radio keeps Radix's more specific `radiogroup`; file takes the name on its dropzone with no invented group layer (one control, merely not a labelable element). No new key in the widget props contract: `aria-*` is already declared on it and forwarded by `toDomProps`, the channel `aria-required` (#3290) uses. Unchanged on purpose: sub-labels keep naming their own inputs (an `aria-labelledby` on the first sub-input would OVERRIDE its own label), `aria-describedby` stays on the first focusable sub-input (#3318), the sub-input ids of #3343 do not move, and standalone rendering emits no role and no IDREF. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
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。#3961 按 B 案裁决落地。 核验记录(head
衍生:#3975(multiselect 同形 XS,刻意不并入 —— 保持已验证面干净,合入后即派)、#3976(内建 select 分支,同文件串行)、#3318 附着评论 —— 三条处置均得当。 Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 9, 2026 19:56
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 9, 2026
Open
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Aug 10, 2026
…不再悬空 (objectstack-ai#3976) (objectstack-ai#3992) 手写 form schema 里的 `{ name: 'status', label: 'Status', type: 'select', options: [...] }`, 可见的「Status」标签指向一个没有任何元素携带的 id。修前实测(probe,未提交): `label for="_r_1_-form-item"` 在文档里找不到宿主,`getByLabelText(/status/i)` 0 命中, trigger 按钮上没有 id、没有 aria-describedby、没有 aria-invalid,连 `required: true` 的 aria-required 也没有 —— 点标签什么都不会发生,屏幕阅读器读到的是一个匿名 combobox: 没有字段名、没有错误消息关联、没有必填状态。 成因是内建 select 分支把整个 DOM pass-through 展开到 Radix `Select.Root`。Root 自己不渲染 任何 DOM 元素,不认识的 prop 被静默丢弃 —— 而 `FormControl`(Radix Slot)下发的 id / aria-describedby / aria-invalid 与调用点下发的 aria-required 全都走这个展开点。 这正是 objectstack-ai#3306 在 widget 侧修掉的同一机制(`SelectField` 把 pass-through 落到 `SelectTrigger`); 两条路径分叉是因为 `BUILTIN_FIELD_TYPES` 含 'select':对象驱动的 field:select 解析到注册 widget,而手写的裸 type: 'select' 根本不查注册表,内建分支的同形缺陷一直留着。 修法是抽出 `BuiltinSelectControl`。组件边界不是风格选择而是必要条件:Slot 注入的是分支 返回的那个元素,只有组件才能接住这些 prop 并重新落位(与同文件 `BuiltinSelectEmptyState` 成为组件是同一理由)。pass-through 落到 `SelectTrigger` 渲染的 button[role=combobox], 并保留 objectstack-ai#3306 的两个白名单例外:`name` 留在 Root(Root 真正消费的唯一 key,转发给参与表单 提交的隐藏 select),`disabled` 留在 Root(单一权威,同时禁用 trigger / items / 隐藏 select)。 `ref` 随 pass-through 走,内建 select 不再是唯一一个 react-hook-form 无法聚焦的内建控件; 作者写的 className 现在也能到达 trigger(修前它到不了任何元素),与分支自己的触控高度用 cn() 合并而非互相覆盖。渲染哪个组件没有变,只变了 host props 的落点。 双路径各自钉住,互不干扰: - `packages/components/.../form-builtin-select-host-id.test.tsx`(12 条):内建路径的 for 解析到 trigger、可访问名、aria-required(必填有/可选无)、aria-invalid 修前 false 修后 true、aria-describedby 闭合到消息元素 id、name 仍只在 Root(trigger 上没有)、 disabled 仍由 Root 单一权威、只有 for 没有第二条 aria-labelledby 通道、两个 select 各自命名;外加分叉钉——注册表里确实存在 field:select 时裸 select 仍走内建分支—— 以及注册 widget 路径拿到 host props 的阳性对照。 - `packages/fields/.../select-label-association-e2e.test.tsx`(13 条):用真 `SelectField` 把 field:select 与裸 select 并排跑同一组保证(describe.each), 注册路径是必须保持绿的阳性对照,分叉钉断言 SelectField 的 trigger locator 不出现 在裸 select 的 DOM 里。 反向验证按预判成立(先写预判再跑,变异未提交):把 pass-through 还原展开到 `Select.Root` 后 13 红 12 绿,与预判逐条一致 —— 红的全是两个文件里内建路径的命名与状态钉,绿的是 name/disabled 留在 Root、两条分叉钉,以及整条 field:select 注册路径(objectstack-ai#3306 那半从未 经过 Root)。 同文件的 group labelling 分支(PR objectstack-ai#3978)未触碰,diff 不相交;内建 select 的 `resolveFieldLabelling` 仍是 'control',因为 trigger 是可 label 的 button,测试里把 「只有 for、没有 aria-labelledby」钉住了。 Co-authored-by: Claude <noreply@anthropic.com>
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#3961
复合/分组 field widget 的可见组标签,今天是零个元素的可访问名。按 PM 的范围裁决(issue 评论 5233281956)采 B 案一次做完:widget 声明
labelling,form 渲染器按声明改用 IDREF 关联。缺陷:两种形态,同一个结局
真表单 + 每字段一行,读每个 label 的
for与它解析到的元素(probe 未提交)。接线前:for指向一个没有元素携带的 id —— 点「Shipping Address」什么都不发生,组标签完全不在可访问性树里。for解析成功,但label[for]指向不可 label 的元素在 HTML 里是惰性的 ——HTMLLabelElement.control返回 null,既不激活任何东西也不贡献可访问名。所以两种形态的结局是同一个:右边两列都是 0。这比 issue 正文对 B 类「比 A 类轻」的描述更严格。
接线后(同一 probe,同一形状):
改动
packages/core—— 声明位。ComponentMeta加labelling?: 'control' | 'group',可选、增量,缺省即'control'(所有现存组件今天的行为)。'group'的含义写在注释里:渲染出的外层不是可 label 元素 —— 既包括真复合(一个容器下多个输入),也包括「唯一控件恰好不是可 label 元素」(file 的div[role=button]dropzone)。这是声明而非猜测:host 无法从 widget 恰好渲染出的 DOM 推断它。packages/components—— form 渲染器按声明分支。 group 字段的FormLabel发id、不发htmlFor;widget 收到aria-labelledby。单控件路径一个属性都没变(两处都是条件展开,连 key 都不进 props),所以没有任何字段多出第二条命名通道 —— #3290 / #3222 / #3952 反复钉的「一个事实一个作者」。⛔ui/form.tsx(Shadcn no-touch)零触碰:FormLabel在自己的htmlFor之后才展开 props,所以「给 label 一个 id」「拿掉它的 for」两半都能以普通 prop 传入。resolveFieldLabelling逐字镜像renderFieldComponent的解析:builtin 判定用原始 type(裸select走内建分支、根本不查注册表;field:select才解析到注册 widget),之后才剥field:前缀。否则一个 widget 的声明会去改一个并不由它渲染的内建控件的 label。packages/fields—— 六个 widget。 与FIELD_TYPES_SKIP_FALLBACK并列的FIELD_TYPES_GROUP_LABELLED声明这六个。逐 widget 处置(按 issue 评论 5233264043 的方案表):id与aria-labelledby从首个子输入移到组容器,补role="group"role="group"(名字已随groupDomProps到达)radiogroup(更具体的角色,且是aria-invalid的正确承载者)aria-labelledbywidget props 契约零新 key:
aria-*已声明在契约上(ReactAriaAttributes),两个 strip 都不碰前缀,toDomProps按前缀转发整族 —— 与aria-required(#3290)同一条通道。刻意不改的四件事
aria-labelledby会覆盖控件的label[for],所以把组名放到首个子输入上会把「Street Address」替换成字段名 —— issue 否掉选项 2 的那个「可访问名拼接」结局。aria-describedby留在首个可聚焦子输入(29 个注册 field widget 在校验失败后从不携带aria-invalid—— #3306 全注册表守卫实测的账本 #3318 的刻意选择):描述/错误必须在焦点落到可聚焦控件时播报,组容器不可聚焦。只挪了id,没整块搬。role键在 host 真的给了名字时才出现 —— 无名的 group 对辅助技术没有信息量。验证
vitest run packages/core packages/components packages/fields→ 251 files / 3573 tests 全绿。vitest run packages/app-shell packages/plugin-form packages/plugin-grid packages/plugin-detail packages/react→ 496 files / 4632 passed / 1 skipped,0 失败。turbo run type-check→ 78/78 successful;node scripts/check-control-bytes.mjsOK。反向验证(先预判再跑,变异均已还原):
role="group"→ 预判 rating 的组名断言翻红、standalone 断言保持绿 → 实测 4 红 32 绿,standalone 行保持绿。sub-inputs keep their own labels的红因来自 RTL 的getLabels:元素带aria-labelledby时它优先用 IDREF 作为该元素的 label 列表,于是「Street Address」不再命名街道框 —— 正是这一改动要避免的可访问名覆盖。for」那条不变量捕获(form 侧仍不发for),所以那条断言单独不足以覆盖,两组断言都必须在。"labelable": false的 diff —— 漏声明的复合 widget 会被 [fields] a boolean field's visible form label is associated with nothing — BooleanField overrides the control id with the field name, so the form's labelfordangles #3952 那类「label 必须解析到真控件」的钉子当场抓住,而不是静默降级。checkboxes→ 预判声明位钉两条翻红 → 实测expected undefined to be 'group'+ 集合断言两条红。顺带发现(均已另立,不在本 PR 修)
把 probe 扩到 18 个字段类型跑了一遍,发现三条超出本单裁定六个 widget的同族账:
multiselect与 checkboxes 逐字同形(host id 落在包裹 div 上,for惰性),不在 issue 实测表与 PM 裁定的六个内 → 标Blocked-by: #3961,修法是本 PR 机制上的两行。select分支把 host id 展开到 Radix Select.Root —— 手写 form schema 里type: 'select'的 labelfor全部悬空(#3306 的内建侧同形) #3976 表单渲染器内建select分支把 host id 展开到 RadixSelect.Root(非 DOM 宿主,静默丢弃),手写 form schema 的裸type: 'select'全部悬空。field:select,走已修好的注册 widget)。aria-invalid—— #3306 全注册表守卫实测的账本 #3318 评论signature与 slider 同形(整行没有任何带 id 的元素,因为它不转发任何 DOM pass-through)→ 落在 29 个注册 field widget 在校验失败后从不携带aria-invalid—— #3306 全注册表守卫实测的账本 #3318 完成范围,按「attach, don't scatter」在那边留实测,未另立单。Generated by Claude Code