import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, omit } from "solid-js"; import { useDirection } from "../direction-provider/direction-provider-context.ts"; import { useBaseUiId } from "../internals/base-ui-id"; import { createChangeEventDetails, REASONS, } from "../internals/change-event-details"; import { composeRefs, userRef } from "../internals/compose-refs"; import { CompositeList } from "../internals/composite/composite-list.tsx"; import { useCompositeRoot } from "../internals/composite/composite-root.ts"; import { createControlledState } from "../internals/create-controlled-state"; import { useFieldRootContext } from "../internals/field-context"; import { fieldStateAttrs } from "../internals/field-state-attrs"; 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 { useFieldControlValidation } from "../internals/use-field-control-validation/use-field-control-validation.ts"; import { useValueChanged } from "../internals/use-value-changed.ts"; import { createRadioGroup } from "./create-radio-group.tsx"; import { type RadioGroupChangeEventDetails, type RadioGroupChangeEventReason, RadioGroupContext, type RadioGroupContextValue, type RadioGroupState, } from "./radio-group-context.ts"; export type { RadioGroupChangeEventDetails, RadioGroupChangeEventReason, RadioGroupState, }; export interface RadioGroupProps extends Omit, "value"> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; /** @default false */ disabled?: MaybeAccessor; /** @default false */ readOnly?: MaybeAccessor; /** @default false */ required?: MaybeAccessor; name?: string | undefined; form?: string | undefined; /** * Controlled selected value. When provided (resolved value not * `undefined`), the consumer owns the state — pair with * {@link onValueChange}; use `null` for a controlled "nothing selected" * state. When omitted, the group manages its own value (seeded from * {@link defaultValue}). */ value?: MaybeAccessor; /** * Initial selected value when uncontrolled. Read once at mount. * @default undefined */ defaultValue?: MaybeAccessor; onValueChange?: | ((value: Value, eventDetails: RadioGroupChangeEventDetails) => void) | undefined; inputRef?: ((element: HTMLInputElement | null) => void) | undefined; } /** * Groups a collection of `` elements. Renders a `
` * (or `as` override). * * §1 wave 2: replaces `renderElement` with `dynamic(() => props.as ?? "div")`. * Field-validity attrs flow through `fieldStateAttrs(state)`. Aria-invalid * comes from `validation.getAriaInvalidProps()`. The composite root's bag * (aria-orientation, onKeyDown, onFocus) is composed via `mergeRootProps`. * * Compose order: wrapperHandlers ∘ composite.props ∘ internalSlot ∘ aria-invalid * ∘ fieldStateAttrs ∘ userProps. The wrapper's `onKeyDown` runs BEFORE the * composite's `handleKeyDown` so the `touched` latch is set ahead of the * composite's queueMicrotask(focus) that triggers the auto-select on the next * radio's onFocus — same ordering rule the React port uses. */ export function RadioGroup( props: RadioGroupProps, ): JSX.Element { const field = useFieldRootContext(); const direction = useDirection(); const disabled = (): boolean => field.disabled() || readOption(props.disabled) === true; const readOnly = (): boolean => readOption(props.readOnly) === true; const required = (): boolean => readOption(props.required) === true; const name = (): string | undefined => field.name() ?? props.name; const form = (): string | undefined => props.form; const id = useBaseUiId(typeof props.id === "string" ? props.id : undefined); // Controlled / uncontrolled selected value (Base UI `value` / // `defaultValue` contract, mirroring React's `useControlled` in // `RadioGroup`): the `value` prop wins while its resolved value is // defined (`null` is a controlled "nothing selected"); otherwise an // internal signal (seeded once from `defaultValue`) backs the state. const [resolvedValue, setUncontrolledValue] = createControlledState< Value | undefined >({ controlled: () => readOption(props.value), default: () => readOption(props.defaultValue), fallback: undefined, name: "", state: "value", }); const c = createRadioGroup({ value: resolvedValue, disabled, readOnly, required, name, onValueChange: (next, event) => { const details = createChangeEventDetails( REASONS.none, event, ) as RadioGroupChangeEventDetails; // Base UI change flow: fire onValueChange first, honor // `details.cancel()`, then write the uncontrolled state (a no-op // for reads while the `value` prop is controlled). props.onValueChange?.(next, details); if (details.isCanceled) { event.preventDefault(); return; } setUncontrolledValue(next); }, }); const checkedValue = c.value; const setCheckedValue = ( value: unknown, eventDetails: RadioGroupChangeEventDetails, ): void => { c.setValue(value as Value, eventDetails.event, "trigger-press"); }; const controlRef: { current: HTMLElement | null } = { current: null }; const validation = useFieldControlValidation({ value: () => checkedValue() ?? null, id: () => id, controlRef, getValue: () => checkedValue() ?? null, }); useValueChanged( () => checkedValue(), () => { const value = checkedValue(); field.setFilled(value != null); field.setDirty(value !== field.validityData().initialValue); if (field.shouldValidateOnChange()) { void validation.commit(value); } else { void validation.commit(value, true); } }, ); const composite = useCompositeRoot({ direction, orientation: "both", loopFocus: true, enableHomeAndEndKeys: false, }); const state = createMemo( (): RadioGroupState => ({ ...field.state(), disabled: disabled(), required: required(), readOnly: readOnly(), }), ); let touched = false; const getTouched = (): boolean => touched; const setTouched = (next: boolean): void => { touched = next; }; const contextValue: RadioGroupContextValue = { checkedValue, setCheckedValue, disabled, readOnly, required, name, form, touched: getTouched, setTouched, present: true, }; const userStyling = resolveClassStyle(props, () => state()); const userProps = omit( props, "class", "style", "attrs", "as", "children", "defaultValue", "disabled", "form", "id", "inputRef", "name", "onValueChange", "readOnly", "required", "value", ); // `wrapperHandlers` are placed FIRST in the chain (internal side) so they // run before the composite's `handleKeyDown`. The touched-latch must be set // before the composite's queueMicrotask(focus) fires so the next-focused // radio's `onFocus` handler picks up `touched === true` and auto-selects. const wrapperHandlers = { onFocus(): void { field.setFocused(true); }, onKeyDown(event: KeyboardEvent): void { if (event.key.startsWith("Arrow")) { field.setTouched(true); setTouched(true); field.setFocused(true); } }, onBlur(event: FocusEvent): void { const currentTarget = event.currentTarget as Element | null; const relatedTarget = event.relatedTarget as Node | null; if ( currentTarget == null || relatedTarget == null || !currentTarget.contains(relatedTarget) ) { field.setTouched(true); field.setFocused(false); if (field.validationMode() === "onBlur") { void validation.commit(checkedValue()); } } }, }; const internalSlot = { id, role: "radiogroup" as const, get "data-disabled"(): "" | undefined { return disabled() ? "" : undefined; }, get "data-readonly"(): "" | undefined { return readOnly() ? "" : undefined; }, get "data-required"(): "" | undefined { return required() ? "" : undefined; }, get "aria-required"(): "true" | undefined { return required() ? "true" : undefined; }, get "aria-disabled"(): "true" | undefined { return disabled() ? "true" : undefined; }, get "aria-readonly"(): "true" | undefined { return readOnly() ? "true" : undefined; }, }; // Order: wrapperHandlers (touched-latch first) ⊕ composite.props (focus // dispatch) ⊕ internalSlot (data/aria attrs) ⊕ aria-invalid ⊕ fieldStateAttrs // ⊕ userProps. const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( wrapperHandlers, composite.props, ); merged = mergeRootProps(merged, internalSlot); merged = mergeRootProps(merged, validation.getAriaInvalidProps() ?? {}); merged = mergeRootProps(merged, fieldStateAttrs(state)); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( { controlRef.current = el; composite.rootRef(el); }, userRef(props))} > elementsRef={composite.elementsRef} onMapChange={composite.onMapChange} > {props.children} ); }