Skip to content

fix(fields): drive the grid widget's per-cell aria-invalid from form-level failure - #5429

Merged
os-support-ai merged 2 commits into
mainfrom
claude/issue-3318-grid-widget-aria-invalid
Aug 20, 2026
Merged

fix(fields): drive the grid widget's per-cell aria-invalid from form-level failure#5429
os-support-ai merged 2 commits into
mainfrom
claude/issue-3318-grid-widget-aria-invalid

Conversation

@os-support-ai

@os-support-aios-support-ai commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3318

Also closes#5223, deliberately — flagging it here rather than leaving it to be discovered at merge time. That card's proposed shape is verbatim what the sweep tightening below implements ("require that the element carrying aria-invalid be focusable"), including its "measure before committing, then triage whatever goes red" caveat, and it says the tightening should be settled with this card rather than before it. Nothing went red. PM: if you would rather keep #5223 open for a separate pass, drop that keyword before landing.

Implements the maintainer's 2026-08-19 ruling on the card (verbatim: 「全部接受」) — Option C for the residual grid row, paired with the sweep tightening, measured first.

First: the card body's sizing is stale, and this corrects it

Per the ruling's secondary item. The card body still reads "29 of 46 widgets" and lists 29 types. That reading predates two merged PRs and should not be used to size anything:

ledgernote
card body (2026-08-03)29 of 46stale
after #334520 cleared, 9 left
after #4933slider cleared, 7 reclassified, 1 left
this PR0 left

The real denominator is 45, not 46owner was retired by #4814. Verified on origin/main at bd977f86f before editing anything: NOT_YET_DELIVERED held exactly one row, grid, and the sweep ran 50/50 green. The ledger file remains the source of truth; the body is history.

The defect

A required grid submitted while still empty failed at the form level, rendered its "is required" message, and marked nothing. Every row was a ghost row, and ghost rows were skipped by the widget's per-cell validity channel. A sighted user saw red text; a screen-reader user was told nothing at all.

GridField was never a widget that "forgot to forward ARIA". Since #4857 it already spreads toDomProps(props) onto its container and then deliberately strips the host aria-invalid, with the reason stated in place: "control-channel state; this grid reports validity per CELL with its own inline marks."

The fix (Option C)

That strip stands — this PR upholds #4857 rather than overturning it. A container is not a control. What was missing is the other half the strip implied but nobody had built: the host's failure now drives the per-cell channel the widget already owns.

  • When the error slot is set on an empty grid (hostFailedEmpty), the ghost entry row stops being skipped and its required cells flag — that is the line the user would actually type into to fix the error.
  • The mark also moves off the td and onto the cell's own control: the text/number input, the SelectTrigger, and LookupField via its published error slot. A td is not focusable, and assistive tech reads validity from the control, not the wrapper.
  • Deliberately narrow: a populated grid already marked its own empty required cells inline, so nothing changes there, and an empty grid that has not failed stays unmarked (no premature alarm).

The td keeps the visual ring, the title, and the line-items-invalid-* test hook, so the existing unit test and e2e/live/grid-conditional-rules.spec.ts are unaffected.

