Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(fields): NumberField reads the published error slot by claude[bot] · Pull Request #7002 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(fields): NumberField reads the published error slot by claude[bot] · Pull Request #7002 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(fields): NumberField reads the published error slot by claude[bot] · Pull Request #7002 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(fields): NumberField reads the published error slot by claude[bot] · Pull Request #7002 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(fields): NumberField reads the published error slot by claude[bot] · Pull Request #7002 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(fields): NumberField reads the published error slot by claude[bot] · Pull Request #7002 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/6803-numberfield-error-slot.md
Original file line numberDiff line numberDiff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/fields/src/__tests__/validation-feedback.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormControl>`'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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
error="Must be at least 10"
/>
);
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(
<NumberField
value={5}
onChange={noop}
field={{ type: 'number' } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('aria-invalid', 'false');
});
});

// ---------------------------------------------------------------
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -68,6 +69,7 @@ const WIDGETS = [
['percent', PercentField],
['markdown', RichTextField],
['select', SelectField],
['number', NumberField],
] as const;

beforeAll(() => {
Expand DownExpand Up@@ -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) => {
Expand Down
20 changes: 13 additions & 7 deletions packages/fields/src/widgets/NumberField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<number>) {
export function NumberField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<number>) {
// Before the readonly return: hooks are unconditional (objectui#6780).
const { refusal, readBadInput } = useBadInputRefusal('1234');

Expand DownExpand Up@@ -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 `<FormControl>`'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 `<FormControl>`'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}
/>
<BadInputMessage refusal={refusal} />
</div>
Expand Down
Loading