import { createContext, useContext, createSignal, createUniqueId, splitProps, type JSX, type Accessor, } from 'solid-js'; import { cn } from '../utils/cn'; // Extend SolidJS JSX to allow `bool:inert` (forces setAttribute so jsdom reflects it as an attribute). declare module 'solid-js' { namespace JSX { interface ExplicitBoolAttributes { inert: boolean; } } } interface CollapsibleCtx { open: Accessor; toggle: () => void; contentId: string; disabled: Accessor; } const Ctx = createContext(); const useCollapsible = () => { const c = useContext(Ctx); if (!c) throw new Error('Collapsible parts must be used within '); return c; }; /** Imperative open controller, handed to a parent (e.g. the kai-tool facade) * via `controllerRef` so it can drive/observe open state — mirrors * HoverCardController. Only available when the Collapsible is UNCONTROLLED * (no `open` prop); in controlled mode the parent already owns the state. */ export interface CollapsibleController { open: Accessor; setOpen: (v: boolean) => void; } export function Collapsible(props: { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** When true, the trigger no longer toggles (programmatic show/hide still work * via the controller; this only gates the user-facing trigger). */ disabled?: boolean; /** Receive the open controller (open accessor + setOpen) once mounted. * Only meaningful in uncontrolled mode (no `open` prop). */ controllerRef?: (api: CollapsibleController) => void; children: JSX.Element; class?: string; }) { const [local, rest] = splitProps(props, ['open', 'defaultOpen', 'onOpenChange', 'disabled', 'controllerRef', 'children', 'class']); const [uncontrolled, setUncontrolled] = createSignal(local.defaultOpen ?? false); const isControlled = () => local.open !== undefined; const open = () => (isControlled() ? !!local.open : uncontrolled()); const disabled = () => !!local.disabled; // setOpen drives the open state and notifies onOpenChange — the single mutate // path shared by the trigger and the imperative controller. const setOpen = (next: boolean) => { if (open() === next) return; if (!isControlled()) setUncontrolled(next); local.onOpenChange?.(next); }; const toggle = () => { if (disabled()) return; setOpen(!open()); }; // Hand the controller up once. setOpen is NOT gated by disabled — disabled only // suppresses the user trigger; programmatic show/hide remain available (the // facade's wireDisclosure applies its own disabled-gating on show/toggle). local.controllerRef?.({ open, setOpen }); const contentId = createUniqueId(); return (
{local.children}
); } export function CollapsibleTrigger(props: { children?: JSX.Element; class?: string; as?: (props: Record) => JSX.Element; onClick?: JSX.EventHandlerUnion; [k: string]: any; }) { const ctx = useCollapsible(); const [local, rest] = splitProps(props, ['children', 'class', 'as', 'onClick']); const triggerProps = () => ({ type: 'button' as const, 'aria-expanded': ctx.open(), 'aria-controls': ctx.contentId, disabled: ctx.disabled() || undefined, 'data-expanded': ctx.open() ? '' : undefined, 'data-closed': ctx.open() ? undefined : '', 'data-state': ctx.open() ? 'open' : 'closed', onClick: (e: MouseEvent) => { if (ctx.disabled()) return; if (typeof local.onClick === 'function') { (local.onClick as (e: MouseEvent) => void)(e); } ctx.toggle(); }, class: local.class, ...rest, }); return ( <> {local.as ? local.as(triggerProps() as any) : ( )} ); } export function CollapsibleContent(props: { children?: JSX.Element; class?: string; [k: string]: any }) { const ctx = useCollapsible(); const [local, rest] = splitProps(props, ['children', 'class']); return (
{local.children}
); }