Rejected options, not implemented: A (container-div mark — a green trap the sweep could not distinguish from real delivery, and a silent overturn of #4857); B (the "Add line" button — #4857 already rejected routing field identity there); D (a third ledger verdict).

One honest gap, tracked not hidden: a required file column still marks only the cell's visual ring. FileCell has a closed prop set and owns its own control, so wiring it needs that component's contract to change — out of scope here, and noted in a comment at the call site rather than half-done.

The measurement the ruling required FIRST

The ruling made this sequencing binding: measure the tightening's impact on the currently-green rows first; if it reds legitimate rows, report the list rather than landing it blind.

Measured with a throwaway probe over all 45 registered types (probe not committed), reading the actual carrier element of every aria-invalid="true" after a real failure:

All 37 previously-green DELIVERING rows already carry on a focusable element. The tightening reds none of them. No list to report.

Representative readings, including every case I expected to be at risk:

address carriers=[input x5] focusable OK
checkboxes carriers=[button[role=checkbox] x2] focusable OK
file carriers=[div[role=button]] focusable OK (the #3961 pattern)
radio carriers=[div[role=radiogroup]] focusable OK (roving tabindex)
rating carriers=[button x5] focusable OK
slider carriers=[span[role=slider]] focusable OK
select carriers=[button[role=combobox]] focusable OK
grid carriers=[] NONE the card's row

A finding that changed the shape of Option C

Option C says "put aria-invalid on the cell's own input". Measured, the sweep's own harness gave it no input to land on:

bare grid (the sweep's config) focusables=[button] inputs=0
grid WITH columns focusables=[input(Item), input(Qty), button] inputs=2

A grid with no columns renders an empty table. Its only focusable element is the auxiliary "Add line" button — the exact carrier option B was rejected for. So the sweep was asking a widget that renders no controls whether it marks its control.

The harness now gives grid a two-column config, exactly the precedent already in that file for the option widgets (OPTION_TYPES get OPTIONS, commented "Option widgets render an unfillable placeholder unless offered a list"). This makes the assertion stricter, not looser: the sweep now has to find the mark on a genuine cell control. Called out here because it is a change to the harness's render state and a reviewer should see it rather than find it.

Sweep tightening (the #5223 fix)

The DELIVERING assertion now requires the aria-invalid="true" carrier to be focusable. Previously it was a query over the whole row, so a mark on a wrapper passed as "delivered" while the control the user edits announced nothing — the cheapest way to turn a ledger row green, reading clean in review, with nothing else in the file to catch it (the wrapper check guards NOT_APPLICABLE rows only).

Reverse verification

Predicted before each run, and each matched:

1. The ratchet fires. With the fix landed and grid still in the ledger, its reverse-asserting row must go red:

Tests 1 failed | 49 passed (50)
AssertionError: field:grid now delivers aria-invalid — remove it from NOT_YET_DELIVERED
+ Received: input aria-invalid="true" aria-label="Item" data-cell="0-0"

The carrier is the ghost row's own cell input — a focusable control. Option C delivered.

2. The tightening can actually fail. Ablation: re-introduced the wrapper mark (the rejected option A) by putting aria-invalid back on the td and removing it from the input:

Tests 1 failed | 50 passed (51)
AssertionError: field:grid carries aria-invalid ONLY on non-focusable element(s)
[td, td] — a wrapper mark (objectui#5223).

This is the proof that matters: the old assertion would have passed that exact state, because a td is found by a row-wide query. The hole is closed, and the new half is not a phantom check. Both ablation legs are source-resolved (the root vitest config aliases every @object-ui/* to src), so no rebuild is involved and no stale dist can fake a green.

3. After removing the ledger row:Tests 51 passed (51).

The ledger is empty — and now stays empty

NOT_YET_DELIVERED goes 29 → 20 → 9 → 1 → 0. The set stays in the file because it is the ratchet, not a to-do list.

Bounded in-place fix, named explicitly: I added one assertion, expect([...NOT_YET_DELIVERED]).toEqual([]). The file header has always said "do NOT add to this list to make a new widget pass", but that was prose — a new widget could quietly acquire a ledger row and the sweep would happily assert it still fails, which reads in review as a considered decision rather than an unfixed gap. Reaching zero is what makes that claim enforceable, so it is now enforced. Same defect class, same file, same gate family, and the failure message tells the next author their three roads (deliver it, measure it into NOT_APPLICABLE, or edit this assertion as a visible act).

Tests

All run from the repo root, under the shared verify lock. Gate union re-run at the final commit 7023649b9:

  • widget-aria-invalid-registry-e2e.test.tsx51 passed (51) (38 DELIVERING + 0 ledgered + 7 NOT_APPLICABLE + 6 structural)
  • packages/fields/107 files / 1803 passed
  • packages/plugin-detail/87 files / 824 passed (master-detail renders this widget)
  • packages/plugin-form/56 files / 574 passed
  • packages/components/src/renderers/form/49 files / 316 passed
  • turbo run type-check --filter=@object-ui/fields — 11/11 successful
  • check:control-bytes OK; check-changeset-presence OK; check-changeset-no-major OK
  • eslint on both changed files: 0 errors. GridField.tsx carries 27 warnings both before and after — measured against origin/main, this PR adds none.

Changeset: @object-ui/fieldspatch (never major, fixed-group gate).

⛔ Auto-merge not enabled — the PM lands this.


Generated by Claude Code

…level failure
A required `grid` submitted while EMPTY failed at the form level, rendered its
"is required" message, and marked nothing: every row was a ghost row, and ghost
rows were skipped by the per-cell validity channel. A sighted user saw red text;
a screen-reader user was told nothing at all.
The container's deliberate strip of the host `aria-invalid` (objectui#4857)
STANDS — a container is not a control. What was missing is the other half that
strip implied: the host's failure now drives the per-cell channel the widget
already owns. When the `error` slot is set on an empty grid, the ghost entry row
stops being skipped and its required cells flag.
The mark also moves off the `<td>` and onto the cell's own control (Input,
SelectTrigger, and LookupField via its published `error` slot). A `td` is not
focusable, and assistive tech reads validity from the control, not the wrapper.
Also tightens the registry sweep's DELIVERING assertion to require a FOCUSABLE
carrier (objectui#5223), closing the hole where a wrapper mark satisfied a
row-wide query while the control announced nothing. Measured first, per the
ruling's sequencing caveat: all 37 previously-green rows already carry on a
focusable element, so the tightening reds none of them.
NOT_YET_DELIVERED is now empty (29 -> 20 -> 9 -> 1 -> 0) and a new case asserts
it stays empty, making the header's long-standing "do not add to this list"
prose mechanical.
Part of #3318
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-J-L-fmHB.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.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)506.94KB113.61KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.34KB
fields (index.js)237.21KB59.50KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.42KB1.39KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)30.51KB7.57KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.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.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)128.07KB32.77KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)241.46KB60.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)124.19KB30.20KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.30KB53.06KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.66KB27.13KB
plugin-map (index.js)20.08KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.52KB20.67KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)36.10KB12.26KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
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 (dashboard-filter-alias.js)6.23KB2.74KB
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)3.08KB1.53KB
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

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

Labels

Projects

None yet

2 participants

@os-support-ai@claude