import type { JSX } from "@solidjs/web"; import { type Accessor, createContext, createMemo, useContext } from "solid-js"; import { makeSlotRefDirective } from "../internals/make-slot-ref-directive"; import { type MaybeAccessor, readOption } from "../internals/maybe-accessor"; import { mergeRootProps } from "../internals/merge-root-props"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../internals/observability/snapshot"; /** * Why the group's `value` changed. Emitted on the third arg of * `onValueChange(next, event, reason)` and accepted by * `c.setValue(next, event?, reason?)`. * * - `"trigger-press"` — a child radio was clicked / activated. * - `"imperative"` — programmatic `setValue` with no originating DOM event. * * Wrappers translate this to their public taxonomy * (`BaseUIChangeEventDetails<...>`). */ export type RadioGroupChangeReason = "trigger-press" | "imperative"; /** * Orientation hint exposed via the group's slot bag. */ export type RadioGroupOrientation = "horizontal" | "vertical" | "both"; /** * Options for {@link createRadioGroup}. * * **Solid-native shape (2026-04-30).** No internal state. `value` is required * and the consumer always owns the signal. See `solidify.md` §6. */ export interface CreateRadioGroupOptions { /** * Selected value. Consumer-owned signal — the primitive reads through the * accessor and does NOT keep an internal copy. Writes happen via * `onValueChange` (the consumer's responsibility to update their signal). */ value: MaybeAccessor; /** Whether interaction is suppressed. @default false */ disabled?: MaybeAccessor; /** Whether the group rejects user-driven changes. @default false */ readOnly?: MaybeAccessor; /** Whether selection is required for form submission. @default false */ required?: MaybeAccessor; /** * Shared `name` for child radios' hidden inputs. */ name?: MaybeAccessor; /** Navigation orientation. @default "both" */ orientation?: MaybeAccessor; /** * Change callback. Called synchronously with `(next, event, reason)`. * `event.preventDefault()` is now a no-op for the primitive (the * consumer's signal write is the only writer). */ onValueChange?: ( value: Value, event: Event, reason: RadioGroupChangeReason, ) => void; } /** * Public API returned by {@link createRadioGroup}. */ export interface RadioGroupApi { value: Accessor; disabled: Accessor; readOnly: Accessor; required: Accessor; name: Accessor; orientation: Accessor; /** * Imperative value setter. Fires `onValueChange(next, event, reason)`. * No-op while `disabled` / `readOnly`. There is no internal state to * write — the consumer's signal write is the only writer. */ setValue: ( next: Value, event?: Event, reason?: RadioGroupChangeReason, ) => void; /** * Root slot — getter-bag of attributes ready for spreading onto a * `
`. */ root: ( userProps?: U, ) => RadioGroupRootSlotProps & (U extends object ? U : object); /** * Directive form of {@link root}. */ rootRef: () => (el: HTMLElement | null) => void; /** Context provider — descendants read via `useRadioGroup()`. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } /** Shape of attributes emitted by `c.root()`. */ export interface RadioGroupRootSlotProps { role: "radiogroup"; "aria-disabled": "true" | undefined; "aria-readonly": "true" | undefined; "aria-required": "true" | undefined; "aria-orientation": "horizontal" | "vertical" | undefined; "data-disabled": "" | undefined; "data-readonly": "" | undefined; "data-required": "" | undefined; "data-orientation": "horizontal" | "vertical" | "both"; } const RadioGroupApiContext = createContext | null>(null); /** * Read the nearest {@link RadioGroupApi} provided by `c.Provider`. Throws if * used outside a Provider. */ export function useRadioGroup(): RadioGroupApi { const ctx = useContext(RadioGroupApiContext); if (ctx == null) { throw new Error( "Base UI: useRadioGroup() must be called inside a .", ); } return ctx as RadioGroupApi; } /** * Solid 2.0-native compound primitive for a radio-group root. Owns the * single selected `value` (controlled or uncontrolled) plus the shared * `disabled` / `readOnly` / `required` / `name` / `orientation` slots that * descendant radios pull from. Composite-list arrow-key navigation, hidden * input form wiring, and field-control validation are the `` * wrapper's job — those are integration concerns layered on top. */ export function createRadioGroup( options: CreateRadioGroupOptions, ): RadioGroupApi { // `value` reads through the consumer's accessor — no internal signal, // no controlled-or-internal branching. Memoized so dependents see the // same identity within a tracking scope. const value = createMemo(() => readOption(options.value), { name: "radio-group.value", }); const disabled = createMemo( () => Boolean(readOption(options.disabled)), { name: "radio-group.disabled" }, ); const readOnly = createMemo( () => Boolean(readOption(options.readOnly)), { name: "radio-group.readOnly" }, ); const required = createMemo( () => Boolean(readOption(options.required)), { name: "radio-group.required" }, ); const name = createMemo(() => readOption(options.name), { name: "radio-group.name", }); const orientation = createMemo( () => readOption(options.orientation) ?? "both", { name: "radio-group.orientation" }, ); // `setValue` is a pure event-emitter. It fires `onValueChange(next, event, reason)`; // the consumer is responsible for updating their value-signal in response. // There is no internal state to write — `event.preventDefault()` is now a // no-op for the primitive (the consumer's handler is the only writer). const setValue = ( next: Value, event?: Event, reason: RadioGroupChangeReason = "imperative", ): void => { if (disabled() || readOnly()) return; const ev = event ?? new Event("valuechange", { cancelable: true }); options.onValueChange?.(next, ev, reason); }; const rootInternal = { role: "radiogroup" as const, get "aria-disabled"() { return disabled() ? ("true" as const) : undefined; }, get "aria-readonly"() { return readOnly() ? ("true" as const) : undefined; }, get "aria-required"() { return required() ? ("true" as const) : undefined; }, get "aria-orientation"() { const o = orientation(); // `aria-orientation` only takes "horizontal" / "vertical" — the // "both" hint is internal-only and is not surfaced via ARIA. return o === "horizontal" || o === "vertical" ? o : undefined; }, get "data-disabled"() { return disabled() ? "" : undefined; }, get "data-readonly"() { return readOnly() ? "" : undefined; }, get "data-required"() { return required() ? "" : undefined; }, get "data-orientation"() { return orientation(); }, }; const root = (userProps?: U) => mergeRootProps(rootInternal, userProps) as RadioGroupRootSlotProps & (U extends object ? U : object); const rootRef = (): ((el: HTMLElement | null) => void) => makeSlotRefDirective(() => root()); const api: RadioGroupApi = { value, disabled, readOnly, required, name, orientation, setValue, root, rootRef, Provider: (props) => ( }> {props.children} ), }; (api as RadioGroupApi & Snapshottable)[SNAPSHOT_KEYS] = RADIO_GROUP_SNAPSHOT_KEYS; return api; } const RADIO_GROUP_SNAPSHOT_KEYS = snapshotKeys( "value", "disabled", "readOnly", "required", "name", "orientation", );