import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, omit, onCleanup, Show, untrack, } from "solid-js"; import { useBaseUiId } from "../../internals/base-ui-id"; import { type FieldRootState, type FieldValidityState, useFieldRootContext, } from "../../internals/field-context.ts"; import { fieldStateAttrs } from "../../internals/field-state-attrs.ts"; import { useFormContext } from "../../internals/form-context.ts"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { useFieldMessagesContext } from "../field-messages-context.ts"; export interface FieldErrorState extends FieldRootState {} export interface FieldErrorProps extends BaseUIComponentProps<"div", FieldErrorState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Determines whether to show the error message according to the field's * [ValidityState](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState). * * - `true` forces the error to render regardless of validity (lets external * libraries drive visibility). * - A string picks a specific `ValidityState` key — the error renders only * when that key is `true` in the field's validity snapshot. * - Omitted: render when the field has a `
`-level error for its * `name`, OR when the field's own validity has collapsed to `false`. */ match?: boolean | keyof ValidityState | undefined; } /** * An error message displayed when the field control fails validation. * Renders a `
` element (inline — the caller can override * via `render`). * * Conditionally rendered via ``: no DOM node is produced when the * field is valid (and no override match hits). The error id is registered * with the surrounding ``'s message registry ONLY while the * element is actually rendered — unmounting cleans up `onCleanup` + the * `` block's tear-down. */ export function FieldError(props: FieldErrorProps): JSX.Element { const field = useFieldRootContext(); const form = useFormContext(); const messages = useFieldMessagesContext(); // Stable id for the error element. Shared across the mount/unmount cycles // triggered by `` — the registry is what tracks presence, the id // itself is fixed for the component's lifetime. const id = useBaseUiId( untrack(() => (typeof props.id === "string" ? props.id : undefined)), ); // Accessor-friendly reads for `match` and `name` — any of these can drive // the `rendered` memo; reading them via accessors instead of at the top of // the component keeps Solid 2's STRICT_READ_UNTRACKED rule happy. const match = (): FieldErrorProps["match"] => props.match; const name = (): string | undefined => field.name(); const formError = createMemo((): string | string[] | null => { const n = name(); if (!n) return null; const errs = form.errors(); const e = errs[n]; return e === undefined ? null : e; }); const rendered = createMemo((): boolean => { const m = match(); if (m === true) return true; if (typeof m === "string") { const v = field.validityData().state as FieldValidityState; return Boolean(v[m as keyof FieldValidityState]); } if (formError() !== null) return true; return field.validityData().state.valid === false; }); // Track the rendered-state transition and register/unregister the id with // the message registry accordingly. The registration is what makes the // control's `aria-describedby` pick us up. // // A split `createEffect(compute, apply)` is the right Solid 2 primitive: // the compute phase observes `rendered()` (tracked); the apply phase // writes into the registry (side effect, unowned scope, safe). Using // `onCleanup` in the apply is legal — it's unowned and the cleanup fires // on the NEXT apply OR on owner disposal. const source = Symbol("field-error"); createEffect( () => rendered(), (shown) => { if (shown) { messages.registerMessageId(source, id); return () => { messages.registerMessageId(source, undefined); }; } }, ); // Extra safety: also unregister on root disposal. (The split-effect // cleanup already handles the "rendered → not rendered" transition; this // catches the "still rendered on disposal" case.) onCleanup(() => { messages.registerMessageId(source, undefined); }); const errorMessage = createMemo((): JSX.Element => { const m = match(); const formErr = formError(); const validity = field.validityData(); const hasSpecificMatch = typeof m === "string"; const clientMessage: JSX.Element = validity.errors.length > 1 ? (
    {validity.errors.map((message) => (
  • {message}
  • ))}
) : ( validity.error ); if (hasSpecificMatch) return clientMessage; if (formErr != null) { // `formErr` may be string or string[]; match React: join arrays // with a newline (the consumer typically only renders the first, // but we surface the whole thing). return Array.isArray(formErr) ? formErr.join("\n") : formErr; } return clientMessage; }); const userStyling = resolveClassStyle(props, () => field.state()); const userHtmlProps = omit( props, "class", "style", "attrs", "as", "id", "match", "children", ) as Record; const internalProps = mergeRootProps(fieldStateAttrs(field.state), { id, role: "alert" as const, get "data-disabled"(): "" | undefined { return field.state().disabled ? "" : undefined; }, }); const styledUserProps = mergeRootProps(userStyling, userHtmlProps); const merged = mergeRootProps(internalProps, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); const element = (): JSX.Element => ( {props.children ?? errorMessage()} ); return {(_item) => element()}; }