Skip to content

fix(fields): field:select 把 aria-invalid/aria-describedby/aria-required 送达真实控件(Select trigger),并加全注册表守卫 (#3306) - #3323

Merged
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-3306-select-aria
Aug 4, 2026
Merged

fix(fields): field:select 把 aria-invalid/aria-describedby/aria-required 送达真实控件(Select trigger),并加全注册表守卫 (#3306)#3323
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-3306-select-aria

Conversation

@xuyushun441-sys

@xuyushun441-sys xuyushun441-sys commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #3306

问题

field:select 把 DOM pass-through({...toDomProps(props)})展开在 Radix Select.Root 上。Select.Root 自身不渲染任何 DOM 元素,不认识的 prop 一律静默丢弃——表单渲染器送下来的 aria-invalidaria-describedbyaria-required 全部消失。实测:一个 required 的 select 提交失败后,红字错误信息视觉上正常显示,但真实控件(trigger)上没有任何 aria 状态——屏幕阅读器用户完全收不到「该字段处于错误状态」,也拿不到错误信息的关联。

这与 #3222(7 个 widget 的 aria-invalid 从未被算出)和 #3290aria-required 声明了没送达)同属本仓视为一等缺陷的「declared ≠ delivered」失败类。

修复

packages/fields/src/widgets/SelectField.tsx(单选分支):

  • DOM pass-through 改为落在 SelectTrigger 上——真实可聚焦的 <button role="combobox">,用户与辅助技术实际交互的元素。aria-invalid / aria-describedby / aria-required / data-* / id / className / tabIndex / 焦点事件由此送达真实 DOM。
  • 两个 key 刻意留在 Root 上:
    • name——Root 唯一真正消费的白名单 key(转发给参与原生表单提交的隐藏 <select>);放到 trigger 上只会挂在一个非 submitter 的 <button> 上;
    • disabled——Root 是单一权威(同时禁用 trigger、选项交互与隐藏 select);再转发给 trigger 会给这个状态第二个 OR 合并的作者。
  • spread 之后显式写 aria-invalid={!!error}字段 widget 的错误提示键:spec 声明 error,objectui 渲染 errorMessage(declared ≠ enforced) #3222 纪律:error 是 spec FieldWidgetPropsSchema 的发布槽位;!!undefined 必须产出显式的 "false"——字段有效时明说有效,而不是沉默)。

没有放宽 toDomProps(白名单原样),没有在消费端加任何容错回退。

顺带核查(issue 要求的同结构扫描)

逐一核查了全部 46 个注册 widget 的 spread 落点:只有 SelectField 把 DOM pass-through 展开到非 DOM 的 Radix Root 上。Boolean/Date/DateTime/Time 等其余 toDomProps 使用者都落在真实 DOM 控件(Input / Radix Checkbox·Switch 的真实 <button>)上。

守卫(issue 要求的「不再逐个复发」)

新增全注册表 sweep packages/fields/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx(范式同 #3291 的 DOM leak sweep):

  • 46 个注册类型 × 真实表单 + 真实 react-hook-form 校验失败(presence 语义 required + null,cloud#972),先自证 "is required" 消息已渲染再读 aria(无错误的错误变体什么都测不到);
  • 送达者断言行内存在 aria-invalid="true",且失败前不存在(不许提前报警);
  • 实测 29 个类型未送达,登记进棘轮账本 NOT_YET_DELIVERED按反方向断言:修好任何一个,它的账本行变红、强制移出账本——账本只能收缩,新类型默认走正向断言,不能悄悄进账本。29 个缺口已作为独立未认领 issue 记录:29 个注册 field widget 在校验失败后从不携带 aria-invalid —— #3306 全注册表守卫实测的账本 #3318
  • 注册表 parity 断言 + 账本条目存在性断言(改名/删除的 widget 不能留下守空的账本行)。

同时把 select 补进既有 widget-aria-invalid-e2e.test.tsx#3222 的守卫,当年漏掉 select 的那个),新增 3 条针对 trigger 的断言:aria-invalid 仅在失败后为 "true"(此前显式 "false")、aria-required 在位、aria-describedby 的 id 链闭合到渲染出的错误信息元素。

破坏验证实录

步骤 操作 结果
改坏 把 spread 还原回 Root、trigger 去掉 aria-invalid 3 条新断言全红(3 failed | 10 skipped
还原 git checkout 回修复态 13 passed 全绿
守卫自证(正向) 全注册表 sweep 以空账本首跑 29 个类型逐名红(每条失败信息点名 widget)
守卫自证(棘轮) 把已送达的 text 植入账本 其账本行红:field:text now delivers aria-invalid — remove it from NOT_YET_DELIVERED

验证

检查 结果
vitest run widget-aria-invalid-e2e.test.tsx 13 passed
vitest run widget-aria-invalid-registry-e2e.test.tsx 48 passed
vitest run widget-dom-leak-e2e.test.tsx#3313 的 gate,46×5) 233 passed
rebase(含 #3322)后 vitest run packages/fields 全包 46 文件 / 823 测试全绿(含上述三个文件)
turbo type-check / lint / build + 仓根全量 vitest 本容器两次重启未能在本地完成,交由 PR CI 把关

改动仅涉 packages/fields(widget + 测试)与一个 changeset(@object-ui/fields patch,fixed group,无 major)。未触碰 content/docs/releases/


🤖 Generated with Claude Code

https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa

claude added 2 commits August 3, 2026 19:26
…he Select trigger

Radix Select.Root renders no DOM element and silently drops every prop it
does not recognise, so the widget's DOM pass-through spread on Root threw
away the form renderer's aria-invalid / aria-describedby / aria-required.
A failed required select showed the red message while assistive tech was
told nothing (objectui#3306).

The spread now lands on SelectTrigger — the focusable button role=combobox
the user actually interacts with — with name kept on Root (it feeds the
hidden native select that takes part in form submission) and disabled kept
single-authority on Root. The widget also computes aria-invalid from the
published error slot, the objectui#3222 discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
… failed validation

Every registered widget type renders through the real form, drives a real
react-hook-form failure (presence-check required + null, cloud#972), proves
the message rendered, then asserts aria-invalid="true" exists inside the
field row. The 29 types measured not to deliver are pinned in a ratchet
ledger (NOT_YET_DELIVERED, tracked in objectui#3318) asserted in the
opposite direction, so fixing a widget forces its ledger entry out and the
ledger only shrinks. Paradigm of the objectui#3291 leak sweep: static
imports through withFieldCarrier, no React.lazy, popovers not opened.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
@vercel

vercel Bot commented Aug 3, 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 3, 2026 11:59pm

Request Review

@github-actions github-actions Bot added the tests label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 28.1 KB 350 KB
Entry file index-BFetZSFh.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.47KB 3.09KB
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) 18.38KB 4.49KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 3.65KB 1.42KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.25KB 0.53KB
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) 476.66KB 104.69KB
core (index.js) 2.25KB 0.80KB
create-plugin (index.js) 9.28KB 2.98KB
data-objectstack (index.js) 136.23KB 34.75KB
fields (index.js) 224.99KB 55.14KB
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.46KB 0.96KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 5.37KB 1.72KB
i18n (useObjectLabel.js) 26.14KB 6.07KB
i18n (useSafeTranslation.js) 3.26KB 1.44KB
layout (index.js) 38.53KB 10.71KB
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.05KB 1.53KB
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) 44.98KB 12.37KB
plugin-charts (index.js) 60.54KB 17.13KB
plugin-chatbot (index.js) 180.09KB 42.72KB
plugin-dashboard (index.js) 112.01KB 28.86KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 230.56KB 56.80KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 111.54KB 26.97KB
plugin-gantt (index.js) 162.25KB 39.55KB
plugin-grid (index.js) 185.08KB 49.04KB
plugin-kanban (index.js) 47.89KB 13.18KB
plugin-list (index.js) 104.94KB 25.32KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.55KB 10.59KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.34KB 2.82KB
plugin-view (index.js) 83.67KB 20.43KB
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) 19.28KB 6.38KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
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.46KB 1.21KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 0.20KB 0.18KB
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

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 4, 2026 00:04
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 49f7449 Aug 4, 2026
16 of 17 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-3306-select-aria branch August 4, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

field:select 从不向辅助技术播报校验状态 —— aria-invalid / aria-describedby / aria-required 被 Radix Select.Root 静默丢弃

2 participants