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
33 changes: 33 additions & 0 deletions .changeset/widget-aria-invalid-delivery-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@object-ui/fields": patch
"@object-ui/components": patch
---

20 more registered field widgets now announce a failed validation to assistive
tech: `multiselect`, `radio`, `checkboxes`, `tags`, `lookup`, `master_detail`,
`user`, `owner`, `file`, `image`, `location`, `object`, `color`, `rating`,
`code`, `avatar`, `address`, `geolocation`, `qrcode` and `object-ref` carry
`aria-invalid="true"` on their real focusable control after a validation
failure, where before the red message rendered while a screen reader was told
nothing (objectui#3318, the registry-wide gap objectui#3306's sweep measured).

Each widget follows the objectui#3222/#3306 pattern: the `toDomProps(props)`
whitelist spread goes onto the control the user actually focuses — the input,
the lookup trigger button, the radiogroup (`role="radiogroup"` is the
ARIA-designated carrier for a set of radios), every chip/checkbox/star of the
composite option widgets, the upload dropzone/button — followed by an explicit
`aria-invalid={!!error}` computed from the published `error` slot. Wrapper
`<div>`s never carry the state, and `name` is withheld from non-form-control
elements (the objectui#3291 leak class).

`Combobox` (`@object-ui/components`) now accepts standard button attributes
and forwards them to its focusable `role="combobox"` trigger, giving
combobox-based widgets an element to deliver `aria-invalid` /
`aria-describedby` to — the same seam objectui#3306 opened on
`SelectTrigger`.

Nine types remain on the objectui#3318 ratchet ledger with their blockers
documented there (`formula`/`summary`/`auto_number`/`vector` render no
focusable control; `grid`, `slider`, `signature` need component-level design;
`filter-condition`/`recipient-picker` deliver in their editable states but
render a dependency-gate hint with no control in a fresh form).
17 changes: 16 additions & 1 deletion packages/components/src/custom/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,18 @@ export interface ComboboxOption {
label: string
}

export interface ComboboxProps {
/**
* Beyond its own named props, the combobox accepts standard button attributes
* (`id`, `aria-*`, `data-*`, handlers, …) and forwards them to the trigger —
* the focusable `role="combobox"` button a user and their screen reader
* actually interact with. Without this seam a field widget rendering a
* Combobox had no element to deliver `aria-invalid` / `aria-describedby` to
* after a validation failure (objectui#3318; the same reason #3306 routes the
* select's pass-through onto `SelectTrigger`). `value` / `onChange` are
* omitted: this component's own `value` / `onValueChange` contract owns them.
*/
export interface ComboboxProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "onChange"> {
options: ComboboxOption[]
value?: string
onValueChange?: (value: string) => void
Expand All @@ -52,13 +63,17 @@ export function Combobox({
emptyText = "No option found.",
className,
disabled,
...triggerProps
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
// Before this component's own props, which stay authoritative for
// the combobox contract (role, aria-expanded, className, disabled).
{...triggerProps}
variant="outline"
role="combobox"
aria-expanded={open}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,32 @@ const WIDGETS: Record<string, ComponentType<any>> = {
* objectui#3318, not an accepted state: the field shows its red message while
* assistive tech is told nothing.
*
* Mechanism, per widget: none of these forwards its DOM pass-through (which
* carries the `<FormControl>` Slot's `aria-invalid`) to the control it
* renders, nor computes one from the published `error` slot. `grid` is the
* one partial case — it has per-CELL `aria-invalid` for its own line-item
* validation, but a form-level failure does not drive it.
* The objectui#3318 delivery batch cleared 20 of the original 29 entries with
* the objectui#3222/#3306 pattern. What remains cannot take that pattern
* as-is; each entry names why, so the follow-up starts from the blocker and
* not from a re-audit:
*
* - `formula` / `summary` / `auto_number` / `vector` — read-only computed /
* display widgets: they render static text and NO focusable control, so
* there is no element assistive tech would read the state from (marking the
* text span would be exactly the non-focusable-wrapper move this ledger's
* rules forbid). A `required` failure on a non-editable field is a metadata
* authoring problem more than a widget one.
* - `grid` — composite line-item editor with its own per-CELL `aria-invalid`
* for line validation; driving it from a FORM-level failure needs a design
* (which cell? the add-row button?) rather than a spread.
* - `slider` — the focusable thumb (`role="slider"`, the ARIA-designated
* carrier) is rendered inside the synced shadcn `ui/slider.tsx` (a #7
* no-touch file), which forwards arbitrary props only to its non-focusable
* Root span; delivering needs a components-level change.
* - `signature` — the drawing `<canvas>` is not focusable at all (no keyboard
* input path exists); the only focusable element is the auxiliary Clear
* button. Needs a real a11y design, not an attribute.
* - `filter-condition` / `recipient-picker` — dependency-gated: with no
* sibling `object_name` / `recipient_type` chosen (the state a fresh form
* and THIS SWEEP render) they show a hint paragraph with no focusable
* control. Their editable states DO deliver `aria-invalid` since #3318's
* delivery batch.
*
* Do NOT add to this list to make a new widget pass; fix the widget (the
* objectui#3222/#3306 pattern: spread `toDomProps(props)` onto the real
Expand All @@ -173,33 +194,13 @@ const WIDGETS: Record<string, ComponentType<any>> = {
* its own ledger row red until it is removed here. The ledger only shrinks.
*/
const NOT_YET_DELIVERED: ReadonlySet<string> = new Set([
'multiselect',
'radio',
'checkboxes',
'tags',
'lookup',
'master_detail',
'file',
'image',
'location',
'formula',
'summary',
'auto_number',
'user',
'owner',
'object',
'vector',
'grid',
'color',
'slider',
'rating',
'code',
'avatar',
'address',
'geolocation',
'signature',
'qrcode',
'object-ref',
'filter-condition',
'recipient-picker',
]);
Expand Down
15 changes: 14 additions & 1 deletion packages/fields/src/widgets/AddressField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { Input, Label, EmptyValue } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';

/**
* Address data structure
Expand All @@ -17,8 +18,14 @@ export interface AddressValue {
* Address field widget - provides a structured address input
* Supports street, city, state, zip code, and country
*/
export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
const address = value || {};
// DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
// sub-input (street) — one carrier for the form renderer's aria-describedby;
// the composite's validation state goes onto EVERY focusable sub-input via
// `aria-invalid={!!error}` (a form-level failure means the whole address is
// missing/invalid, and whichever sub-input the user reaches must announce it).
const domProps = toDomProps(props);

const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
onChange({
Expand Down Expand Up @@ -47,13 +54,15 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
<div>
<Label htmlFor="street" className="text-xs">Street Address</Label>
<Input
{...domProps}
id="street"
type="text"
value={address.street || ''}
onChange={(e) => handleFieldChange('street', e.target.value)}
placeholder="123 Main St"
disabled={readonly || props.disabled}
className={props.className}
aria-invalid={!!error}
/>
</div>

Expand All @@ -67,6 +76,7 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
onChange={(e) => handleFieldChange('city', e.target.value)}
placeholder="San Francisco"
disabled={readonly || props.disabled}
aria-invalid={!!error}
/>
</div>

Expand All @@ -79,6 +89,7 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
onChange={(e) => handleFieldChange('state', e.target.value)}
placeholder="CA"
disabled={readonly || props.disabled}
aria-invalid={!!error}
/>
</div>
</div>
Expand All @@ -93,6 +104,7 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
onChange={(e) => handleFieldChange('zipCode', e.target.value)}
placeholder="94102"
disabled={readonly || props.disabled}
aria-invalid={!!error}
/>
</div>

Expand All @@ -105,6 +117,7 @@ export function AddressField({ value, onChange, field, readonly, ...props }: Fie
onChange={(e) => handleFieldChange('country', e.target.value)}
placeholder="United States"
disabled={readonly || props.disabled}
aria-invalid={!!error}
/>
</div>
</div>
Expand Down
9 changes: 8 additions & 1 deletion packages/fields/src/widgets/AvatarField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import React from 'react';
import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
import { Upload, X } from 'lucide-react';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';

/**
* Avatar field widget - provides an avatar/profile picture uploader
* Supports image URLs or file uploads
*/
export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function AvatarField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
const [isHovered, setIsHovered] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -92,11 +93,17 @@ export function AvatarField({ value, onChange, field, readonly, ...props }: Fiel
className="hidden"
/>
<Button
// DOM pass-through onto the widget's real focusable control — the
// upload button is the keyboard path to the hidden file input
// (objectui#3318).
{...toDomProps(props)}
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={readonly || props.disabled}
// AFTER the spread so this widget's own computation wins (#3222).
aria-invalid={!!error}
>
<Upload className="w-4 h-4 mr-2" />
{value ? 'Change' : 'Upload'} Avatar
Expand Down
18 changes: 18 additions & 0 deletions packages/fields/src/widgets/CheckboxesField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react';
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
import type { OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';
import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';

Expand Down Expand Up @@ -29,6 +30,7 @@ export function CheckboxesField({
dependsOn: dependsOnProp,
emptyHint,
dataSource: _dataSource,
error,
...props
}: FieldWidgetComponentProps<string[]>) {
const config = field as any;
Expand Down Expand Up @@ -90,8 +92,23 @@ export function CheckboxesField({
onChange(next);
};

// DOM pass-through (objectui#3318): the container carries the form
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
// wrapper div is not where assistive tech reads the invalid state. That
// state goes onto every focusable checkbox below (`checkbox` is an
// aria-invalid-supporting role), computed from the published `error` slot
// (#3222). The per-item ids below stay authoritative for their labels.
// `name` is withheld too: it is only DOM-legal on form controls, and on
// this div it is exactly the leak #3291 sweeps for.
const {
'aria-invalid': _hostAriaInvalid,
name: _domName,
...groupDomProps
} = toDomProps(props);

return (
<div
{...groupDomProps}
className={className}
data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
>
Expand All @@ -107,6 +124,7 @@ export function CheckboxesField({
checked={selected.includes(value)}
onCheckedChange={(checked) => toggle(value, !!checked)}
disabled={props.disabled}
aria-invalid={!!error}
data-testid={`checkboxes-option-${value}`}
/>
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
Expand Down
9 changes: 8 additions & 1 deletion packages/fields/src/widgets/CodeField.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react';
import { Textarea, cn, EmptyValue } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';

/**
* Code field widget - provides a code editor with syntax highlighting
* Uses a simple textarea with monospace font
* For advanced code editing, use the @object-ui/plugin-editor component
*/
export function CodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function CodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
const config = field;
// Get code-specific configuration from field metadata
const language = (config as any)?.language ?? 'javascript';
Expand All @@ -22,13 +23,19 @@ export function CodeField({ value, onChange, field, readonly, ...props }: FieldW

return (
<Textarea
// DOM pass-through onto the real focusable control (objectui#3318).
{...toDomProps(props)}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={config?.placeholder || `// Write ${language} code here...`}
disabled={readonly || props.disabled}
className={cn("font-mono text-sm", props.className)}
rows={12}
spellCheck={false}
// AFTER the spread so this widget's own computation wins: `error` is
// the published validation slot (#3222), `!!undefined` → explicit
// "false".
aria-invalid={!!error}
/>
);
}
11 changes: 10 additions & 1 deletion packages/fields/src/widgets/ColorField.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React from 'react';
import { Input, EmptyValue } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
import { toDomProps } from './toDomProps';

/**
* Color field widget - provides a color picker input
* Supports hex color values (e.g., #ff0000)
*/
export function ColorField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
export function ColorField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
const colorField = field as any;

if (readonly) {
Expand All @@ -29,15 +30,23 @@ export function ColorField({ value, onChange, field, readonly, ...props }: Field
onChange={(e) => onChange(e.target.value)}
disabled={readonly || props.disabled}
className="w-10 h-10 rounded border border-input cursor-pointer"
// Both focusable halves of this widget announce the same validation
// state (objectui#3318).
aria-invalid={!!error}
/>
<Input
// DOM pass-through onto the primary text control (objectui#3318): the
// whitelist spread carries the form renderer's id / aria-describedby.
{...toDomProps(props)}
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={colorField?.placeholder || '#000000'}
disabled={readonly || props.disabled}
className={props.className}
pattern="^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
// AFTER the spread so this widget's own computation wins (#3222).
aria-invalid={!!error}
/>
</div>
);
Expand Down
Loading
Loading