From db069328f0a1ac0c399440bf426ecdc8baf3cf36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 11:30:23 +0000 Subject: [PATCH] =?UTF-8?q?docs(protocol):=20widget-contract=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E3=80=8C=E8=B0=81=E6=B8=B2=E6=9F=93=E4=BB=80=E4=B9=88?= =?UTF-8?q?=E3=80=8D=E2=80=94=E2=80=94=E6=A0=A1=E9=AA=8C=E6=96=87=E6=A1=88?= =?UTF-8?q?=E5=BD=92=E5=AE=BF=E4=B8=BB,required=20=E5=8F=AA=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E4=B8=BA=20aria-required=20(#4866)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#3222 按方向 1(objectui 跟随 spec)落地后(objectui PR #3289), 本页两处教法会让照抄它的第三方 widget 出现双份显示,按裁定收口。契约本身 是对的,`packages/spec/src/ui/widget.zod.ts` 一个字未改。 1. `CustomRatingField` 示例删掉 `{error && {error}}`。 在 #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」保持不动。 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE --- .../protocol/objectui/widget-contract.mdx | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) 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`: