Skip to content

Commit 56409c2

Browse files
fix(fields,components): deliver the validation slot to field widgets as error (#3222) (#3289)
`@objectstack/spec/ui`'s `FieldWidgetPropsSchema` declares `error?: string`; `@object-ui/fields` declared `errorMessage?: string`. That looked like a naming split, but the slot was dead under BOTH spellings: nothing in packages/ or apps/ ever produced it, so the seven widgets computing `aria-invalid={!!errorMessage}` computed it from a permanent `undefined`. `aria-invalid` had never once been set on a failing field — and because a widget writes its own attribute AFTER the props spread, those seven actively OVERWROTE the correct `aria-invalid` that `<FormControl>`'s Radix Slot hands down. Two halves, both required: - the form renderer now passes `fieldState.error?.message` down as `error` when it renders a registered widget (the producer that never existed); - the slot adopts the spec's name across `FieldWidgetComponentProps` and the 7 widgets, with no alias kept. #3221 had already closed the type, so the compiler — not grep — validated the rename. Responsibilities stay split: the widget consumes `error` only to drive `aria-invalid` on the control it renders; the message text remains with `<FormMessage/>`. `required` is deliberately NOT lowered into widget props — the required marker has exactly one author (`<FormLabel>`), and the a11y state a widget could carry is `aria-required`, which needs no contract change. Builtin field types strip `error` instead: they render inside `<FormControl>`, whose Slot already supplies `aria-invalid`, so the prop would only reach the DOM as a stray attribute. Docs synced (plugin-development guide + skill, component.prompt.md — which also used the spec's non-generic alias as a generic and destructured a `mode` prop that exists on neither type). Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ff3ad7 commit 56409c2

18 files changed

Lines changed: 723 additions & 111 deletions
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
"@object-ui/fields": minor
3+
"@object-ui/components": minor
4+
---
5+
6+
Field widgets are finally told when their field fails validation, and the props
7+
slot that carries it takes the name the published contract gives it
8+
(objectui#3222).
9+
10+
**Breaking** for anyone implementing a field widget (see migration below). The
11+
repo version policy keeps this a `minor` — objectui's major tracks
12+
`@objectstack`'s — so read the bump as "breaking within objectui".
13+
14+
## The a11y defect this fixes
15+
16+
`@objectstack/spec/ui`'s `FieldWidgetPropsSchema` — the published contract that
17+
third-party and AI-authored field widgets are written against — has always
18+
declared `error?: string`. `@object-ui/fields` declared its own slot as
19+
`errorMessage`. That looked like a naming split; it was worse:
20+
21+
```
22+
producers of `errorMessage` anywhere in packages/ + apps/ : 0
23+
reads of `errorMessage` in packages/fields/src : 15 (7 widgets)
24+
reads of `props.error` : 0
25+
```
26+
27+
The slot was dead under BOTH spellings. No host ever passed it: the form
28+
renderer showed validation text through its own `<FormMessage/>` and never
29+
forwarded the prop. So `EmailField`, `CurrencyField`, `UrlField`,
30+
`RichTextField`, `PercentField`, `TextAreaField` and `PhoneField` each computed
31+
`aria-invalid={!!errorMessage}` from a value that was `undefined` forever —
32+
**`aria-invalid` had never once been set, and a screen reader was never told
33+
the field had failed validation.**
34+
35+
Worse than "never set": `<FormControl>` is a Radix `Slot` that hands its child a
36+
CORRECT `aria-invalid`, but a widget's own attribute is written after the props
37+
spread, so it wins. Those seven widgets were actively overwriting the right
38+
answer with `false`.
39+
40+
FROM: `renderFieldComponent` received no validation state, and the widget props
41+
type declared `errorMessage?: string`, which nothing produced.
42+
TO: the form renderer passes react-hook-form's `fieldState.error?.message` down
43+
as `error` when it renders a registered widget, and the props type declares
44+
`error?: string`. Both ends of the contract are live for the first time; a
45+
rename alone would only have swapped one dead key for another.
46+
47+
## Migration for widget authors
48+
49+
```diff
50+
-export function MyField({ value, onChange, field, readonly, errorMessage }: FieldWidgetComponentProps< string >) {
51+
- return <Input value={value} aria-invalid={!!errorMessage} />;
52+
+export function MyField({ value, onChange, field, readonly, error }: FieldWidgetComponentProps< string >) {
53+
+ return <Input value={value} aria-invalid={!!error} />;
54+
```
55+
56+
No alias is kept. `errorMessage` was retained nowhere on purpose — a tolerant
57+
second spelling is exactly the de-facto second contract AGENTS.md #0.1 forbids,
58+
and it is what would let a missed call site go quiet again. Because
59+
objectui#3221 had already removed the type's `[key: string]: any`, every missed
60+
site is a compile error rather than a silent `any`, so the compiler — not grep
61+
— validated this rename.
62+
63+
## Responsibilities are split, not duplicated
64+
65+
The widget consumes `error` **only** to drive `aria-invalid` on the control it
66+
renders (which only it can do — `aria-invalid` has to sit on the input element).
67+
The message TEXT stays with `<FormMessage/>` in the form renderer. A widget that
68+
also renders the text double-displays it, and the docs, the agent prompt and the
69+
tests all now say so.
70+
71+
For the same reason `required` — also declared by the spec, also never delivered
72+
— is deliberately NOT lowered into widget props: the required marker has exactly
73+
one author, the renderer's `<FormLabel>`, and giving widgets the flag invites a
74+
second asterisk. The a11y state a widget could legitimately carry is
75+
`aria-required`, which needs no contract change at all (`AriaAttributes` is
76+
already part of the type and widgets already forward it).
77+
78+
Builtin field types are unaffected: they render inside `<FormControl>`, whose
79+
Slot already supplies `aria-invalid`, so `error` is stripped there rather than
80+
leaking into the DOM as a stray attribute.
81+
82+
Docs updated to match: `content/docs/guide/plugin-development.md`,
83+
`skills/objectui/guides/plugin-development.md` and
84+
`.github/prompts/component.prompt.md` — the last of which additionally used the
85+
spec's non-generic type alias as a generic (`FieldWidgetProps< number >`) and
86+
destructured a `mode` prop that exists on neither type.

.github/prompts/component.prompt.md

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

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

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

3636
### A. Field Widgets (`field:*`)
3737
Responsible for **Input** (Edit Mode) and **Display** (Read Mode) of a specific data type.
38-
* **Contract:** Must implement `FieldWidgetProps` (Ref: `src/ui/widget.zod.ts`).
38+
* **Contract:** Two layers with the same shape and DIFFERENT names — do not mix them up.
39+
* `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.
40+
* `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.
3941
```typescript
40-
type FieldWidgetProps<T = any> = {
42+
import type { FieldWidgetComponentProps } from '@object-ui/fields';
43+
44+
type FieldWidgetComponentProps<T = any> = {
4145
value: T;
4246
onChange: (val: T) => void;
43-
field: FieldSchema; // Config
47+
field: FieldMetadata; // Config
4448
readonly?: boolean;
49+
disabled?: boolean;
50+
error?: string; // active validation message — see below
4551
}
4652
```
53+
The type is **closed**: a key it does not declare is a compile error, not a silent `any`.
4754
* **Required Types (Ref: `src/data/field.zod.ts`):**
4855
* **Textual:** `text` (Input), `textarea` (Multi-line), `password`, `email`, `url`, `phone`.
4956
* **Rich Content:** `markdown` (Editor), `html` (WYSIWYG), `code` (Monaco/Ace).
@@ -168,24 +175,35 @@ Conversational and Generative UI components.
168175
## 2. API Reference & Contracts
169176
170177
### A. Field Widget Implementation
171-
**Reference:** `@objectstack/spec` -> `dist/ui/widget.zod.d.ts`
178+
**Reference:** `packages/fields/src/widgets/types.ts` (implemented props), `@objectstack/spec` -> `dist/ui/widget.zod.d.ts` (declared contract)
179+
180+
Import the **generic** `FieldWidgetComponentProps<T>` from `@object-ui/fields`.
181+
The spec's `FieldWidgetProps` is a non-generic alias — writing
182+
`FieldWidgetProps< number >` does not compile. There is no `mode` prop on
183+
either type; read-mode is `readonly`.
172184
173185
```typescript
174-
import { FieldWidgetProps } from '@objectstack/spec/ui';
175-
176-
export function RatingField({
177-
value,
178-
onChange,
179-
field,
180-
mode
181-
}: FieldWidgetProps<number>) {
182-
183-
if (mode === 'read') {
186+
import type { FieldWidgetComponentProps } from '@object-ui/fields';
187+
188+
export function RatingField({
189+
value,
190+
onChange,
191+
field,
192+
readonly,
193+
error,
194+
}: FieldWidgetComponentProps<number>) {
195+
196+
if (readonly) {
184197
return <span>{''.repeat(value || 0)}</span>;
185198
}
186199

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

241259
* **Statelessness:** Widgets should rely on `props.value` and `props.onChange`. Avoid internal state unless necessary for transient UI interactions (like hover).
242260
* **Schema Awareness:** The widget must respect schema options (e.g., `field.required`, `field.readonly`, `field.options`).
243-
* **Validation:** Rendering logic should handle `props.errorMessage` gracefully.
261+
* **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.
244262
* **Accessibility:** Use standard ARIA roles and keyboard navigation (Shadcn UI/Radix primitives recommended).
245263

246264
---

content/docs/guide/plugin-development.md

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,30 @@ type FieldWidgetComponentProps<T = any> = {
187187
readonly?: boolean;
188188
disabled?: boolean;
189189
className?: string;
190-
errorMessage?: string;
190+
error?: string;
191191
};
192192
```
193193

194+
The validation slot is named `error`, matching `FieldWidgetPropsSchema` in
195+
`@objectstack/spec/ui` — the published contract a widget is written against.
196+
The form renderer supplies it from the active validation message.
197+
198+
### Who renders what
199+
200+
The widget and the form renderer split validation display, and the split is
201+
not optional:
202+
203+
| Concern | Owner |
204+
|---|---|
205+
| `aria-invalid` on the input | **the widget** — only it renders the input element |
206+
| the required marker (`*`) | **the form renderer** (`<FormLabel>`) |
207+
| the message TEXT | **the form renderer** (`<FormMessage/>`) |
208+
209+
So consume `error` as a **boolean signal**`aria-invalid={!!error}` — and do
210+
not render the message yourself. The form already prints it below the control;
211+
a widget that prints it too shows the user the same sentence twice. For the
212+
same reason `required` is not in the props: the marker has one author.
213+
194214
### Example: Color Picker Field
195215

196216
```tsx
@@ -205,7 +225,7 @@ export function ColorPickerField({
205225
field,
206226
readonly,
207227
disabled,
208-
errorMessage,
228+
error,
209229
}: FieldWidgetComponentProps<string>) {
210230
if (readonly) {
211231
return (
@@ -220,26 +240,24 @@ export function ColorPickerField({
220240
}
221241

222242
return (
223-
<div className="flex flex-col gap-1">
224-
<div className="flex items-center gap-2">
225-
<input
226-
type="color"
227-
value={value || '#000000'}
228-
onChange={(e) => onChange(e.target.value)}
229-
disabled={disabled}
230-
className="h-8 w-8 cursor-pointer rounded border-0 p-0"
231-
/>
232-
<Input
233-
value={value || ''}
234-
onChange={(e) => onChange(e.target.value)}
235-
placeholder={field?.placeholder || '#000000'}
236-
disabled={disabled}
237-
className="font-mono text-sm"
238-
/>
239-
</div>
240-
{errorMessage && (
241-
<span className="text-xs text-destructive">{errorMessage}</span>
242-
)}
243+
<div className="flex items-center gap-2">
244+
<input
245+
type="color"
246+
value={value || '#000000'}
247+
onChange={(e) => onChange(e.target.value)}
248+
disabled={disabled}
249+
className="h-8 w-8 cursor-pointer rounded border-0 p-0"
250+
/>
251+
<Input
252+
value={value || ''}
253+
onChange={(e) => onChange(e.target.value)}
254+
placeholder={field?.placeholder || '#000000'}
255+
disabled={disabled}
256+
className="font-mono text-sm"
257+
// The whole job of `error` here: tell assistive tech the field failed.
258+
// The message text is rendered by the form, not by this widget.
259+
aria-invalid={!!error}
260+
/>
243261
</div>
244262
);
245263
}

0 commit comments

Comments
 (0)