import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, omit, untrack } from "solid-js"; import { type FieldRootState, useFieldRootContext, } from "../../internals/field-context.ts"; import { fieldStateAttrs } from "../../internals/field-state-attrs.ts"; import { useAriaLabelledBy } from "../../internals/labelable-provider/use-aria-labelled-by.ts"; import { useLabelableId } from "../../internals/labelable-provider/use-labelable-id.ts"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { useFieldControlRegistration } from "../../internals/use-field-control-registration/use-field-control-registration.ts"; import { useFieldMessagesContext } from "../field-messages-context.ts"; export interface FieldControlState extends FieldRootState {} export interface FieldControlProps extends BaseUIComponentProps<"input", FieldControlState> { /** Tag/component to render. Defaults to `"input"`. */ as?: ValidComponent; } /** * The form control to label and validate. Renders an `` element. * * Wires: * - id registration into the surrounding `` (so * `` can point `htmlFor` at us). * - value registration into the surrounding `` (so form-level * validation / submit focus can find us). * - `aria-labelledby` composed from any labels registered with the provider. * - `aria-describedby` composed from descriptions + errors registered with * the field-messages registry. * - `onInput` / `onBlur` / `onFocus` into the root's touched/dirty/filled/ * focused setters and the validation pipeline (`validation.commit` on * blur when `validationMode === 'onBlur'`; `validation.getInputValidationProps()` * merges an `onInput`-driven commit for `onChange`/`onSubmit` modes). */ export function FieldControl(props: FieldControlProps): JSX.Element { const field = useFieldRootContext(); const messages = useFieldMessagesContext(); // Register control id with the labelable-provider so ``'s // `htmlFor` wires up. `useLabelableId` handles the one-shot registration // + cleanup. We pass `props.id` (read once, belt-and-braces via // `untrack` in the hook itself) to honour a consumer override. const { id } = useLabelableId({ id: untrack(() => (typeof props.id === "string" ? props.id : undefined)), }); const disabled = createMemo( (): boolean => field.disabled() || props.disabled === true, ); const state = createMemo( (): FieldControlState => ({ ...field.state(), disabled: disabled(), }), ); // Register our live value with the surrounding ``. The root // uses this to key the control into `
`'s `fields` map and to // refresh the registration on value changes (via `useValueChanged`). // `getValue` reads directly from the input element — matches React's // "validation.inputRef.current?.value" fallback. useFieldControlRegistration({ value: () => props.value, id: () => id, controlRef: field.validation.inputRef, getValue: () => field.validation.inputRef.current?.value, }); // Keep `filled` in sync with either an external `value` (if controlled) // or the input element's `value` attribute on mount. Mirrors React's // `useIsoLayoutEffect` branch in FieldControl. createEffect( () => { const externalValue = props.value; return externalValue; }, (externalValue) => { const hasExternal = externalValue != null; const input = field.validation.inputRef.current; if (input?.value || (hasExternal && externalValue !== "")) { field.setFilled(true); } else if (hasExternal && externalValue === "") { field.setFilled(false); } }, ); const ariaLabelledBy = useAriaLabelledBy(); const ariaDescribedBy = createMemo((): string | undefined => { const ids = messages.getMessageIds(); if (ids.length === 0) return undefined; return ids.join(" "); }); const userStyling = resolveClassStyle(props, () => state()); const userHtmlProps = omit( props, "class", "style", "attrs", "as", "id", ) as Record; const internalProps = mergeRootProps(fieldStateAttrs(state), { id, ref: (el: HTMLInputElement | null): void => { field.validation.inputRef.current = el; }, get disabled() { return disabled(); }, get name() { return field.name() ?? props.name; }, get "data-disabled"(): "" | undefined { return disabled() ? "" : undefined; }, get "aria-labelledby"() { return ariaLabelledBy() ?? ""; }, get "aria-describedby"() { return ariaDescribedBy() ?? ""; }, onFocus: () => { field.setFocused(true); }, onBlur: (event: FocusEvent & { currentTarget: HTMLInputElement }) => { field.setTouched(true); field.setFocused(false); if (field.validationMode() === "onBlur") { void field.validation.commit(event.currentTarget.value); } }, onInput: (event: InputEvent & { currentTarget: HTMLInputElement }) => { const value = event.currentTarget.value; field.setDirty(value !== field.validityData().initialValue); field.setFilled(value !== ""); }, onKeyDown: (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { if (event.currentTarget.tagName === "INPUT" && event.key === "Enter") { field.setTouched(true); void field.validation.commit(event.currentTarget.value); } }, }); const styledUserProps = mergeRootProps(userStyling, userHtmlProps); const merged = mergeRootProps( mergeRootProps( internalProps, field.validation.getInputValidationProps() as Record, ), styledUserProps, ); const Tag = dynamic(() => props.as ?? "input"); return ; }