Skip to content

fix(fields): group-labelled widget 的只读/零选项分支也消费 host label 的 IDREF (#3990) - #4002

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

fix(fields): group-labelled widget 的只读/零选项分支也消费 host label 的 IDREF (#3990)#4002
yinlianghui merged 1 commit into
mainfrom
claude/issue-3990-readonly-group-label

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3990

结论

七个 labelling: 'group' widget 只在可编辑分支消费 host 递下来的
aria-labelledby + host id。字段级 readonly: true 与零可选项两种状态走的是
提前 return,渲染在那个容器之前 —— 于是 form renderer 让 label 发布了自己的
id、撤掉了 for,而文档里没有任何元素引用这个 id。可见标签是 0 个元素的可访问名。

不是 #3961 / #3975 的回归:改那两单之前,同样这些状态发的是指向「无人携带该 id」的
for,一样什么都不命名。形状从悬空 for 变成无人消费的 IDREF,严重程度未变 ——
是那次修法没有覆盖到的状态(#3961 的钉子全部在可编辑态取样)。

逐 widget 实测(正文警告「写法各异不可推断」,所以七类逐个量)

一次性 probe(未提交,已删),真 form renderer + 裸注册 labelling: 'group',
逐字段数「引用 host label 已发布 id 的元素个数 / byLabelText / 被命名的 role」:

widget 只读+有值 只读+空值 零选项 可编辑(对照) mode:'view'(对照)
multiselect 0 → 1 0 → 1 0 → 1 1 → 1 group 1 → 1 group
checkboxes 0 → 1 0 → 1 0 → 1 1 → 1 group 1 → 1 group
radio 0 → 1 0 → 1 0 → 1 1 → 1 radiogroup 1 → 1 radiogroup
rating 0 → 1 0 → 1 不适用(无选项) 1 → 1 group 1 → 1 group
file 0 → 1 0 → 1 不适用 1 → 1 button 1 → 1 button
address 0 → 1 0 → 1 不适用 1 → 1 group 1 → 1 group
geolocation 0 → 1 0 → 1 不适用 1 → 1 group 1 → 1 group

即:七类在每个只读态上读数完全一致(全 0),并非只有正文实测过的
multiselect / checkboxes;可编辑态与整表单 mode: 'view' 两列改前改后都是 1(view
不走 widget 的 readonly 分支,与正文一致)。

处置(沿既定 group 机制)

新增 packages/fields/src/widgets/toHostGroupProps.ts:只读面统一只带两个「整字段」
—— host idaria-labelledby —— 外加让它们生效的 role="group",一处拼写。

窄化是刻意的,不是偷懒:只读面没有可聚焦控件,aria-describedby /
aria-required / disabled / 焦点回调在那里无人宣读;而 name 落到 div 上正是
#3291 在扫的泄漏;把整份 toDomProps 铺下去还会用 host 的 className 盖掉 widget
自己的(EmptyValue / FileField / AddressField 的 className 都留在 props 里)。
role 必须随行:role-less 的 div / span 上 aria-labelledby 命名的是 nothing
(generic 禁止 author name),这正是本单量到的形状。

逐 widget 落点:

widget 只读+有值落点 只读+空值落点 零选项落点
multiselect chip 行容器 EmptyValue 本身 OptionsEmptyState
checkboxes 已选 Badge 行容器 EmptyValue 本身 同上
radio 选中项文本 span EmptyValue 本身 同上
rating 星星行容器 同左(无空态) 不适用
file 文件名行容器 EmptyValue 本身 不适用
address 格式化单行 span EmptyValue 本身 不适用
geolocation 图标+坐标行容器 同左(占位符在容器内) 不适用

两处 role 与可编辑态故意不同,因为渲染的根本不是同一个面:radio 可编辑答
radiogroup,只读面里一个 radio 都不剩;file 可编辑答 button(dropzone),
只读面里没有 dropzone,只有文件名。共享的「无可选项」框对 checkboxes / radio /
multiselect 答 group;单选 select 不属于 group-labelled(label 仍是可用的 for),
该框对它一个属性都不多发 —— 这条有阳性对照钉。

顺带把 AddressField / GeolocationField 的可编辑容器也换成同一个 toHostGroupProps
(它们此前手写 id + 条件 role + aria-labelledby,语义与输出完全等价),这样
同一文件里只读面与可编辑面不可能对同一个问题给出两种答案。

无缺口/豁免

没有豁免项:七类的每个只读渲染路径都找到了现成的承载元素,没有新增任何 DOM 节点
一处如实记录的取舍:hosted 且空值时 EmptyValue 就是整块只读面,于是它自己带上这三个
属性,它原有的 aria-label(“No value”)按 accname 被 aria-labelledby 压过 ——
这是想要的结果:在它此前的 generic role 上 author name 本就被禁止、从未暴露,所以
真实选择是「字段名」还是「没有名字」。standalone 时该占位符不带 role,行为不变。

钉子(64 个,新文件 composite-group-label-readonly-e2e.test.tsx)

  • 逐 widget 只读+有值:getAllByRole('group', { name }) 前 0 / 后 1,且 byLabelText 同元素;
  • 逐 widget 只读+空值:同上,并按三种真实形态分别断言(占位符即本体 / 占位符在容器内 /
    rating 无空态);
  • 逐 widget 只读态:host id 与 name 落在同一元素(id-form-item 结尾)、label 无
    for、字段内无悬空或 inert 的 for、只读面不带 name / aria-invalid;
  • 三个选项 widget 零选项态:命名元素就是 OptionsEmptyState 框本身(testId 断言);
    以及 readonly 与零选项同时成立时 readonly 分支胜出且仍被命名;
  • 可编辑态回归钉:逐 widget 同时断言可编辑角色(radio=radiogroup / file=button / 其余
    group)与只读角色 group,任何「统一角色」的后续改动都必须来这里说明它改了什么;
  • standalone 回归钉:逐 widget 只读渲染无 role、无 IDREF、无 id;SelectField 零选项框
    无 role / 无 IDREF / 无 id(共享组件新增 prop 的阳性对照)。

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

  • 变异 A:撤掉 MultiSelectField 只读 chip 行的展开。预判红在 multiselect 的 4 条
    只读钉 + 「七类同表」+ 漂移钉 = 6 条,不动 EmptyValue / 零选项 / standalone。
    实测:Tests 6 failed | 58 passed,失败项与预判逐条一致。
  • 变异 B:撤掉 CheckboxesField 传给 OptionsEmptyStatehostGroupProps预判只红
    「checkboxes: the unfillable-options box is the named group」1 条。
    实测:Tests 1 failed | 63 passed

两次都是经典方向(撤掉即翻红),没有出现计数型或反转型的情况 —— 因为这些钉断言的是
「被命名的元素个数从 0 到 1」这个谓词,而不是某个下游计数。

验证

  • pnpm --workspace-concurrency=2 --filter '@object-ui/fields^...' build → 通过(先行,
    新 worktree 的依赖产物)。
  • 仓根 pnpm exec vitest run packages/fields --maxWorkers=2Test Files 73 passed (73),
    Tests 1160 passed (1160)
  • 消费半径:仓根 pnpm exec vitest run packages/components/src/renderers/form --maxWorkers=2
    Test Files 33 passed (33),Tests 182 passed (182)(group-labelling 是 form renderer
    与 widget 的联合机制,声明位那侧一并跑过)。
  • 仓根 pnpm exec turbo run type-check --concurrency=2Tasks: 78 successful, 78 total
  • node scripts/check-control-bytes.mjs → OK(3902 个跟踪文本文件);另对本 PR 触碰的
    文件做了越界自扫(grep -naP 控制字符类)→ 无命中。
  • eslint 触碰文件:0 error(仅既有风格 warning,_ 前缀丢弃变量与 any)。

changeset:@object-ui/fields patch。

边界:只动 packages/fields/src/widgets/** + 测试 + changeset;未碰 form.tsx / index.tsx
的声明集合(机制已定型),未碰 content/docs/releases/


Generated by Claude Code

…3990)

七个 `labelling: 'group'` widget 只在可编辑分支消费 host 递下来的
`aria-labelledby` + host id;字段级 `readonly: true` 与零可选项两种状态走的是
提前 return,渲染在那个容器之前,于是 label 发布了 id 却没有任何元素引用它。

实测(改前的 main,真 form renderer + 裸注册,逐字段数引用该 id 的元素):

    multiselect  readonly+value   consumers=0  byLabelText=0  named=0
    multiselect  readonly+empty   consumers=0  byLabelText=0  named=0
    multiselect  zeroOptions      consumers=0  byLabelText=0  named=0
    multiselect  editable         consumers=1  byLabelText=1  named=1

七类在每个只读态上都是同一读数,改后都是 1。不是 #3961/#3975 的回归:那之前
同样这些状态发的是指向无人携带 id 的 `for`,一样什么都不命名 —— 形状从悬空
`for` 变成无人消费的 IDREF,严重程度未变。

每个只读面现在只带两个「整字段」键(host `id` 与 `aria-labelledby`)加上让它们
生效的 `role="group"`,统一走新的 `toHostGroupProps`。窄化是刻意的:只读面没有
可聚焦控件,`aria-describedby` / `aria-required` / `disabled` / 焦点回调在那里
无人宣读,而 `div` 上的 `name` 正是 #3291 扫的泄漏。

`radio`(可编辑 `radiogroup`)与 `file`(可编辑 `button`,即 dropzone)在只读态
答的是 `group`,因为那两个面里一个 radio、一个 dropzone 都不剩;共享的
「无可选项」框对 checkboxes / radio / multiselect 同样答 `group`,单选 `select`
不属于 group-labelled,继续什么都不发。

standalone(网格内联编辑器、裸 SDUI 节点)不递这两个键,输出保持逐字节不变,
包括 `EmptyValue` 占位符本身。
@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:42pm

Request Review

@github-actions github-actions Bot added the tests label Aug 9, 2026
@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-Bbp351gZ.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) 228.44KB 56.66KB
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。#3990 落地 —— group-labelled a11y 家族(#3961#3975#3990)三态全收口。

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

  1. 逐 widget 实测后处置,零推断:七类 × 三态(只读+有值/只读+空值/零选项)全部 0→1 实测表在案,无豁免(每个只读路径都有现成承载元素、零新增 DOM);正文警告的「写法各异不可推断」被逐个测量兑现。
  2. 统一拼写 helper 的窄化正确:toHostGroupProps 只带整字段两键 + role —— 只读面无可聚焦控件,describedby/required/name 在此无人宣读且 name 正是 字段 widget 把渲染器专用 props 原样展开到 DOM——schema="[object Object]" 出现在真实输入框上 #3291 泄漏;Address/Geolocation 可编辑容器换同一 helper(同文件两面不可能给两种答案)—— 超字面范围但目的正当、输出等价有既有钉覆盖、可拆卸,接受。
  3. 只读+空值(EmptyValue)态的扩量正确:与有值态同源同缺陷,不修则跨态不变量写不成;EmptyValue 的 aria-label 被压过的取舍按 ARIA 1.2 论证(诚实标注基于规范非真浏览器实测)。
  4. 64 钉含漂移钉与阳性对照(可编辑角色逐 widget 同断言、SelectField 零选项框无三键);双变异经典方向精确命中(6 红/1 红)。
  5. 门与规程:fields 全量 1160 + form 消费面 182 测试绿;type-check 78/78;控制字节门 + 自扫;changeset patch 含取舍记录;fable 0;⛔ form.tsx/index.ts/releases/ 零触碰。
  6. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 22:47:55Z)。

concerns 采信:单成员 group 语义偏弱但是唯一不撒谎的可命名角色(已注释);「只递 id 的第三方 host」新增行为面方向正确。out-of-scope #4005(只读态 describedby 无消费者,Blocked-by 本单;首次噪声对照行已自行作废 —— 证据卫生好)由 PM 分诊(另评)。本单合入后 #3986(select multiple 路径)解锁可派。


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants