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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
41 changes: 41 additions & 0 deletions .changeset/7126-inline-widgets-read-error.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/fields': patch
---

The last five inline edit widgets read the delivered `error` slot, so a failed
required `text` / `boolean` / `date` / `datetime` / `time` control finally
reports `aria-invalid` (objectui#7126).

objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key to
whichever widget it resolves. Of the 27 distinct components in `EDIT_WIDGETS`,
21 read it; five did not — `TextField`, `BooleanField` (serving both `boolean`
and `toggle`), `DateField`, `DateTimeField` and `TimeField` — so for their field
types the delivery was inert and the attribute was still never set.

`text` being in that set is what made this a live defect rather than tidiness.
It is the most common field type in any object, so it is the likeliest thing a
kanban column makes required: `RequiredFieldsDialog` computed the failure, drew
the red "Required" hint, handed the state to the control, and the control said
nothing to assistive tech. The grid's inline cell editor and the detail page's
inline edit (`InlineFieldInput`) compose the same seam.

Each of the five now computes `aria-invalid={!!error}` **after** its DOM
pass-through spread — one existing idiom, the objectui#3222 discipline the other
21 already share, so a valid field says an explicit `"false"` rather than staying
mute. Two judgements worth stating:

- **The FORM path was never broken and is unchanged.** `<FormControl>` is a
Radix `Slot` whose `aria-invalid` reached each control through the props
spread; the form also produces `error`, so the widget's own computation now
agrees with the value it replaces. The gap was every host WITHOUT that Slot.
- **`BooleanField` is the one composite here, and the mark goes on the
control.** Its Radix `Checkbox` / `Switch` renders a real
`button[role=checkbox]` / `button[role=switch]`; the wrapping flex `div` is
deliberately not the target, because a wrapper mark satisfies a subtree query
while telling a screen-reader user nothing (objectui#5223). The three
date/time widgets each render one native input, so the browser's picker raises
no second-element question.

This buys the MARKING only. The objectui#3222 slot drives `aria-invalid` and
renders no text: the visible message stays with the host, and nothing that was
invisible becomes visible.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The five widgets that never read the delivered `error` now report
* `aria-invalid` on a FOCUSABLE control, through the real inline seam
* (objectui#7126).
*
* ## The defect
*
* objectui#7008 made `FieldEditWidget` DELIVER the declared `error` key
* (`toHostProps`, landed as f08bcd9af). Of the 27 distinct components in
* `EDIT_WIDGETS`, 21 read it; five did not — `TextField`, `BooleanField`
* (`boolean` + `toggle`), `DateField`, `DateTimeField`, `TimeField` — so for
* their field types the delivery was inert and `aria-invalid` was still never
* set. `text` being in that set is what made it live rather than tidy: it is
* the most common type in any object, and the kanban `RequiredFieldsDialog`
* renders whatever types the target column made required.
*
* ## Why the FACTORY and not the widgets directly
*
* In the FORM these five were already announced correctly and always had been:
* `<FormControl>` is a Radix `Slot`, its `aria-invalid` reached the control
* through each widget's props spread untouched, and
* `widget-aria-invalid-registry-e2e.test.tsx` sweeps exactly that path with an
* EMPTY `NOT_YET_DELIVERED` ledger. So a form-based test would have been green
* before this change and proves nothing about it.
*
* `FieldEditWidget` renders no Slot. It is the seam every NON-form host
* composes — the grid's inline cell editor, the detail page's inline edit
* (`InlineFieldInput`), the kanban required-fields dialog — and the only way
* the state reaches the control there is the declared `error` prop. That is
* the path that was broken, so that is the path measured here.
*
* ## What is NOT claimed
*
* The marking only. objectui#3222's slot drives `aria-invalid` and renders no
* text; the visible message stays with the host. Nothing here becomes visible
* that was not visible before.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import React from 'react';

import { FieldEditWidget } from '../FieldEditWidget';

afterEach(() => cleanup());

/**
* HTML's own focusability rules, as a selector — copied from
* `widget-aria-invalid-registry-e2e.test.tsx` on purpose, so both sweeps judge
* "the control a keyboard user can land on" by one definition.
*
* This is the objectui#5223 line: a mark on a non-focusable wrapper satisfies a
* subtree query while telling a screen-reader user nothing, and it is the
* cheapest way to make an assertion like the ones below go green without
* helping anyone.
*/
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',');

function describeEl(el: Element): string {
const role = el.getAttribute('role');
const type = el.getAttribute('type');
return `${el.tagName.toLowerCase()}${type ? `[type=${type}]` : ''}${role ? `[role=${role}]` : ''}`;
}

function renderInline(field: Record<string, unknown>, error?: string) {
const { container } = render(
<FieldEditWidget
field={field as never}
value={undefined as never}
onChange={() => {}}
error={error}
/>,
);
return container;
}

/**
* The population of objectui#7126, by the field TYPE each widget serves inline
* — six types, five widgets (`boolean` and `toggle` both resolve to
* `BooleanField`), plus the two branch variants that a type key alone does not
* reach: `TextField`'s textarea branch (`rows > 1`) and `BooleanField`'s
* checkbox branch (`widget: 'checkbox'`). Both are real authored configs, and
* each renders a DIFFERENT element, so a fix applied to only one branch of
* either widget still fails here.
*/
const CASES: ReadonlyArray<readonly [label: string, field: Record<string, unknown>]> = [
['text', { name: 'f', type: 'text', label: 'F' }],
['text (rows > 1 -> textarea branch)', { name: 'f', type: 'text', label: 'F', rows: 4 }],
['boolean (switch branch)', { name: 'f', type: 'boolean', label: 'F' }],
['boolean (widget: checkbox branch)', { name: 'f', type: 'boolean', label: 'F', widget: 'checkbox' }],
['toggle', { name: 'f', type: 'toggle', label: 'F' }],
['date', { name: 'f', type: 'date', label: 'F' }],
['datetime', { name: 'f', type: 'datetime', label: 'F' }],
['time', { name: 'f', type: 'time', label: 'F' }],
];

describe('inline field widgets announce a delivered `error` (objectui#7126)', () => {
it.each(CASES)(
'%s — carries aria-invalid="true" on a FOCUSABLE control when the host delivers `error`',
(_label, field) => {
const container = renderInline(field, 'Required');

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(
carriers.map(describeEl),
'the host delivered `error` and nothing in the rendered widget says so — assistive tech is never told the field failed',
).not.toEqual([]);

// THE WRAPPER-MARK HOLE (objectui#5223). `BooleanField` is the case this
// exists for: it renders its control inside a flex `div`, and marking
// that `div` would satisfy the query above while the switch the user
// actually operates announces nothing.
expect(
carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl),
`aria-invalid sits ONLY on non-focusable element(s) [${carriers.map(describeEl).join(', ')}] — that is a wrapper mark, not a control mark`,
).not.toEqual([]);
},
);

it.each(CASES)(
'%s — says an explicit aria-invalid="false" when the host delivers no `error`',
(_label, field) => {
// The load-bearing half, and the reason this is a two-state reading
// rather than "the attribute exists": `!!undefined` must yield `"false"`,
// so a valid field SAYS it is valid instead of staying mute (the
// objectui#3222 discipline). Without this, an unconditional
// `aria-invalid="true"` would pass the case above.
const container = renderInline(field);

const control = container.querySelector(FOCUSABLE);
expect(control, 'no focusable control rendered at all').not.toBeNull();
expect(control).toHaveAttribute('aria-invalid', 'false');
expect(container.querySelector('[aria-invalid="true"]')).toBeNull();
},
);

it('CONTROL: a widget that ALREADY read `error` reports the same way through the same harness', () => {
// Without this, a green sweep above could not be distinguished from a
// harness that marks everything it renders. `select` -> `SelectField` was
// one of the 21 readers before this change (objectui#3306 / #7008's pin),
// so it must read `true`/`false` here for exactly the reasons the five now
// do — same factory, same delivery, same assertion.
const SELECT_FIELD = {
name: 'stage',
type: 'select',
label: 'Stage',
options: [{ label: 'New', value: 'new' }],
};

const invalid = renderInline(SELECT_FIELD, 'Required');
const trigger = invalid.querySelector('[role="combobox"]')!;
expect(trigger.tagName).toBe('BUTTON');
expect(trigger).toHaveAttribute('aria-invalid', 'true');

cleanup();

const valid = renderInline(SELECT_FIELD);
expect(valid.querySelector('[role="combobox"]')).toHaveAttribute('aria-invalid', 'false');
});

it('CONTROL: `user` was a FALSE zero in the census and is NOT in the population', () => {
// The one trap in the measurement that produced this card. A word-boundary
// `error` grep over the 27 `EDIT_WIDGETS` components returns SIX zeroes,
// and `UserField` is one of them — but it renders `LookupField` with a
// props spread, so it delivers `error` transitively and has always marked.
// A naive census reports six and is wrong about one; this pins the sixth so
// the next reader does not "fix" a widget that was never broken (and so a
// future refactor that flattens the delegation cannot silently drop it).
const container = renderInline(
{ name: 'owner_id', type: 'user', label: 'Owner', reference_to: 'sys_user' },
'Required',
);

const carriers = Array.from(container.querySelectorAll('[aria-invalid="true"]'));
expect(carriers.filter((el) => el.matches(FOCUSABLE)).map(describeEl)).not.toEqual([]);
});
});
24 changes: 23 additions & 1 deletion packages/fields/src/widgets/BooleanField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ import { toDomProps } from './toDomProps.js';
* BooleanField - Toggle input supporting switch and checkbox variants
* Renders as Switch or Checkbox based on field widget configuration
*/
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
export function BooleanField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<boolean>) {
const config = field as any;
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
const widget = config?.widget;
Expand DownExpand Up@@ -56,6 +56,26 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie

const domProps = toDomProps(props);

/**
* WHICH ELEMENT carries `aria-invalid`, since this widget is the one of the
* five in objectui#7126 that renders a composite: a control plus its
* `sr-only` label inside a flex `div`.
*
* It goes on the Radix `Checkbox` / `Switch` -- each renders a real
* `<button role="checkbox">` / `<button role="switch">`, which is the
* focusable element a keyboard user lands on and the one assistive tech
* reads control state from. `aria-invalid` is a GLOBAL ARIA attribute, valid
* on both roles. The wrapper `div` is deliberately NOT the target: marking it
* satisfies a row-wide query while telling a screen-reader user nothing,
* which is exactly the hole objectui#5223 closed in the registry sweep and
* the move that sweep now forbids by requiring a FOCUSABLE carrier.
*
* Written AFTER the DOM spread in both branches so this widget's own
* computation wins (the objectui#3222 idiom, shared with `SelectField` /
* `EmailField` / `NumberField`), and `!!undefined` yields an explicit
* `"false"` so a valid field says so rather than staying mute. MARKING only:
* the message TEXT stays with the host.
*/
if (widget === 'checkbox') {
return (
<div className="flex items-center space-x-2">
Expand All@@ -65,6 +85,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={(checked) => onChange(!!checked)}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand All@@ -79,6 +100,7 @@ export function BooleanField({ value, onChange, field, readonly, ...props }: Fie
checked={!!value}
onCheckedChange={onChange}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
</div>
Expand Down
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateInputValue } from './nativeDateValue.js';
* DateField - Date picker input widget
* Uses native date input and displays locale-formatted date in readonly mode
*/
export function DateField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return: the hook count must not depend on a prop
// (objectui#4468). A bare `toLocaleDateString()` reads the MACHINE's locale,
// which is how a Chinese form ended up with an `8/11/2026` value in it.
Expand All@@ -21,6 +21,29 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="date">`, and the browser's date
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `date`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -35,6 +58,7 @@ export function DateField({ value, onChange, field, readonly, ...props }: FieldW
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
26 changes: 25 additions & 1 deletion packages/fields/src/widgets/DateTimeField.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,7 @@ import { toDateTimeInputValue, fromDateTimeInputValue } from './nativeDateValue.
* DateTimeField - Combined date and time picker widget
* Displays both date and time in locale format when readonly
*/
export function DateTimeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function DateTimeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
// Before the readonly early return — the hook count must not depend on a
// prop. See DateField for why the bare `toLocale*` calls were wrong
// (objectui#4468).
Expand All@@ -27,6 +27,29 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi

const domProps = toDomProps(props);

/**
* `aria-invalid` after the DOM spread below, the objectui#3222 idiom shared
* with the other readers (`SelectField`, `EmailField`, `NumberField`):
* `error` is the published validation slot
* (`@objectstack/spec/ui`'s `FieldWidgetPropsSchema`) and `!!undefined`
* yields an explicit `"false"`, so a valid field SAYS it is valid rather
* than staying mute.
*
* There is no composite-target question here despite the name "picker": the
* widget renders ONE `<input type="datetime-local">`, and the browser's date-and-time
* picker is that same element's own UI, not a second element. So the
* focusable control a keyboard user lands on IS the carrier -- no wrapper is
* marked (the objectui#5223 line).
*
* Reading it here is what makes the delivery non-inert for `datetime-local`
* (objectui#7126). The FORM path already announced correctly, because
* `<FormControl>`'s Radix `Slot` value reached the input through the spread
* untouched; every host WITHOUT that Slot -- `FieldEditWidget`, i.e. the
* kanban required-fields dialog and the grid / detail inline editors --
* hands the state over as the declared `error` prop (delivered since
* objectui#7008) and nothing read it. MARKING only: the message TEXT stays
* with the host.
*/
return (
<Input
{...domProps}
Expand All@@ -41,6 +64,7 @@ export function DateTimeField({ value, onChange, field, readonly, ...props }: Fi
domProps.onClick?.(e);
}}
disabled={readonly || domProps.disabled}
aria-invalid={!!error}
/>
);
}
Loading
Loading