From f90ea900eabd481ac8d05b52bff957c8391d4f6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:08:49 +0000 Subject: [PATCH 1/2] fix(fields): NumberField reads the published `error` slot `NumberField` destructured `{ value, onChange, field, readonly, ...props }` with no `error`, so the published validation slot landed in `props` and `toDomProps` (a whitelist) dropped it. The widget wrote `aria-invalid` only while its own bad-input refusal was active, so handed an `error` and no host to cover for it, the control carried no `aria-invalid` at all. Wire `error` and collapse the conditional spread into the ordinary `aria-invalid={!!error || !!refusal}` the sibling number widgets use. Both halves land together: reading `error` is what makes an unconditional attribute safe, and un-conditionalising is what makes reading `error` visible. Pinned in `validation-feedback.test.tsx` (the consumer half, which goes red without the wiring) and `number` added to the `WIDGETS` table of `widget-aria-invalid-e2e.test.tsx`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../__tests__/validation-feedback.test.tsx | 47 +++++++++++++++++++ .../widget-aria-invalid-e2e.test.tsx | 3 ++ packages/fields/src/widgets/NumberField.tsx | 20 +++++--- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/packages/fields/src/__tests__/validation-feedback.test.tsx b/packages/fields/src/__tests__/validation-feedback.test.tsx index 4aa88b6647..faaf8f0854 100644 --- a/packages/fields/src/__tests__/validation-feedback.test.tsx +++ b/packages/fields/src/__tests__/validation-feedback.test.tsx @@ -120,6 +120,53 @@ describe('P3.2 Validation Feedback', () => { const input = screen.getByRole('spinbutton'); expect(input).toHaveAttribute('aria-invalid', 'true'); }); + + /** + * objectui#6803. `NumberField` was the ONE widget in this package that + * renders a control and never read the published `error` slot, so these two + * cases are the load-bearing half of that fix. + * + * ⚠️ The e2e pin next door (`widget-aria-invalid-e2e.test.tsx`) CANNOT show + * this gap, and that is measured, not assumed: inside the real form the + * `aria-invalid` that ``'s Radix Slot hands down arrives as a + * widget prop, and `toDomProps` forwards the whole `aria-*` family by + * prefix — so the Slot's correct value reached the input on its own and the + * `number` row was GREEN there before this fix as well as after. The + * omission was invisible precisely because a host was covering for it. + * + * Here there is no host and no Slot, which is the widget's own contract: + * handed an `error`, it must mark its own control. That is the assertion + * that goes red without the wiring. + */ + it('NumberField sets aria-invalid when `error` provided', () => { + render( + + ); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('NumberField says aria-invalid="false" when handed no error', () => { + // The explicit `"false"` is the load-bearing half, same as in the e2e + // file: a valid field SAYS it is valid rather than staying mute. Before + // objectui#6803 this widget wrote the attribute ONLY while its own + // bad-input refusal was active, so with no host to cover for it the + // control carried no `aria-invalid` at all. + render( + + ); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('aria-invalid', 'false'); + }); }); // --------------------------------------------------------------- diff --git a/packages/fields/src/__tests__/widget-aria-invalid-e2e.test.tsx b/packages/fields/src/__tests__/widget-aria-invalid-e2e.test.tsx index c0ae0c675f..988195d76e 100644 --- a/packages/fields/src/__tests__/widget-aria-invalid-e2e.test.tsx +++ b/packages/fields/src/__tests__/widget-aria-invalid-e2e.test.tsx @@ -52,6 +52,7 @@ import { PercentField } from '../widgets/PercentField'; import { RichTextField } from '../widgets/RichTextField'; import { TextField } from '../widgets/TextField'; import { SelectField } from '../widgets/SelectField'; +import { NumberField } from '../widgets/NumberField'; /** * The widgets that read the validation slot, by their form-path key: the seven @@ -68,6 +69,7 @@ const WIDGETS = [ ['percent', PercentField], ['markdown', RichTextField], ['select', SelectField], + ['number', NumberField], ] as const; beforeAll(() => { @@ -133,6 +135,7 @@ describe('field widgets announce an invalid field to AT (objectui#3222)', () => ['currency', 'amount', null], ['percent', 'ratio', null], ['markdown', 'body', ''], + ['number', 'quantity', null], ] as const)( 'a required %s field is aria-invalid only AFTER validation fails', async (type, name, emptyValue) => { diff --git a/packages/fields/src/widgets/NumberField.tsx b/packages/fields/src/widgets/NumberField.tsx index aac4f9b49d..006c352a10 100644 --- a/packages/fields/src/widgets/NumberField.tsx +++ b/packages/fields/src/widgets/NumberField.tsx @@ -9,7 +9,7 @@ import { useBadInputRefusal, BadInputMessage, BAD_INPUT_BORDER } from './numberB * NumberField - Numeric input with optional decimal precision * Supports min/max/step constraints and configurable decimal precision */ -export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps) { +export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) { // Before the readonly return: hooks are unconditional (objectui#6780). const { refusal, readBadInput } = useBadInputRefusal('1234'); @@ -72,12 +72,18 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel min={typeof numberField?.min === 'number' ? numberField.min : undefined} max={typeof numberField?.max === 'number' ? numberField.max : undefined} step={step} - // ⚠️ Written ONLY when refused. This widget does not read the published - // `error` slot (objectui#3222 never gave it one), so an unconditional - // `aria-invalid={!!refusal}` would stamp `"false"` over the correct - // value ``'s Radix Slot hands down — the exact overwrite - // objectui#3222's e2e pins call out. - {...(refusal ? { 'aria-invalid': true } : {})} + // `refusal` is this widget's OWN reading and no host can produce it; + // `error` keeps its single author (objectui#3222 / objectui#6716) — + // the same two-name split `CurrencyField` and `PercentField` use. + // + // This was a CONDITIONAL spread (written only while `refusal` was + // active) for as long as the widget did not read `error`: an + // unconditional attribute computed from a prop it never consumed would + // have stamped `"false"` over the correct value ``'s Radix + // Slot hands down — the overwrite objectui#3222's e2e pins call out. + // Reading `error` here is what retires that hazard, so the two halves + // landed together (objectui#6803); neither is safe alone. + aria-invalid={!!error || !!refusal} /> From 8699b5634616240ec2e5c1ffb53c3238f3d9a9cf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:20:02 +0000 Subject: [PATCH 2/2] chore(changeset): declare the NumberField `error` slot fix Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6803-numberfield-error-slot.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/6803-numberfield-error-slot.md diff --git a/.changeset/6803-numberfield-error-slot.md b/.changeset/6803-numberfield-error-slot.md new file mode 100644 index 0000000000..2517ab9aa1 --- /dev/null +++ b/.changeset/6803-numberfield-error-slot.md @@ -0,0 +1,21 @@ +--- +'@object-ui/fields': patch +--- + +`NumberField` now reads the published `error` validation slot, so a number +field marked invalid is announced to assistive tech by the widget itself +(objectui#6803, closing an objectui#3222 gap). + +The widget destructured `{ value, onChange, field, readonly, ...props }` with +no `error`, so the slot landed in the open tail and `toDomProps` — a whitelist +— dropped it. It wrote `aria-invalid` only while its own bad-input refusal was +active, which meant that on any host that does not hand a value down itself, +an invalid number field carried no `aria-invalid` at all. + +`error` is now wired and the conditional spread becomes the ordinary +`aria-invalid={!!error || !!refusal}` the sibling number widgets already use. +Both halves ship together on purpose: reading `error` is what makes an +unconditional attribute safe to write, and leaving the attribute conditional +would have kept the wiring invisible. Un-conditionalising WITHOUT reading +`error` is the regression this pairing forbids — it would stamp `"false"` over +the correct value `FormControl`'s Radix Slot hands down.