diff --git a/content/docs/protocol/objectui/widget-contract.mdx b/content/docs/protocol/objectui/widget-contract.mdx index 59c880bde9..d7a1ed0998 100644 --- a/content/docs/protocol/objectui/widget-contract.mdx +++ b/content/docs/protocol/objectui/widget-contract.mdx @@ -58,10 +58,13 @@ interface FieldWidgetProps { // Read-only mode flag. When true, display the value but don't allow editing. readonly: boolean; - // Required field flag. Indicate the required state visually and validate accordingly. + // Required field flag. Reflect it as `aria-required` on the control you render. + // Never draw your own required marker — the host's label already owns the `*`. required: boolean; - // Validation error message to display, when present. + // The active validation message, absent while the field is valid. A signal for + // `aria-invalid`, not text for the widget to render: the host renders the + // message itself. See "Who Renders What" below. error?: string; // Complete field definition from the schema (type, constraints, options, etc.). @@ -84,7 +87,13 @@ import type { FieldWidgetProps } from '@objectstack/spec/ui'; function CustomRatingField({ value, onChange, readonly, required, error }: FieldWidgetProps) { return ( -
+
{[1, 2, 3, 4, 5].map((star) => ( !readonly && onChange(star)} /> ))} - {error && {error}}
); } ``` +### Who Renders What + +A widget shares a field's chrome with its host, and each piece below has exactly **one** +owner. Rendering one the host already renders is the classic custom-widget defect: the +same sentence, or the same asterisk, appears twice. + +| Concern | Owner | +|---|---| +| `aria-invalid` on the control element | **the widget** — only it renders that element | +| The validation message **text** | **the host** (objectui's ``) | +| The required marker `*` | **the host** (objectui's ``) | + +- **`error` is a signal, not text to render.** It carries the active message string — + objectui feeds it from react-hook-form's `fieldState.error?.message` and leaves it + `undefined` while the field is valid — but the host already renders that text below the + control. Read it to set `aria-invalid`, nothing else. A widget that also prints it + displays the same message twice (objectui#3222). +- **If you compute `aria-invalid` yourself, derive it from `error` — and mind the spread + order.** A host may already be injecting a correct `aria-invalid`: objectui's + `` is a Radix `Slot` that does. So forward the props you don't consume + onto the control you render, and never write an `aria-invalid` computed from something + else *after* that spread — it silently overwrites the host's correct value with `false`. + That is exactly what seven built-in objectui widgets did while the slot went unproduced, + so an invalid field was never announced to a screen reader (objectui#3222). +- **Never draw your own required marker.** The host's label owns the `*`; a second author + for it produces the same double display, which is why `required` is deliberately absent + from objectui's rendered props type. The one thing a widget genuinely adds is the state + on the control — `aria-required`, which assistive tech announces *as* a state instead of + folding it into the accessible name, and which keeps working for a field rendered with + no label at all (objectui#3290). Reflect `required` as `aria-required` and stop there; + objectui goes further and injects `aria-required` itself, so forwarding your leftover + props gets it for free. Do **not** set the native `required` attribute — that arms the + browser's own constraint-validation bubble alongside the host's messages. + ## Field Types Each field declares a `type`. The renderer auto-infers a widget from the type; a custom `widget` name on the field view overrides that inference. The full set of field types is defined by the `FieldType` enum in `packages/spec/src/data/field.zod.ts`: