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
86 changes: 86 additions & 0 deletions .changeset/field-widget-error-slot-follows-spec.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
---
"@object-ui/fields": minor
"@object-ui/components": minor
---

Field widgets are finally told when their field fails validation, and the props
slot that carries it takes the name the published contract gives it
(objectui#3222).

**Breaking** for anyone implementing a field widget (see migration below). The
repo version policy keeps this a `minor` — objectui's major tracks
`@objectstack`'s — so read the bump as "breaking within objectui".

## The a11y defect this fixes

`@objectstack/spec/ui`'s `FieldWidgetPropsSchema` — the published contract that
third-party and AI-authored field widgets are written against — has always
declared `error?: string`. `@object-ui/fields` declared its own slot as
`errorMessage`. That looked like a naming split; it was worse:

```
producers of `errorMessage` anywhere in packages/ + apps/ : 0
reads of `errorMessage` in packages/fields/src : 15 (7 widgets)
reads of `props.error` : 0
```

The slot was dead under BOTH spellings. No host ever passed it: the form
renderer showed validation text through its own `<FormMessage/>` and never
forwarded the prop. So `EmailField`, `CurrencyField`, `UrlField`,
`RichTextField`, `PercentField`, `TextAreaField` and `PhoneField` each computed
`aria-invalid={!!errorMessage}` from a value that was `undefined` forever —
**`aria-invalid` had never once been set, and a screen reader was never told
the field had failed validation.**

Worse than "never set": `<FormControl>` is a Radix `Slot` that hands its child a
CORRECT `aria-invalid`, but a widget's own attribute is written after the props
spread, so it wins. Those seven widgets were actively overwriting the right
answer with `false`.

FROM: `renderFieldComponent` received no validation state, and the widget props
type declared `errorMessage?: string`, which nothing produced.
TO: the form renderer passes react-hook-form's `fieldState.error?.message` down
as `error` when it renders a registered widget, and the props type declares
`error?: string`. Both ends of the contract are live for the first time; a
rename alone would only have swapped one dead key for another.

## Migration for widget authors

```diff
-export function MyField({ value, onChange, field, readonly, errorMessage }: FieldWidgetComponentProps< string >) {
- return <Input value={value} aria-invalid={!!errorMessage} />;
+export function MyField({ value, onChange, field, readonly, error }: FieldWidgetComponentProps< string >) {
+ return <Input value={value} aria-invalid={!!error} />;
```

No alias is kept. `errorMessage` was retained nowhere on purpose — a tolerant
second spelling is exactly the de-facto second contract AGENTS.md #0.1 forbids,
and it is what would let a missed call site go quiet again. Because
objectui#3221 had already removed the type's `[key: string]: any`, every missed
site is a compile error rather than a silent `any`, so the compiler — not grep
— validated this rename.

## Responsibilities are split, not duplicated

The widget consumes `error` **only** to drive `aria-invalid` on the control it
renders (which only it can do — `aria-invalid` has to sit on the input element).
The message TEXT stays with `<FormMessage/>` in the form renderer. A widget that
also renders the text double-displays it, and the docs, the agent prompt and the
tests all now say so.

For the same reason `required` — also declared by the spec, also never delivered
— is deliberately NOT lowered into widget props: the required marker has exactly
one author, the renderer's `<FormLabel>`, and giving widgets the flag invites a
second asterisk. The a11y state a widget could legitimately carry is
`aria-required`, which needs no contract change at all (`AriaAttributes` is
already part of the type and widgets already forward it).

Builtin field types are unaffected: they render inside `<FormControl>`, whose
Slot already supplies `aria-invalid`, so `error` is stripped there rather than
leaking into the DOM as a stray attribute.

Docs updated to match: `content/docs/guide/plugin-development.md`,
`skills/objectui/guides/plugin-development.md` and
`.github/prompts/component.prompt.md` — the last of which additionally used the
spec's non-generic type alias as a generic (`FieldWidgetProps< number >`) and
destructured a `mode` prop that exists on neither type.
52 changes: 35 additions & 17 deletions .github/prompts/component.prompt.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@

2. **Fields (@object-ui/fields):**
* Standard Input/Display widgets (Text, Number, Date, Select).
* Must implement `FieldWidgetProps`.
* Must implement `FieldWidgetComponentProps` (the package's own generic React interface — not the spec's non-generic `FieldWidgetProps` alias; see §2.A).

3. **Layouts & Patterns (@object-ui/layout):**
* Page structures (Sidebar, Header, AppLauncher).
Expand All@@ -35,15 +35,22 @@ You will be asked to build components in these 3 standard slots. Refer to `packa

### A. Field Widgets (`field:*`)
Responsible for **Input** (Edit Mode) and **Display** (Read Mode) of a specific data type.
* **Contract:** Must implement `FieldWidgetProps` (Ref: `src/ui/widget.zod.ts`).
* **Contract:** Two layers with the same shape and DIFFERENT names — do not mix them up.
* `FieldWidgetProps` (`@objectstack/spec/ui`, Ref: `src/ui/widget.zod.ts`) is the **declared** contract: a `z.infer` of `FieldWidgetPropsSchema`, so it is a plain **non-generic** type alias. Read it to learn what a widget receives.
* `FieldWidgetComponentProps<T>` (`@object-ui/fields`) is the **implemented** React interface, and the one you actually import and parameterize when writing a widget in this repo.
```typescript
type FieldWidgetProps<T = any> = {
import type { FieldWidgetComponentProps } from '@object-ui/fields';

type FieldWidgetComponentProps<T = any> = {
value: T;
onChange: (val: T) => void;
field: FieldSchema; // Config
field: FieldMetadata; // Config
readonly?: boolean;
disabled?: boolean;
error?: string; // active validation message — see below
}
```
The type is **closed**: a key it does not declare is a compile error, not a silent `any`.
* **Required Types (Ref: `src/data/field.zod.ts`):**
* **Textual:** `text` (Input), `textarea` (Multi-line), `password`, `email`, `url`, `phone`.
* **Rich Content:** `markdown` (Editor), `html` (WYSIWYG), `code` (Monaco/Ace).
Expand DownExpand Up@@ -168,24 +175,35 @@ Conversational and Generative UI components.
## 2. API Reference & Contracts

### A. Field Widget Implementation
**Reference:** `@objectstack/spec` -> `dist/ui/widget.zod.d.ts`
**Reference:** `packages/fields/src/widgets/types.ts` (implemented props), `@objectstack/spec` -> `dist/ui/widget.zod.d.ts` (declared contract)

Import the **generic** `FieldWidgetComponentProps<T>` from `@object-ui/fields`.
The spec's `FieldWidgetProps` is a non-generic alias — writing
`FieldWidgetProps< number >` does not compile. There is no `mode` prop on
either type; read-mode is `readonly`.

```typescript
import { FieldWidgetProps } from '@objectstack/spec/ui';

export function RatingField({
value,
onChange,
field,
mode
}: FieldWidgetProps<number>) {

if (mode === 'read') {
import type { FieldWidgetComponentProps } from '@object-ui/fields';

export function RatingField({
value,
onChange,
field,
readonly,
error,
}: FieldWidgetComponentProps<number>) {

if (readonly) {
return <span>{'★'.repeat(value || 0)}</span>;
}

return (
<div className="flex gap-1">
// `error` is the ACTIVE VALIDATION MESSAGE, supplied by the form renderer.
// Consume it as a boolean signal for a11y and nothing more: the message
// text is rendered by `<FormMessage/>` and the required marker by
// `<FormLabel>`, both in the form renderer. A widget that also prints the
// text double-displays it.
<div className="flex gap-1" role="radiogroup" aria-invalid={!!error}>
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
Expand DownExpand Up@@ -240,7 +258,7 @@ export const widgetRegistry = {

* **Statelessness:** Widgets should rely on `props.value` and `props.onChange`. Avoid internal state unless necessary for transient UI interactions (like hover).
* **Schema Awareness:** The widget must respect schema options (e.g., `field.required`, `field.readonly`, `field.options`).
* **Validation:** Rendering logic should handle `props.errorMessage` gracefully.
* **Validation:** `props.error` is the active validation message. Use it for the a11y state (`aria-invalid={!!error}`) and nothing else — the host renders the message text and the required marker, so a widget that renders either shows it twice.
* **Accessibility:** Use standard ARIA roles and keyboard navigation (Shadcn UI/Radix primitives recommended).

---
Expand Down
62 changes: 40 additions & 22 deletions content/docs/guide/plugin-development.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -187,10 +187,30 @@ type FieldWidgetComponentProps<T = any> = {
readonly?: boolean;
disabled?: boolean;
className?: string;
errorMessage?: string;
error?: string;
};
```

The validation slot is named `error`, matching `FieldWidgetPropsSchema` in
`@objectstack/spec/ui` — the published contract a widget is written against.
The form renderer supplies it from the active validation message.

### Who renders what

The widget and the form renderer split validation display, and the split is
not optional:

| Concern | Owner |
|---|---|
| `aria-invalid` on the input | **the widget** — only it renders the input element |
| the required marker (`*`) | **the form renderer** (`<FormLabel>`) |
| the message TEXT | **the form renderer** (`<FormMessage/>`) |

So consume `error` as a **boolean signal** — `aria-invalid={!!error}` — and do
not render the message yourself. The form already prints it below the control;
a widget that prints it too shows the user the same sentence twice. For the
same reason `required` is not in the props: the marker has one author.

### Example: Color Picker Field

```tsx
Expand All@@ -205,7 +225,7 @@ export function ColorPickerField({
field,
readonly,
disabled,
errorMessage,
error,
}: FieldWidgetComponentProps<string>) {
if (readonly) {
return (
Expand All@@ -220,26 +240,24 @@ export function ColorPickerField({
}

return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<input
type="color"
value={value || '#000000'}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="h-8 w-8 cursor-pointer rounded border-0 p-0"
/>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field?.placeholder || '#000000'}
disabled={disabled}
className="font-mono text-sm"
/>
</div>
{errorMessage && (
<span className="text-xs text-destructive">{errorMessage}</span>
)}
<div className="flex items-center gap-2">
<input
type="color"
value={value || '#000000'}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="h-8 w-8 cursor-pointer rounded border-0 p-0"
/>
<Input
value={value || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field?.placeholder || '#000000'}
disabled={disabled}
className="font-mono text-sm"
// The whole job of `error` here: tell assistive tech the field failed.
// The message text is rendered by the form, not by this widget.
aria-invalid={!!error}
/>
</div>
);
}
Expand Down
Loading
Loading