Skip to content

fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961) - #3978

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3961-composite-group-label
Aug 9, 2026
Merged

fix(fields,components): 复合/分组 field widget 的组标签改用 IDREF 关联(#3961)#3978
yinlianghui merged 1 commit into
mainfrom
claude/issue-3961-composite-group-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3961

复合/分组 field widget 的可见组标签,今天是零个元素的可访问名。按 PM 的范围裁决(issue 评论 5233281956)采 B 案一次做完:widget 声明 labelling,form 渲染器按声明改用 IDREF 关联。

缺陷:两种形态,同一个结局

真表单 + 每字段一行,读每个 label 的 for 与它解析到的元素(probe 未提交)。接线前:

address for=…-form-item -> MISSING byLabelText=0 byRole+name=0
geolocation for=…-form-item -> MISSING byLabelText=0 byRole+name=0
checkboxes for=…-form-item -> div byLabelText=0 byRole+name=0
radio for=…-form-item -> div[radiogroup] byLabelText=0 byRole+name=0
rating for=…-form-item -> div byLabelText=0 byRole+name=0
file for=…-form-item -> div[role=button] byLabelText=0 byRole+name=0

所以两种形态的结局是同一个:右边两列都是 0。这比 issue 正文对 B 类「比 A 类轻」的描述更严格。

接线后(同一 probe,同一形状):

address for=(none) ownId=…-group-label byLabelText=1(div[role=group]) byRole+name=[group:1]
geolocation for=(none) ownId=…-group-label byLabelText=1(div[role=group]) byRole+name=[group:1]
checkboxes for=(none) ownId=…-group-label byLabelText=1(div[role=group]) byRole+name=[group:1]
radio for=(none) ownId=…-group-label byLabelText=1(div[role=radiogroup]) byRole+name=[radiogroup:1]
rating for=(none) ownId=…-group-label byLabelText=1(div[role=group]) byRole+name=[group:1]
file for=(none) ownId=…-group-label byLabelText=1(div[role=button]) byRole+name=[button:1]
text/image/location/tags/color/code/qrcode/user ← 逐字不变(`for` 仍指向真控件)

改动

packages/core —— 声明位。ComponentMetalabelling?: 'control' | 'group',可选、增量,缺省即 'control'(所有现存组件今天的行为)。'group' 的含义写在注释里:渲染出的外层不是可 label 元素 —— 既包括真复合(一个容器下多个输入),也包括「唯一控件恰好不是可 label 元素」(file 的 div[role=button] dropzone)。这是声明而非猜测:host 无法从 widget 恰好渲染出的 DOM 推断它。

packages/components —— form 渲染器按声明分支。 group 字段的 FormLabelid、不发 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 的方案表):

widget处置代码改动
address只把 host idaria-labelledby 从首个子输入移到组容器,补 role="group"
geolocation同上(latitude 保留 subId)
checkboxes现有包裹 div 补 role="group"(名字已随 groupDomProps 到达)
rating同上
radio保留 Radix 的 radiogroup(更具体的角色,且是 aria-invalid 的正确承载者)(仅注释)
file不硬套 group:唯一控件是 dropzone,它自己接 aria-labelledby(仅注释)

widget props 契约零新 key:aria-* 已声明在契约上(React AriaAttributes),两个 strip 都不碰前缀,toDomProps 按前缀转发整族 —— 与 aria-required(#3290)同一条通道。

刻意不改的四件事

  1. 子标签仍命名自己的输入。aria-labelledby覆盖控件的 label[for],所以把组名放到首个子输入上会把「Street Address」替换成字段名 —— issue 否掉选项 2 的那个「可访问名拼接」结局。
  2. aria-describedby 留在首个可聚焦子输入(29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318 的刻意选择):描述/错误必须在焦点落到可聚焦控件时播报,组容器不可聚焦。只挪了 id,没整块搬。
  3. AddressField / GeolocationField 子输入使用硬编码固定 id(street/city/latitude…)——同表单出现两个该类型字段即产生重复 DOM id #3343 的子输入 id 不动。
  4. standalone 逐字不变(inline grid editor、裸 SDUI 节点:没人下发 id、也没有 host label 可指):不发 role、不发 IDREF。role 键在 host 真的给了名字时才出现 —— 无名的 group 对辅助技术没有信息量。

验证

  • vitest run packages/core packages/components packages/fields251 files / 3573 tests 全绿
  • 消费半径(渲染这六个 widget 的其它 host):vitest run packages/app-shell packages/plugin-form packages/plugin-grid packages/plugin-detail packages/react496 files / 4632 passed / 1 skipped,0 失败
  • turbo run type-check → 78/78 successful;node scripts/check-control-bytes.mjs OK。
  • 新增钉子:fields 侧 e2e 36 例(每个 widget 两方向 + 组名不吞子名 + standalone 不回退)、声明位 25 例、components 侧 form 分支 9 例。

反向验证(先预判再跑,变异均已还原):

  1. 撤掉 rating 的 role="group" → 预判 rating 的组名断言翻红、standalone 断言保持绿 → 实测 4 红 32 绿,standalone 行保持绿。
  2. 把 address held-back 的两个键放回街道输入 → 预判组名断言 + 「子标签保持自己的名字」翻红 → 实测 5 红,其中 sub-inputs keep their own labels 的红因来自 RTL 的 getLabels:元素带 aria-labelledby 时它优先用 IDREF 作为该元素的 label 列表,于是「Street Address」不再命名街道框 —— 正是这一改动要避免的可访问名覆盖。⚠️ 同时实测到:这个方向不会被「无悬空 for」那条不变量捕获(form 侧仍不发 for),所以那条断言单独不足以覆盖,两组断言都必须在。
  3. 「不声明会响」演示:e2e 里让 checkboxes 漏声明 → 预判组名 3 条 + label 关联不变量翻红 → 实测 5 红,含 "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 label for dangles #3952 那类「label 必须解析到真控件」的钉子当场抓住,而不是静默降级。
  4. 真实声明集合里删掉 checkboxes → 预判声明位钉两条翻红 → 实测 expected undefined to be 'group' + 集合断言两条红。

顺带发现(均已另立,不在本 PR 修)

把 probe 扩到 18 个字段类型跑了一遍,发现三条超出本单裁定六个 widget的同族账:


Generated by Claude Code

…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
@vercel

vercelBot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 9, 2026 7:46pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.1 KB350 KB
Entry fileindex-B2p3T6Di.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.66KB3.13KB
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)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
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)483.34KB106.60KB
core (index.js)3.00KB1.14KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)139.61KB35.99KB
fields (index.js)227.92KB56.45KB
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)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
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.32KB1.64KB
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)45.23KB12.45KB
plugin-charts (index.js)61.49KB17.48KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.39KB30.67KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)237.03KB59.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)112.29KB27.16KB
plugin-gantt (index.js)162.55KB39.57KB
plugin-grid (index.js)187.90KB49.79KB
plugin-kanban (index.js)48.53KB13.38KB
plugin-list (index.js)109.73KB26.55KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.58KB10.58KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
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)22.31KB7.59KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
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.71KB1.34KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
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

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3961 按 B 案裁决落地。

核验记录(head a02ad8567,基 62c644168,实物核验 + CI 亲读):

  1. B 案完整落地(实物核验 13 文件全在裁决面):ComponentMeta.labelling 声明位、FIELD_TYPES_GROUP_LABELLED 审计集合、form 侧 resolveFieldLabelling 逐字镜像渲染解析(裸 select 走内建分支不受 widget 声明影响 —— 有钉);ui/form.tsx 零触碰确认(diff 不含该文件),单控件路径零改动(条件展开连 key 都不进 props)。
  2. 逐 widget 处置与前次报告方案表一致:address/geolocation 只挪 id、describedby 留首个子输入(29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318 的刻意选择被尊重);radio 保留 radiogroup、file 不硬套 group(零代码仅注释,注释防「清理」误删展开点 —— 合理)。
  3. 「不声明会响」从承诺变断言:双演示(e2e 漏声明 5 红含 labelable:false diff + 真实集合删项 2 红)+ components 侧正向钉。这正是 B 案胜过 A 案的核心论证的实测化。
  4. 反向验证三次 + 机制深挖:变异 2 的红因追到 @testing-library/dom getLabels 的 aria-labelledby 优先机制(RTL 报错文案有误导,已在 PR 写明真实机制);同一变异下另一组断言不被捕获的方向如实记录并保留双组 —— 处置正确。
  5. 命名裁定:'group' 字面对 file 场景的不精确已由 dev 诚实备注 —— 维持现名:core 注释已把语义写成「渲染外层不可 label ⇒ host 以 IDREF 关联」,改名收益不抵二次 churn;唯一读点/产点已记录,未来若扩声明面再议。
  6. 门与规程:强制面 3573 测试绿 + 消费半径 4633 绿(#5046 教训覆盖);type-check 78/78;控制字节门 + 未 tracked 新文件门外自扫;changeset core minor(增量 API)+ components/fields patch 论证成立;fable 0(trailer 去型号名处置正确);又一次无 token curl 假绿自查并弃用 —— 纪律执行到位。
  7. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 19:52:35Z、Type Check 19:52:45Z)。基落后 main 数合:改动面与其间合入的 PR3969/3970 无交集,交合并队列重算,无需 update-branch。

衍生:#3975(multiselect 同形 XS,刻意不并入 —— 保持已验证面干净,合入后即派)、#3976(内建 select 分支,同文件串行)、#3318 附着评论 —— 三条处置均得当。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 19:56
@yinlianghui
yinlianghui added this pull request to the merge queueAug 9, 2026
Merged via the queue into main with commit c3b01a7Aug 9, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3961-composite-group-label branch August 9, 2026 19:56
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude