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 { CompositeList } from "../../internals/composite/composite-list.tsx"; import { createControlledState } from "../../internals/create-controlled-state"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps, Orientation } from "../../internals/types"; import { type AccordionRootChangeEventDetails, type AccordionRootChangeEventReason, AccordionRootContext, type AccordionRootContextValue, type AccordionRootState, type AccordionValue, } from "../accordion-root-context.ts"; import { createAccordion } from "../create-accordion.tsx"; export type { AccordionRootChangeEventDetails, AccordionRootChangeEventReason, AccordionRootState, AccordionValue, }; export interface AccordionRootProps extends Omit< BaseUIComponentProps<"div", AccordionRootState>, "value" > { /** * Controlled open values. When provided (any defined value — an * explicitly passed empty array counts), the consumer owns the state — * pair with {@link onValueChange}. When omitted, the accordion manages * its own open set (seeded from {@link defaultValue}). An empty array * means "no item open". */ value?: AccordionValue | undefined; /** * Initial open values when uncontrolled. Read once at mount. * @default [] */ defaultValue?: AccordionValue | undefined; /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Whether the component should ignore user interaction. * @default false */ disabled?: boolean | undefined; /** * Allows the browser's built-in page search to find and expand closed * panels via `hidden="until-found"`. Forces panels to stay mounted. * @default false */ hiddenUntilFound?: boolean | undefined; /** * Whether panels stay mounted when closed. Ignored when * {@link hiddenUntilFound} is `true`. * @default false */ keepMounted?: boolean | undefined; /** * Whether arrow-key navigation between triggers wraps around at the * extents. * @default true */ loopFocus?: boolean | undefined; /** * Fired when an item expands or collapses. The first argument is the new * array of open values; the second is a cancel-capable details bag — * calling `eventDetails.cancel()` keeps the accordion's own * (uncontrolled) state unchanged, mirroring React Base UI; controlled * consumers simply don't update their signal. */ onValueChange?: | (( value: AccordionValue, eventDetails: AccordionRootChangeEventDetails, ) => void) | undefined; /** * Whether multiple items can be open at the same time. * @default false */ multiple?: boolean | undefined; /** * Visual orientation — controls whether arrow-up/down or arrow-left/right * move roving focus between triggers. * @default "vertical" */ orientation?: Orientation | undefined; } /** * Groups all parts of the accordion. Renders a `
`. * * Solid 2 notes: * * - Accessor-form reads for every reactive input — no top-level reactive * reads, no destructuring of `props`. * - `value` is always stored internally as an array: single-expand mode is * encoded as `[]` or `[currentlyOpenValue]`, multi-expand as the full * open set. This lets the item's `isOpen` predicate be the same regardless * of mode. * - Context provider sits INSIDE `get children()` (not wrapped around the * root element) so the attribute getters and the `state` memo stay owned * by this component's setup scope — matches `radio-group.tsx`. * - Items register with a ``. The trigger's keyboard handler * walks that list to find sibling triggers (see `accordion-trigger.tsx`). */ export function AccordionRoot( props: AccordionRootProps, ): JSX.Element { const direction = useDirection(); const hiddenUntilFound = (): boolean => props.hiddenUntilFound ?? false; const keepMounted = (): boolean => props.keepMounted ?? false; const loopFocus = (): boolean => props.loopFocus ?? true; // Controlled / uncontrolled value state (Base UI `value` / `defaultValue` // contract): the `value` prop wins while it is defined — an explicitly // passed empty array IS controlled; otherwise an internal signal (seeded // once from `defaultValue`, else `[]`) backs the state. const [resolvedValue, setUncontrolledValue] = createControlledState< AccordionValue >({ controlled: () => props.value, default: () => props.defaultValue, fallback: [] as AccordionValue, name: "", state: "value", }); // Compose the headless primitive — it owns value-array normalization and // the single/multi toggle logic. The wrapper layers CompositeList + // region role + direction-provider on top. Change flow mirrors React's // `AccordionRoot.handleValueChange`: fire `onValueChange` first, honor // `details.cancel()`, then write the uncontrolled state (ignored for // reads while the `value` prop is controlled). const c = createAccordion({ value: resolvedValue, multiple: () => props.multiple === true, disabled: () => props.disabled === true, orientation: () => props.orientation ?? "vertical", onValueChange: (next, details) => { props.onValueChange?.(next, details as AccordionRootChangeEventDetails); if (details.isCanceled) return; setUncontrolledValue(next); }, }); const value = c.value; const disabled = c.disabled; const orientation = c.orientation; const handleValueChange = ( newValue: unknown, nextOpen: boolean, // Incoming details from the item is reference-only for the legacy // signature; the primitive constructs its own fresh details bag for // the onValueChange dispatch (matches React's "two separate signals" // shape — cancelling onOpenChange doesn't veto onValueChange). _incoming: AccordionRootChangeEventDetails, ): void => { c.toggle(newValue as Value, nextOpen); }; const state: () => AccordionRootState = () => ({ value: value(), disabled: disabled(), orientation: orientation(), }); // Shared array that `` keeps in DOM order. The trigger's // keyboard handler reads it synchronously during event dispatch, so it // must be a plain mutable array (no signal indirection). const accordionItemRefs: Array = []; const contextValue: AccordionRootContextValue = { accordionItemRefs, direction, disabled, handleValueChange, hiddenUntilFound, keepMounted, loopFocus, orientation, state, value, }; const userStyling = resolveClassStyle(props, () => state()); const userHtmlProps = omit( props, "class", "style", "attrs", "as", "children", "defaultValue", "disabled", "hiddenUntilFound", "keepMounted", "loopFocus", "multiple", "onValueChange", "orientation", "value", ) as Record; const internalProps = { role: "region" as const, get dir() { return direction(); }, get "data-orientation"() { return orientation(); }, get "data-disabled"(): "" | undefined { return disabled() ? "" : undefined; }, }; const styledUserProps = mergeRootProps(userStyling, userHtmlProps); const merged = mergeRootProps(internalProps, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( } > elementsRef={accordionItemRefs}> {props.children} ); }