Skip to content

fix(components): deliver aria-required to field controls (#3290) - #3298

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3290-aria-required
Aug 3, 2026
Merged

fix(components): deliver aria-required to field controls (#3290)#3298
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-3290-aria-required

Conversation

@xuyushun441-sys

@xuyushun441-sysxuyushun441-sys commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Closes#3290

必填状态过去只以「可访问名的一部分」存在——表单渲染器算出的 required(静态 required + requiredWhen CEL)只驱动 < FormLabel > 里的红星,而那颗红星带着 aria-label="required",于是状态经由 < label for > 关联被并进控件的可访问名,字段被读成「Title required」。把状态塞进名字有三处失效:按名字顺序朗读而非以状态播报、「只列必填项」这类导航看不见它、无 label 的字段(紧凑布局 / grid 内联编辑)根本不画星号因而信号彻底消失,且 requiredWhen 让必填是动态的——状态通道能表达翻转,名字不能。

注:本文中 < FormLabel > / < FormControl > 这类写法的尖括号后有意加了空格——GitHub 正文清洗器会把「< 紧跟字母」当 HTML 标签删掉(本 PR 初版正文已被吃掉三处,这是修正后的版本)。

裁决执行清单

裁决项落实
宿主一处加 'aria-required': required || undefinedform.tsx 转发进 widget 的 props(紧接 error: fieldState.error?.message
48 个 widget 零改动✅ 未改任何 widget;另经实测复核(见下「前置核对」第 2 条)
红星改 aria-hidden="true"✅ 同时移除 aria-label="required",状态通道成为唯一来源
加原生 required✅ 并加了一条回归栅栏测试钉死它

前置核对(三条都是复核,不是假设)

  1. 基线含 fieldschema 是同一个意思的两个载体,~25 个 widget 读 field || schema #3233(PR refactor(types,react,components,fields)!: converge the widget metadata carrier to field, retiring schema (#3233) #3296——worktree 基于 origin/main@6e794a19epackages/fields/src/withFieldCarrier.tsx 存在;renderFieldComponent 已是 field-widget / 裸名 SDUI 双路径的现状。
  2. 两条分支都收到 aria-required——stripRendererOnlyPropsstripRegisteredFieldProps 都是定长解构,aria-* 不在其中,因此原样透传。不止读代码:临时跑了一个真 widget(EmailField / PhoneField / UrlField / TextAreaField / CurrencyField / PercentField / TextField)穿真表单渲染器的检查,七个全部把 aria-required="true" 落到真实的 input / textarea 元素上,零 widget 改动。该临时文件已删除,未进提交。
  3. < FormControl > 确实不注入 aria-required——packages/components/src/ui/form.tsx 里的 Radix Slot 只注入 id / aria-describedby / aria-invalid。(附带更正 issue 正文一处措辞:全仓 grep aria-required 并非 0 处,plugin-formEmbeddableForm 和 console 的 ProfilePage 各有手写的一处;表单渲染器这条路径上确为 0,结论不变。)

为什么是 || undefined 而不是 String(required)

非必填字段应当完全没有该属性,而不是 aria-required="false"。后者合法但语义更嘈杂,且 requiredWhen 翻成 FALSE 时必须产出的正是「属性消失」。测试里单独钉住了这个拼写。

测试

新增 packages/components/src/renderers/form/__tests__/form-aria-required-delivery.test.tsx(9 条)。每条都做了 sabotage 验证——改坏见红、还原见绿:

Sabotage结果
删掉 'aria-required' 一行6 红
改成 String(required)2 红(「非必填不带属性」+「requiredWhen 翻回时消失」)
红星退回 aria-label="required"2 红(可访问名 + marker 断言)
补上原生 required1 红
两个 strip 函数吃掉 aria-required6 红

覆盖:内置分支、注册 widget 分支、非必填属性缺席、requiredWhen 动态翻转、无 label 的必填字段(老设计下信号完全不存在的那个场景)、可访问名不再含 "required"(用 toHaveAccessibleName,不是 querySelector)、红星仍渲染但在 a11y 树外、无原生 required

关于最后一条有个坑值得记:toBeRequired()不能用来断言「没有原生 required」——jest-dom 把 aria-required="true" 也算作 required,那个 matcher 无论修成哪样都会绿。改为直接断言原生属性缺席 + el.required === false

顺带改到 packages/components 之外的一处(是本改动自身的调用点修复,非范围外顺手修)

e2e/live/field-conditional-rules.spec.tsisRequired() helper 用 span[aria-label="required"] 定位红星——红星移出 a11y 树后这个选择器必然失效。红星因此获得 data-required-marker="true" 作为显式定位钩子(ADR-0054 C4 的稳定 locator 惯例),helper 改用它。a11y 属性不该兼任测试钩子,这正是这条 aria-label 能活这么久的原因。这是全仓唯一一处该选择器。

给邻接单的两条提示

  • form.tsx 仍有一批用户可见的硬编码英文(全屏文本编辑对话框、Submit/Cancel 默认值、required aria-label) #3272(form.tsx 硬编码英文):清单里的 aria-label="required" 字符串已随本 PR 消失——它现在是 aria-hidden 的纯视觉星号,不再有任何面向用户的文本需要翻译。届时请核对并从清单划掉。
  • packages/fields/src/widgets/types.ts:29 有一句注释仍在描述旧行为(「< FormLabel > 里带 aria-label="required"*」)。按本单范围栅栏未动 packages/fields;这是一处陈旧注释而非功能缺陷,留给维护者决定是单独收还是搭车下次改 fields 时顺手更正。

changeset

已加 .changeset/aria-required-reaches-the-control.md@object-ui/components: patch)。依据:AGENTS.md 说纯 bug 修复不必写,但本改动用户可见——每个必填控件多出一个 DOM 属性、可访问名从「Title required」变为「Title」——且移除 aria-label="required" 对任何按它选择的下游是破坏性的(本仓 e2e 就是活证)。定 patch 而非 minor:无 API、无 props 契约、无 schema 变更,红星对视力用户的呈现一模一样。若维护者认为 DOM/a11y 行为变更应按仓规的「自身破坏性变更也标 minor」处理,改一个字即可。

验证输出

pnpm exec vitest run packages/components/src/renderers/form/__tests__/ packages/components/src/__tests__/form-renderers.test.tsx
Test Files 22 passed (22)
Tests 138 passed (138)
pnpm exec vitest run packages/components # 全量
Test Files 69 passed (69)
Tests 530 passed (530)
pnpm exec vitest run packages/fields/src/__tests__/widget-aria-invalid-e2e.test.tsx # 跨包真 widget 集成
Test Files 1 passed (1)
Tests 10 passed (10)
pnpm --filter @object-ui/components type-check # 通过,无输出
pnpm --filter @object-ui/components lint # 0 errors(760 warnings 均为既有)
node scripts/check-changeset-no-major.mjs # ✅ No changeset declares a `major` bump.

未动 packages/spec、任何 widget、packages/fields / types / reactcontent/docs/releases/

The form renderer computed a correct `required` (static flag merged with the
`requiredWhen` CEL verdict) and spent it on one thing: the red asterisk in
`<FormLabel>`. That asterisk carried `aria-label="required"`, so the ONLY path
from the computed state to assistive tech was `<label for>` folding it into the
control's ACCESSIBLE NAME — the field was announced "Title required".
A state smuggled through a name is broken three ways: it is read in name order
rather than announced as a state and "list the required fields" navigation
cannot see it; a field rendered without a `label` (compact layouts, inline grid
editing) draws no asterisk so the signal vanishes entirely; and `requiredWhen`
makes required dynamic, which a state channel expresses and a name does not.
The host now forwards `'aria-required': required || undefined` to the widget
props. No widget changed: `aria-required` is already declared and typed on
`FieldWidgetComponentProps & AriaAttributes`, every widget forwards leftover
props to its control, and neither `stripRegisteredFieldProps` nor
`stripRendererOnlyProps` touches `aria-*`. Verified against seven real widgets.
The asterisk becomes `aria-hidden="true"` so the state is announced once, not
twice, and picks up `data-required-marker` as an explicit locator — the one
in-repo e2e that selected on its `aria-label` moves to that hook.
Deliberately NOT the native `required` attribute: it would arm the browser's
constraint-validation bubble alongside react-hook-form's messages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
@vercel

vercelBot commented Aug 3, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 3, 2026 1:12pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.47KB3.09KB
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)18.38KB4.49KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)3.65KB1.42KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.25KB0.53KB
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)476.66KB104.69KB
core (index.js)2.25KB0.80KB
create-plugin (index.js)9.28KB2.98KB
data-objectstack (index.js)136.23KB34.75KB
fields (index.js)223.38KB54.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.46KB0.96KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)5.37KB1.72KB
i18n (useObjectLabel.js)26.14KB6.07KB
i18n (useSafeTranslation.js)3.26KB1.44KB
layout (index.js)37.96KB10.54KB
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.05KB1.53KB
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)44.98KB12.37KB
plugin-charts (index.js)60.54KB17.13KB
plugin-chatbot (index.js)180.09KB42.72KB
plugin-dashboard (index.js)112.01KB28.86KB
plugin-designer (index.js)210.51KB42.51KB
plugin-detail (index.js)230.56KB56.80KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)111.49KB26.95KB
plugin-gantt (index.js)162.25KB39.55KB
plugin-grid (index.js)185.08KB49.04KB
plugin-kanban (index.js)47.89KB13.18KB
plugin-list (index.js)104.94KB25.32KB
plugin-map (index.js)16.81KB5.24KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.55KB10.59KB
plugin-timeline (index.js)25.76KB7.33KB
plugin-tree (index.js)8.34KB2.82KB
plugin-view (index.js)83.67KB20.43KB
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)19.28KB6.38KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.02KB0.55KB
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.46KB1.21KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)0.20KB0.18KB
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

@xuyushun441-sys
xuyushun441-sys marked this pull request as ready for review August 3, 2026 13:18
@xuyushun441-sys
xuyushun441-sys added this pull request to the merge queueAug 3, 2026
Merged via the queue into main with commit 680080aAug 3, 2026
17 checks passed
@xuyushun441-sys
xuyushun441-sys deleted the claude/issue-3290-aria-required branch August 3, 2026 13:19
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Aug 4, 2026
… five renderer sites outside the object form (objectstack-ai#3299) (objectstack-ai#3340)
Five renderers computed `required`, painted a red asterisk, and put NO
required state on the control — the same defect objectstack-ai#3290/objectstack-ai#3298 fixed in
form.tsx, at the five sites its scope fence excluded. Four of them were
worse than pre-fix form.tsx: the bare `*` had no `aria-hidden`, so the
only "signal" was a literal asterisk folded into the accessible name
("Title asterisk").
Every site converges on the reference shape (EmbeddableForm.tsx:481,489):
- control: `aria-required={required || undefined}` — absence, not
"false", for optional fields;
- asterisk: `aria-hidden="true"` — announced once, as a state, never as
part of the name.
Sites:
- app-shell/ActionParamDialog: both branches (boolean row + default);
the state rides the widget props and reaches the DOM through the
fields' `toDomProps` whitelist, which forwards `aria-*` by prefix.
- app-shell/CreateViewDialog: display label, machine name, and each
type-specific required-field selector (statically required, so a
literal `aria-required="true"`).
- components/custom/action-param-dialog: all five typed branches; for
select the state lands on the Radix trigger (the focusable combobox —
the root renders no element).
- components/custom/field: FieldContainer injects `aria-required` via
the same Slot that already injects id/aria-describedby/aria-invalid —
one line covers every consumer. Its asterisk is a CSS pseudo-element,
which never enters the a11y tree, so no marker change is needed there.
- plugin-detail/InlineCreateRelated: create-tab inputs.
Deliberately NOT native `required` (objectstack-ai#3290 ruling): each host runs its
own validation; native required would arm the browser's constraint-
validation bubble beside it. The SDUI controls that already set native
`required` (renderers/form/{input,textarea,select,checkbox}.tsx,
basic/text-input.tsx) are excluded per the issue — no second validator
there, so their channel is already correct.
Tests per site, with mutation verification on the two load-bearing
deliveries (FieldContainer's Slot injection; the app-shell dialog's
widget-chain delivery): attribute removed -> red, restored -> green.
Fixesobjectstack-ai#3299
Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa
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

Development

Successfully merging this pull request may close these issues.

aria-required 从未到达任何输入控件——必填状态只以「可访问名的一部分」存在

2 participants

@xuyushun441-sys@claude