) {
const config = field as any;
@@ -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 (
@@ -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}`}
/>
{opt.label}
diff --git a/packages/fields/src/widgets/CodeField.tsx b/packages/fields/src/widgets/CodeField.tsx
index 497267bcb..72a592b89 100644
--- a/packages/fields/src/widgets/CodeField.tsx
+++ b/packages/fields/src/widgets/CodeField.tsx
@@ -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) {
+export function CodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) {
const config = field;
// Get code-specific configuration from field metadata
const language = (config as any)?.language ?? 'javascript';
@@ -22,6 +23,8 @@ export function CodeField({ value, onChange, field, readonly, ...props }: FieldW
return (
);
diff --git a/packages/fields/src/widgets/FileField.tsx b/packages/fields/src/widgets/FileField.tsx
index 6aac2304e..b68e879ca 100644
--- a/packages/fields/src/widgets/FileField.tsx
+++ b/packages/fields/src/widgets/FileField.tsx
@@ -4,6 +4,7 @@ import { useUpload } from '@object-ui/providers';
import { useObjectTranslation } from '@object-ui/i18n';
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
import { useUploadingSignal } from './useUploadingSignal';
import {
fileValueForSubmit,
@@ -115,7 +116,7 @@ function useFileUploads(opts: {
* Supports single and multiple file uploads with configurable accepted file types.
* L2: File size validation, per-file progress indicators, error messages.
*/
-export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps) {
+export function FileField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps) {
const { t } = useObjectTranslation();
const inputRef = useRef(null);
const cameraRef = useRef(null);
@@ -209,6 +210,8 @@ export function FileField({ value, onChange, field, readonly, onUploadingChange,
}
};
+ const { name: _domName, ...dropzoneDomProps } = toDomProps(props);
+
return (
- {/* Drag-and-drop zone */}
+ {/* Drag-and-drop zone — the widget's real focusable control (it is the
+ keyboard path to the hidden file input), so the DOM pass-through
+ and the validation state land here (objectui#3318). `name` is
+ withheld: only DOM-legal on form controls, and on this div it is
+ exactly the leak #3291 sweeps for. */}
diff --git a/packages/fields/src/widgets/FilterConditionField.tsx b/packages/fields/src/widgets/FilterConditionField.tsx
index 0ce36e4b2..4b73a0144 100644
--- a/packages/fields/src/widgets/FilterConditionField.tsx
+++ b/packages/fields/src/widgets/FilterConditionField.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { FilterBuilder, cn } from '@object-ui/components';
import { SchemaRendererContext } from '@object-ui/react';
import type { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
import { useFieldTranslation } from './useFieldTranslation';
/**
@@ -297,6 +298,7 @@ export function FilterConditionField({
onChange,
readonly,
className,
+ error,
...props
}: FieldWidgetComponentProps
) {
const ctx = React.useContext(SchemaRendererContext);
@@ -397,10 +399,19 @@ export function FilterConditionField({
{rawMode ? (
<>
diff --git a/packages/fields/src/widgets/ImageField.tsx b/packages/fields/src/widgets/ImageField.tsx
index 1fe6d459b..b103912a8 100644
--- a/packages/fields/src/widgets/ImageField.tsx
+++ b/packages/fields/src/widgets/ImageField.tsx
@@ -4,6 +4,7 @@ import { useUpload } from '@object-ui/providers';
import { useObjectTranslation } from '@object-ui/i18n';
import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
import { ImageLightbox } from './ImageLightbox';
import { useUploadingSignal } from './useUploadingSignal';
import {
@@ -24,7 +25,7 @@ const ImageCropperDialog = lazy(() =>
* ImageField - Image upload widget with preview thumbnails
* Supports single and multiple image uploads with drag-and-drop and preview display
*/
-export function ImageField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetComponentProps
) {
+export function ImageField({ value, onChange, field, readonly, onUploadingChange, error, ...props }: FieldWidgetComponentProps) {
const inputRef = useRef(null);
const imageField = field as any;
const multiple = imageField?.multiple || false;
@@ -212,12 +213,18 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
)}
inputRef.current?.click()}
className="w-full"
disabled={uploading}
data-testid="image-field-upload-button"
+ // AFTER the spread so this widget's own computation wins (#3222).
+ aria-invalid={!!error}
>
{uploading ? (
diff --git a/packages/fields/src/widgets/LocationField.tsx b/packages/fields/src/widgets/LocationField.tsx
index 50db44a84..480cc40e2 100644
--- a/packages/fields/src/widgets/LocationField.tsx
+++ b/packages/fields/src/widgets/LocationField.tsx
@@ -1,12 +1,13 @@
import React from 'react';
import { Input, EmptyValue } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
/**
* LocationField - Geographic coordinate input for latitude and longitude
* Stores location as { latitude, longitude } object and displays as comma-separated pair
*/
-export function LocationField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps) {
+export function LocationField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) {
const config = field;
// Location is stored as { latitude, longitude } object
// For display, convert to "latitude, longitude" string format
@@ -39,12 +40,18 @@ export function LocationField({ value, onChange, field, readonly, ...props }: Fi
return (
);
}
diff --git a/packages/fields/src/widgets/LookupField.tsx b/packages/fields/src/widgets/LookupField.tsx
index 5c46b2a10..40d4a28d6 100644
--- a/packages/fields/src/widgets/LookupField.tsx
+++ b/packages/fields/src/widgets/LookupField.tsx
@@ -11,6 +11,7 @@ import { cn,
PopoverContent, EmptyValue } from '@object-ui/components';
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
@@ -191,7 +192,7 @@ function mapFieldTypeToFilterType(
* from the referenced object using `DataSource.find()`.
* Falls back to static `options` when no DataSource is available.
*/
-export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps) {
+export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps) {
const [isOpen, setIsOpen] = useState(false);
const { t } = useFieldTranslation();
const listboxId = React.useId();
@@ -912,8 +913,15 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
// Shared field trigger — the anchor for either the inline PeoplePicker
// (search fields) or the classic quick-select popover. No onClick: the Radix
// trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
+ //
+ // DOM pass-through onto this button — the widget's real focusable control —
+ // per objectui#3318. `name` is withheld: no native control here takes part
+ // in form submission, and a stray `name` on a button invites exactly the
+ // accidental-submitter semantics #3306 kept it off the SelectTrigger for.
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
const triggerButton = (
d.field).join(', ') })
: undefined}
+ // AFTER the spread so this widget's own computation wins (#3222):
+ // `fieldError` is the published validation slot — NOT the popover's
+ // fetch error, which is a widget-internal state named `error` below.
+ aria-invalid={!!fieldError}
>
{hydrating ? (
) {
const config = field as any;
@@ -91,8 +93,22 @@ export function MultiSelectField({
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 chip button below, computed from the
+ // published `error` slot (#3222), so whichever chip the user tabs to
+ // announces the failure. `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 (
@@ -108,6 +124,7 @@ export function MultiSelectField({
onClick={() => toggle(value)}
disabled={props.disabled}
aria-pressed={active}
+ aria-invalid={!!error}
data-testid={`multiselect-option-${opt.value}`}
className={cn(
'rounded-full border px-3 py-1 text-sm transition-colors',
diff --git a/packages/fields/src/widgets/ObjectField.tsx b/packages/fields/src/widgets/ObjectField.tsx
index ed428b57e..a33797f63 100644
--- a/packages/fields/src/widgets/ObjectField.tsx
+++ b/packages/fields/src/widgets/ObjectField.tsx
@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react';
import { Textarea, cn, EmptyValue } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
/**
* ObjectField - JSON object editor
* Allows editing structured JSON data
*/
-export function ObjectField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps
) {
+export function ObjectField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) {
const config = field;
// Initialize string state based on value
@@ -16,7 +17,9 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
};
const [jsonString, setJsonString] = useState(getInitialJsonString);
- const [error, setError] = useState(null);
+ // Named `parseError`, NOT `error`: `error` is the published validation slot
+ // on the widget contract (#3222) and is destructured above.
+ const [parseError, setParseError] = useState(null);
// Sync internal string state when value changes externally
// This is a controlled component pattern where we need to sync external changes
@@ -53,33 +56,39 @@ export function ObjectField({ value, onChange, field, readonly, ...props }: Fiel
const handleChange = (e: React.ChangeEvent) => {
const str = e.target.value;
setJsonString(str);
- setError(null);
+ setParseError(null);
if (!str.trim()) {
onChange(null);
return;
}
-
+
try {
const parsed = JSON.parse(str);
onChange(parsed);
} catch (e) {
// Invalid JSON - don't propagate change to parent, but keep local state
- setError("Invalid JSON");
+ setParseError("Invalid JSON");
}
};
return (
- {error &&
{error}
}
+ {parseError &&
{parseError}
}
);
}
diff --git a/packages/fields/src/widgets/ObjectRefField.tsx b/packages/fields/src/widgets/ObjectRefField.tsx
index 36ec0166b..15e66c630 100644
--- a/packages/fields/src/widgets/ObjectRefField.tsx
+++ b/packages/fields/src/widgets/ObjectRefField.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Combobox, EmptyValue, cn } from '@object-ui/components';
import { SchemaRendererContext } from '@object-ui/react';
import type { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
import { useFieldTranslation } from './useFieldTranslation';
/**
@@ -31,6 +32,7 @@ export function ObjectRefField({
onChange,
readonly,
className,
+ error,
...props
}: FieldWidgetComponentProps) {
const ctx = React.useContext(SchemaRendererContext);
@@ -91,8 +93,14 @@ export function ObjectRefField({
return {label} ;
}
+ // DOM pass-through onto the combobox trigger — the widget's real focusable
+ // control (objectui#3318). `name` is withheld: the trigger is a button, not
+ // a submission control (same reasoning as #3306's SelectTrigger).
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
+
return (
onChange(v as any)}
@@ -104,6 +112,8 @@ export function ObjectRefField({
// `w-[200px]` is a component default that left this control stranded at
// a third of the row while `名称` beside it ran full width.
className={cn('w-full', className)}
+ // AFTER the spread so this widget's own computation wins (#3222).
+ aria-invalid={!!error}
/>
);
}
diff --git a/packages/fields/src/widgets/QRCodeField.tsx b/packages/fields/src/widgets/QRCodeField.tsx
index 21fc18328..6675982f9 100644
--- a/packages/fields/src/widgets/QRCodeField.tsx
+++ b/packages/fields/src/widgets/QRCodeField.tsx
@@ -2,12 +2,13 @@ import React from 'react';
import { Input, Button, EmptyValue } from '@object-ui/components';
import { QrCode, Copy } from 'lucide-react';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
/**
* QR Code field widget - generates QR codes from text
* Uses a simple SVG-based QR code generator
*/
-export function QRCodeField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps) {
+export function QRCodeField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps) {
const [showQR, setShowQR] = React.useState(false);
const config = field;
@@ -47,12 +48,18 @@ export function QRCodeField({ value, onChange, field, readonly, ...props }: Fiel
onChange(e.target.value)}
placeholder={config?.placeholder || 'Enter text for QR code'}
disabled={readonly || props.disabled}
className={props.className}
+ // AFTER the spread so this widget's own computation wins: `error`
+ // is the published validation slot (#3222), `!!undefined` →
+ // explicit "false".
+ aria-invalid={!!error}
/>
{value && (
<>
diff --git a/packages/fields/src/widgets/RadioField.tsx b/packages/fields/src/widgets/RadioField.tsx
index 499b7c703..faf8d8deb 100644
--- a/packages/fields/src/widgets/RadioField.tsx
+++ b/packages/fields/src/widgets/RadioField.tsx
@@ -2,6 +2,7 @@ import React, { useId, useEffect } from 'react';
import { RadioGroup, RadioGroupItem, Label, EmptyValue } from '@object-ui/components';
import { isValueStillOffered, type OptionLike } from '@object-ui/core';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
import { OptionsEmptyState } from './OptionsEmptyState';
import { useCascadingOptions } from './useCascadingOptions';
@@ -29,6 +30,7 @@ export function RadioField({
dependsOn: dependsOnProp,
emptyHint,
dataSource: _dataSource,
+ error,
...props
}: FieldWidgetComponentProps
) {
const config = field as any;
@@ -77,11 +79,20 @@ export function RadioField({
}
return (
+ // DOM pass-through onto the radiogroup (objectui#3318). Unlike Radix
+ // `Select.Root` (#3306) this Root IS a real DOM element — a
+ // `` — and `radiogroup` is exactly the role
+ // WAI-ARIA designates to carry `aria-invalid` for a set of radios
+ // (`radio` itself does not support it): the group's state is announced
+ // when focus lands on any radio inside it.
{options.map((opt) => {
// Radix speaks strings — stringify the (possibly numeric,
diff --git a/packages/fields/src/widgets/RatingField.tsx b/packages/fields/src/widgets/RatingField.tsx
index e7b880b67..9e542ba14 100644
--- a/packages/fields/src/widgets/RatingField.tsx
+++ b/packages/fields/src/widgets/RatingField.tsx
@@ -2,12 +2,13 @@ import React from 'react';
import { Star } from 'lucide-react';
import { cn } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
/**
* Rating field widget - provides a star rating input
* Supports numeric values from 0 to max (default 5)
*/
-export function RatingField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps) {
+export function RatingField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps) {
// Get rating-specific configuration from field metadata
const ratingField = field as any;
const max = ratingField?.max ?? 5;
@@ -37,8 +38,21 @@ export function RatingField({ value, onChange, field, readonly, className, ...pr
);
}
+ // 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 star button below, computed from the
+ // published `error` slot (#3222). `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 (
-
+
{Array.from({ length: max }, (_, i) => (
setHoverValue(null)}
className="focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded"
disabled={readonly || props.disabled}
+ aria-invalid={!!error}
>
) {
const ctx = React.useContext(SchemaRendererContext);
@@ -133,6 +135,8 @@ export function RecipientPickerField({
// field is never un-editable.
return (
onChange(e.target.value as any)}
+ aria-invalid={!!error}
/>
);
}
@@ -149,8 +154,19 @@ export function RecipientPickerField({
return {options.find((o) => o.value === value)?.label ?? value} ;
}
+ // DOM pass-through onto the combobox trigger — the widget's real focusable
+ // control (objectui#3318). `name` is withheld: the trigger is a button, not
+ // a submission control (same reasoning as #3306's SelectTrigger).
+ //
+ // NOTE this widget stays on the #3318 ledger regardless: its dependency-
+ // gated state (no `recipient_type` chosen yet — the state a fresh form and
+ // the registry sweep render) is a plain hint paragraph with no focusable
+ // control, so there is nothing there to carry the attribute.
+ const { name: _domName, ...triggerDomProps } = toDomProps(props);
+
return (
onChange(v as any)}
@@ -163,6 +179,8 @@ export function RecipientPickerField({
emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
disabled={disabled}
className={cn('w-full', className)}
+ // AFTER the spread so this widget's own computation wins (#3222).
+ aria-invalid={!!error}
/>
);
}
diff --git a/packages/fields/src/widgets/TagsField.tsx b/packages/fields/src/widgets/TagsField.tsx
index f049677bb..2f8754137 100644
--- a/packages/fields/src/widgets/TagsField.tsx
+++ b/packages/fields/src/widgets/TagsField.tsx
@@ -1,13 +1,14 @@
import React from 'react';
import { Badge, Input, EmptyValue, cn } from '@object-ui/components';
import { FieldWidgetComponentProps } from './types';
+import { toDomProps } from './toDomProps';
/**
* TagsField - free-form list of string tags. Type a value and press Enter (or
* comma) to add it; click a tag's × to remove it. The stored value is a
* string[]. Used for the `tags` field type.
*/
-export function TagsField({ value, onChange, field, readonly, className, ...props }: FieldWidgetComponentProps) {
+export function TagsField({ value, onChange, field, readonly, className, error, ...props }: FieldWidgetComponentProps) {
const tags: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
const [draft, setDraft] = React.useState('');
@@ -54,6 +55,10 @@ export function TagsField({ value, onChange, field, readonly, className, ...prop
))}
setDraft(e.target.value)}
onKeyDown={onKeyDown}
@@ -61,6 +66,11 @@ export function TagsField({ value, onChange, field, readonly, className, ...prop
disabled={props.disabled}
placeholder={tags.length === 0 ? '输入后回车添加…' : ''}
className="h-7 flex-1 border-0 bg-transparent p-0 px-1 shadow-none focus-visible:ring-0 min-w-[8ch]"
+ // AFTER the spread so this widget's own computation wins (the #3222
+ // discipline): `error` is the published validation slot, and
+ // `!!undefined` yields an explicit "false" — a valid field SAYS it is
+ // valid rather than staying mute.
+ aria-invalid={!!error}
/>
);