import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, createSignal, omit } from "solid-js"; import { FieldContext, type FieldContextValue, type FieldRegistration, type FieldRootState, type FieldValidateFn, type FieldValidationMode, type FieldValidityData, } from "../../internals/field-context.ts"; import { fieldStateAttrs } from "../../internals/field-state-attrs.ts"; import { useFieldsetContext } from "../../internals/fieldset-context.ts"; import { LabelableProvider } from "../../internals/labelable-provider/labelable-provider"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { createField } from "../create-field.tsx"; import { FieldMessagesContext, type FieldMessagesContextValue, } from "../field-messages-context.ts"; export type { FieldRootState, FieldValidityData }; export interface FieldRootProps extends BaseUIComponentProps<"div", FieldRootState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Whether the component should ignore user interaction. * Takes precedence over the `disabled` prop on the `` component. * @default false */ disabled?: MaybeAccessor; /** * Identifies the field when a form is submitted. * Takes precedence over the `name` prop on the `` component. */ name?: MaybeAccessor; /** * A function for custom validation. Return a string or an array of strings * with the error message(s) if the value is invalid, or `null` if the value * is valid. Asynchronous functions are supported, but they do not prevent * form submission when using `validationMode="onSubmit"`. */ validate?: FieldValidateFn | undefined; /** * Determines when the field should be validated. * * - `onSubmit`: triggers validation when the form is submitted, and * re-validates on change after submission. * - `onBlur`: triggers validation when the control loses focus. * - `onChange`: triggers validation on every change to the control value. * * @default 'onSubmit' */ validationMode?: MaybeAccessor; /** * How long to wait between `validate` callbacks if `validationMode="onChange"` * is used. Specified in milliseconds. * @default 0 */ validationDebounceTime?: MaybeAccessor; /** * Whether the field is invalid. Useful when the field state is controlled * by an external library. */ invalid?: MaybeAccessor; /** * Whether the field's value has been changed from its initial value. * Useful when the field state is controlled externally. */ dirty?: MaybeAccessor; /** * Whether the field has been touched. Useful when the field state is * controlled externally. */ touched?: MaybeAccessor; } /** * Groups all parts of the field. Renders a `
` element. * * Thin structural adapter over `createField` — composes the primitive and * bridges its api into the `FieldContext` shape that the descendant parts * (``, ``, etc.) consume. Also renders a * `` so label/control can discover each other without * manual id plumbing, and a `` so description/error * elements can contribute their ids to the control's `aria-describedby`. */ export function FieldRoot(props: FieldRootProps): JSX.Element { const fieldset = useFieldsetContext(); // disabled inherits from const disabledProp = createMemo( (): boolean => (readOption(props.disabled) ?? false) || fieldset.disabled(), ); // Compose the primitive. All reactive options read through accessors so // createField can track them without re-creating on every render. const c = createField({ disabled: disabledProp, invalid: (): boolean | undefined => readOption(props.invalid), name: (): string | undefined => readOption(props.name), // Getter, not an eager read: createField reads `options.validate` // lazily at validation time, and an eager `props.validate` read here // is untracked in the component body (`[STRICT_READ_UNTRACKED]`). get validate() { return props.validate; }, validationMode: (): FieldValidationMode | undefined => readOption(props.validationMode), validationDebounceTime: (): number | undefined => readOption(props.validationDebounceTime), dirty: (): boolean | undefined => readOption(props.dirty), touched: (): boolean | undefined => readOption(props.touched), }); const i = c._internal; // Bridge registerFieldControl: pushes its registration // here via useFieldControlRegistration. We sync the control's actual // DOM id into createField (so the form-fields-map key matches the // input's id, not createField's auto-generated controlId) and seed the // initial value into validityData. let activeSource: symbol | null = null; const registerFieldControl = ( source: symbol, registration: FieldRegistration | undefined, ): void => { if (!registration) { if (activeSource !== source) return; activeSource = null; return; } activeSource = source; if (registration.id) { i.setControlId(registration.id); } i.pushInitialValue(registration); }; // Published FieldContext. Maps createField's api to the FieldContextValue // shape that all descendant parts read from. const contextValue: FieldContextValue = { invalid: c.invalid, name: (): string | undefined => readOption(props.name), disabled: c.disabled, validityData: c.validity, setValidityData: i.setValidityData, touched: c.touched, setTouched: c.setTouched, dirty: c.dirty, setDirty: c.setDirty, filled: c.filled, setFilled: c.setFilled, focused: c.focused, setFocused: c.setFocused, state: i.state as () => FieldRootState, validate: i.validateFn, validationMode: i.validationMode, validationDebounceTime: i.validationDebounceTime, shouldValidateOnChange: i.shouldValidateOnChange, markedDirtyRef: i.markedDirtyRef, registerFieldControl, validation: i.validation, present: true, }; // Description/Error id registry. Keyed by symbol so a description AND an // error can coexist; getMessageIds() returns them in registration order. const messageRegistrations = new Map(); const [messageIdsSignal, setMessageIdsSignal] = createSignal< readonly string[] >(Object.freeze([] as string[]), { ownedWrite: true, equals: false }); const registerMessageId = (source: symbol, id: string | undefined): void => { if (id === undefined) { if (!messageRegistrations.has(source)) return; messageRegistrations.delete(source); } else { const existing = messageRegistrations.get(source); if (existing === id) return; messageRegistrations.set(source, id); } setMessageIdsSignal( Object.freeze(Array.from(messageRegistrations.values())), ); }; const messagesContextValue: FieldMessagesContextValue = { registerMessageId, getMessageIds: messageIdsSignal as () => readonly string[], present: true, }; const userHtmlProps = omit( props, "class", "style", "attrs", "as", "disabled", "name", "validate", "validationMode", "validationDebounceTime", "invalid", "dirty", "touched", "children", ) as Record; const stateAccessor = i.state as () => FieldRootState; const userStyling = resolveClassStyle(props, () => stateAccessor()); const internalProps = mergeRootProps(fieldStateAttrs(stateAccessor), { get "data-disabled"(): "" | undefined { return stateAccessor().disabled ? "" : undefined; }, }); const styledUserProps = mergeRootProps(userStyling, userHtmlProps); const merged = mergeRootProps(internalProps, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); }