Skip to content

Commit 00f2bcd

Browse files
os-zhuangclaude
andauthored
docs(protocol): widget-contract 收口「谁渲染什么」——校验文案归宿主,required 只反映为 aria-required (#4866) (#5914)
objectui#3222 按方向 1(objectui 跟随 spec)落地后(objectui PR #3289), 本页两处教法会让照抄它的第三方 widget 出现双份显示,按裁定收口。契约本身 是对的,`packages/spec/src/ui/widget.zod.ts` 一个字未改。 1. `CustomRatingField` 示例删掉 `{error && <span className="error">{error}</span>}`。 在 #3222 之前 widget 拿不到 `error`,这行永远不执行,问题是隐性的; PR #3289 让表单渲染器真的把 `fieldState.error?.message` 传下来之后, 宿主的 FormMessage 与 widget 会把同一句话画两遍。示例改为把 `error` 只用于 `aria-invalid`,并顺手用上一直被解构但从未使用的 `required` (`aria-required={required || undefined}`,与 objectui 渲染器同一写法)。 2. `required` 的「Indicate the required state visually」改为限定表述: 必填标记 `*` 由宿主的 FormLabel 拥有,widget 只把它反映成控件上的 `aria-required`。这正是 #3222 决定不把 `required` 布尔下沉到 widget props 的理由(objectui#3290:真正需要的是 aria-required,不需要新增契约键)。 同一声明块里 `error` 的注释原文是「Validation error message to display」, 与第 1 点是同一处失实的两半,一并改成「信号而非待渲染文案」。 新增「Who Renders What」一节固定三项归属(aria-invalid 归 widget、文案归宿主 FormMessage、必填标记归宿主 FormLabel),并记下 PR #3289 实测到的 spread 顺序陷阱:宿主的 FormControl(Radix Slot)本来就递正确的 aria-invalid, widget 在 spread 之后写一个来源不同的 aria-invalid 会把它覆盖成 false。 `:48` 的「the source of truth is FieldWidgetPropsSchema」保持不动。 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18d0695 commit 00f2bcd

1 file changed

Lines changed: 46 additions & 4 deletions

File tree

content/docs/protocol/objectui/widget-contract.mdx

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ interface FieldWidgetProps {
5858
// Read-only mode flag. When true, display the value but don't allow editing.
5959
readonly: boolean;
6060

61-
// Required field flag. Indicate the required state visually and validate accordingly.
61+
// Required field flag. Reflect it as `aria-required` on the control you render.
62+
// Never draw your own required marker — the host's label already owns the `*`.
6263
required: boolean;
6364

64-
// Validation error message to display, when present.
65+
// The active validation message, absent while the field is valid. A signal for
66+
// `aria-invalid`, not text for the widget to render: the host renders the
67+
// message itself. See "Who Renders What" below.
6568
error?: string;
6669

6770
// Complete field definition from the schema (type, constraints, options, etc.).
@@ -84,20 +87,59 @@ import type { FieldWidgetProps } from '@objectstack/spec/ui';
8487

8588
function CustomRatingField({ value, onChange, readonly, required, error }: FieldWidgetProps) {
8689
return (
87-
<div className="rating-field" aria-invalid={!!error}>
90+
<div
91+
className="rating-field"
92+
// `error` drives the invalid STATE. Its text is the host's to render.
93+
aria-invalid={!!error}
94+
// The required STATE, on the control. Not a second asterisk.
95+
aria-required={required || undefined}
96+
>
8897
{[1, 2, 3, 4, 5].map((star) => (
8998
<Star
9099
key={star}
91100
filled={Number(value) >= star}
92101
onClick={() => !readonly && onChange(star)}
93102
/>
94103
))}
95-
{error && <span className="error">{error}</span>}
96104
</div>
97105
);
98106
}
99107
```
100108

109+
### Who Renders What
110+
111+
A widget shares a field's chrome with its host, and each piece below has exactly **one**
112+
owner. Rendering one the host already renders is the classic custom-widget defect: the
113+
same sentence, or the same asterisk, appears twice.
114+
115+
| Concern | Owner |
116+
|---|---|
117+
| `aria-invalid` on the control element | **the widget** — only it renders that element |
118+
| The validation message **text** | **the host** (objectui's `<FormMessage />`) |
119+
| The required marker `*` | **the host** (objectui's `<FormLabel>`) |
120+
121+
- **`error` is a signal, not text to render.** It carries the active message string —
122+
objectui feeds it from react-hook-form's `fieldState.error?.message` and leaves it
123+
`undefined` while the field is valid — but the host already renders that text below the
124+
control. Read it to set `aria-invalid`, nothing else. A widget that also prints it
125+
displays the same message twice (objectui#3222).
126+
- **If you compute `aria-invalid` yourself, derive it from `error` — and mind the spread
127+
order.** A host may already be injecting a correct `aria-invalid`: objectui's
128+
`<FormControl>` is a Radix `Slot` that does. So forward the props you don't consume
129+
onto the control you render, and never write an `aria-invalid` computed from something
130+
else *after* that spread — it silently overwrites the host's correct value with `false`.
131+
That is exactly what seven built-in objectui widgets did while the slot went unproduced,
132+
so an invalid field was never announced to a screen reader (objectui#3222).
133+
- **Never draw your own required marker.** The host's label owns the `*`; a second author
134+
for it produces the same double display, which is why `required` is deliberately absent
135+
from objectui's rendered props type. The one thing a widget genuinely adds is the state
136+
on the control — `aria-required`, which assistive tech announces *as* a state instead of
137+
folding it into the accessible name, and which keeps working for a field rendered with
138+
no label at all (objectui#3290). Reflect `required` as `aria-required` and stop there;
139+
objectui goes further and injects `aria-required` itself, so forwarding your leftover
140+
props gets it for free. Do **not** set the native `required` attribute — that arms the
141+
browser's own constraint-validation bubble alongside the host's messages.
142+
101143
## Field Types
102144

103145
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`:

0 commit comments

Comments
 (0)