import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { omit } from "solid-js"; import { useDirection } from "../direction-provider/direction-provider-context.ts"; import type { BaseUIEventReasons } from "../internals/change-event-details"; import { composeRefs, userRef } from "../internals/compose-refs"; import { CompositeRootContext, type CompositeRootContextValue, } from "../internals/composite/composite-context.ts"; import { CompositeList } from "../internals/composite/composite-list.tsx"; import { useCompositeRoot } from "../internals/composite/composite-root.ts"; import { createControlledState } from "../internals/create-controlled-state"; import { mergeRootProps } from "../internals/merge-root-props"; import { resolveClassStyle } from "../internals/resolve-class-style"; import { createStableCallback } from "../internals/stable-callback"; import type { BaseUIComponentProps, Orientation } from "../internals/types"; import { type ToggleGroupChangeEventDetails, ToggleGroupContext, type ToggleGroupContextValue, } from "./toggle-group-context.ts"; export type ToggleGroupChangeEventReason = BaseUIEventReasons["none"]; export type { ToggleGroupChangeEventDetails }; /** * State projected to `data-*` attributes. `orientation` projects via the * default mapping as `data-orientation="horizontal"` / `"vertical"`. * `disabled` + `multiple` are emitted as presence-only attrs. */ export interface ToggleGroupState { /** Whether the group ignores user interaction. */ disabled: boolean; /** `true` when any subset of children can be pressed simultaneously. */ multiple: boolean; /** Keyboard-navigation orientation. */ orientation: Orientation; } export interface ToggleGroupProps extends BaseUIComponentProps<"div", ToggleGroupState> { /** * Tag/component to render. Defaults to `"div"`. Replaces the * pre-spike `render` prop (axis G). */ as?: ValidComponent; /** * Controlled pressed-state array. When provided (not `undefined`), the * consumer owns the state — pair with {@link onValueChange}. When * omitted, the group manages its own value (seeded from * {@link defaultValue}). An empty array IS a controlled value. */ value?: readonly Value[] | undefined; /** * Initial pressed-state array when uncontrolled. Read once at mount. * @default [] */ defaultValue?: readonly Value[] | undefined; /** * Fires when the set of pressed values changes. Consumer may call * `details.cancel()` to veto the write. */ onValueChange?: | ((groupValue: Value[], details: ToggleGroupChangeEventDetails) => void) | undefined; /** * Whether the group (and every descendant toggle) ignores user interaction. * @default false */ disabled?: boolean | undefined; /** * Navigation orientation — drives arrow-key dispatch and the * `data-orientation` attribute. * @default "horizontal" */ orientation?: Orientation | undefined; /** * Whether arrow-key navigation loops focus at the ends. * @default true */ loopFocus?: boolean | undefined; /** * When `true`, multiple toggles can be pressed simultaneously. * @default false */ multiple?: boolean | undefined; } const EMPTY: readonly string[] = Object.freeze([]); /** * Groups a series of `` elements, coordinating their pressed state * through a single `value` array. Renders a `
` (or `as` * override). * * §1 wave 2: replaces `renderElement` with `dynamic(() => props.as ?? "div")`. * The state-attr projection (`data-disabled`, `data-multiple`, * `data-orientation`) is emitted via inline reactive getters; the composite * root's `props` bag (aria-orientation, onKeyDown, onFocus) is composed via * `mergeRootProps`. */ export function ToggleGroup( props: ToggleGroupProps, ): JSX.Element { const disabled = (): boolean => props.disabled === true; const multiple = (): boolean => props.multiple === true; const orientation = (): Orientation => props.orientation ?? "horizontal"; const loopFocus = (): boolean => props.loopFocus ?? true; const direction = useDirection(); // Controlled / uncontrolled group value (Base UI `value` / `defaultValue` // contract, mirroring React's `useControlled` in `ToggleGroup`): the // `value` prop wins while defined (an empty array IS controlled); // otherwise an internal signal (seeded once from `defaultValue`, else // `[]`) backs the state. const [value, setUncontrolledValue] = createControlledState( { controlled: () => props.value, default: () => props.defaultValue, fallback: EMPTY as readonly Value[], name: "", state: "value", }, ); const isValueInitialized = (): boolean => props.value !== undefined || props.defaultValue !== undefined; const setGroupValue = createStableCallback< ( newValue: Value, nextPressed: boolean, details: ToggleGroupChangeEventDetails, ) => void >(() => (newValue, nextPressed, details) => { const current = value(); let nextGroupValue: Value[]; if (multiple()) { nextGroupValue = current.slice(); if (nextPressed) { nextGroupValue.push(newValue); } else { const idx = nextGroupValue.indexOf(newValue); if (idx > -1) nextGroupValue.splice(idx, 1); } } else { nextGroupValue = nextPressed ? [newValue] : []; } // 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?.(nextGroupValue, details); if (details.isCanceled) { return; } setUncontrolledValue(nextGroupValue); }); const composite = useCompositeRoot({ get orientation() { return orientation(); }, get loopFocus() { return loopFocus(); }, direction, enableHomeAndEndKeys: true, }); const rootContext: CompositeRootContextValue = { highlightedIndex: composite.highlightedIndex, onHighlightedIndexChange: composite.onHighlightedIndexChange, highlightItemOnHover: () => false, relayKeyboardEvent: composite.relayKeyboardEvent, present: true, }; const contextValue: ToggleGroupContextValue = { value, setGroupValue, disabled, get orientation() { return orientation(); }, multiple, isValueInitialized, present: true, }; // Strip wrapper-owned API keys + `as` + `children` (re-injected wrapped // in providers below). const userStyling = resolveClassStyle(props, () => ({ disabled: disabled(), multiple: multiple(), orientation: orientation(), })); const userProps = omit( props, "class", "style", "attrs", "as", "children", "defaultValue", "disabled", "loopFocus", "multiple", "onValueChange", "orientation", "value", ); // Internal slot bag: role + state-attr getters + composite props // (aria-orientation, onKeyDown, onFocus). Inline reactive getters keep // the data-attrs live across upstream signal flips. const internalSlot = { role: "group" as const, get "data-disabled"(): "" | undefined { return disabled() ? "" : undefined; }, get "data-multiple"(): "" | undefined { return multiple() ? "" : undefined; }, get "data-orientation"(): string { return orientation(); }, }; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, composite.props, ); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); }