import { createContext, useContext, createSignal, createUniqueId, Show, onCleanup, splitProps, type JSX, type Accessor, } from 'solid-js'; import { Portal } from 'solid-js/web'; import { ChevronRight, Check } from 'lucide-solid'; import { cn } from '../utils/cn'; import { useChatConfig } from '../primitives/chat-config'; import { createPresence, usePosition, useDismiss, As, type AsTag } from './overlay'; interface DropdownCtx { open: Accessor; setOpen: (v: boolean, opts?: { viaKeyboard?: boolean; returnFocus?: boolean }) => void; triggerId: string; menuId: string; setTrigger: (el: HTMLElement) => void; setMenu: (el: HTMLElement) => void; trigger: Accessor; menu: Accessor; openedViaKeyboard: Accessor; /** Register a portaled submenu surface so outside-click dismissal treats it as * "inside" the menu tree (sub content lives in a sibling portal, not the menu DOM). */ registerSubMenu: (el: HTMLElement) => () => void; /** Currently-mounted submenu surfaces, for the dismiss "inside" test. */ subMenus: Accessor; } const Ctx = createContext(); const useDropdown = () => { const c = useContext(Ctx); if (!c) throw new Error('Dropdown parts must be used within '); return c; }; // The roving-focus set: real menuitems AND checkbox/radio items, minus disabled. // A DropdownSubTrigger is a menuitem too, so it participates. Labels/separators // are intentionally excluded. Submenu content is portaled to a SIBLING node, so // a parent's querySelectorAll scoped to its own menu never reaches sub items. const ITEM_SELECTOR = '[role="menuitem"]:not([aria-disabled="true"]), [role="menuitemcheckbox"]:not([aria-disabled="true"]), [role="menuitemradio"]:not([aria-disabled="true"])'; /** Imperative open controller, handed to a parent (e.g. the kai-menu facade) via * `controllerRef` so it can drive/observe the Dropdown's open state. */ export interface DropdownController { open: Accessor; setOpen: (v: boolean) => void; } export function Dropdown(props: { children: JSX.Element; /** Initial open state (uncontrolled seed). */ defaultOpen?: boolean; /** When true, the trigger never opens the menu. */ disabled?: boolean; /** Receive the open controller (open accessor + setOpen) once mounted. */ controllerRef?: (api: DropdownController) => void; }) { const [open, setOpenSig] = createSignal(props.defaultOpen ?? false); const [viaKb, setViaKb] = createSignal(false); const [trigger, setTrigger] = createSignal(); const [menu, setMenu] = createSignal(); const [subMenus, setSubMenus] = createSignal([]); const registerSubMenu = (el: HTMLElement) => { setSubMenus((prev) => [...prev, el]); return () => setSubMenus((prev) => prev.filter((m) => m !== el)); }; const setOpen = (v: boolean, opts?: { viaKeyboard?: boolean; returnFocus?: boolean }) => { // Gate opening while disabled; closing always works. if (v && props.disabled) return; setViaKb(!!opts?.viaKeyboard); setOpenSig(v); if (v) { // Focus the first item on keyboard-open. The menu mounts via ; we // attempt focus now and re-assert in the menu ref's microtask so it lands // once the node exists. Skip disabled items (roving-focus contract). if (opts?.viaKeyboard) { queueMicrotask(() => menu()?.querySelector(ITEM_SELECTOR)?.focus()); menu()?.querySelector(ITEM_SELECTOR)?.focus(); } } else if (opts?.returnFocus !== false) { // Closing via keyboard/select: return focus to the trigger. The menu // unmounts on a microtask (createPresence) and that teardown blurs // whatever is focused, so re-assert focus AFTER unmount too. const el = trigger(); el?.focus(); queueMicrotask(() => el?.focus()); } }; // Hand the open controller up to a facade (e.g. kai-menu) so it can drive + // observe open state via wireDisclosure. Mirrors HoverCardRoot.controllerRef. props.controllerRef?.({ open, setOpen: (v: boolean) => setOpen(v) }); return ( {props.children} ); } export function DropdownTrigger(props: { as?: AsTag; children?: JSX.Element; class?: string; [k: string]: any }) { const ctx = useDropdown(); // Forward extra attributes (e.g. aria-label for an icon-only trigger). The // controlled wiring below (id/aria-*/onClick/onKeyDown/class/type) is applied // AFTER the spread so it always wins over a caller-supplied duplicate. const [, rest] = splitProps(props, ['as', 'children', 'class']); const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); ctx.setOpen(true, { viaKeyboard: true }); } }; return ( ctx.setOpen(!ctx.open())} onKeyDown={onKeyDown} class={props.class} {...(props.as ? {} : { type: 'button' })} > {props.children} ); } export function DropdownContent(props: { children: JSX.Element; class?: string }) { const ctx = useDropdown(); const config = useChatConfig(); const presence = createPresence(ctx.open); const position = usePosition(ctx.trigger, ctx.menu, { placement: 'bottom-start', gutter: 6, // Trigger removed from the DOM -> close (no focus return; it's gone) so the // menu portal doesn't orphan. onDisconnect: () => ctx.setOpen(false, { returnFocus: false }), }); useDismiss({ enabled: ctx.open, onDismiss: (reason) => ctx.setOpen(false, { returnFocus: reason === 'escape' }), // Open submenus portal to a sibling node, so include them as "inside" — a // click on a sub item must not be treated as an outside dismiss. refs: () => [ctx.trigger(), ctx.menu(), ...ctx.subMenus()], }); const items = () => Array.from(ctx.menu()?.querySelectorAll(ITEM_SELECTOR) ?? []); const focusIndex = (i: number) => { const list = items(); if (!list.length) return; const idx = ((i % list.length) + list.length) % list.length; list[idx].focus(); }; // Resolve the focused item via the menu's own root node. Inside a Shadow DOM // (every kitn-* element), `document.activeElement` returns the host element, // not the focused menu item, which would break ArrowUp/Down roving focus. // `getRootNode().activeElement` correctly returns the active node within the // same tree (ShadowRoot or Document). const activeItem = () => { const root = ctx.menu()?.getRootNode() as Document | ShadowRoot | undefined; return (root?.activeElement ?? document.activeElement) as Element | null; }; const currentIndex = () => items().findIndex((el) => el === activeItem()); const onKeyDown = (e: KeyboardEvent) => { const list = items(); switch (e.key) { case 'ArrowDown': e.preventDefault(); focusIndex(currentIndex() + 1); break; case 'ArrowUp': e.preventDefault(); focusIndex(currentIndex() - 1); break; case 'Home': e.preventDefault(); focusIndex(0); break; case 'End': e.preventDefault(); focusIndex(list.length - 1); break; case 'Tab': ctx.setOpen(false, { returnFocus: false }); break; default: if (e.key.length === 1 && /\S/.test(e.key)) { const start = currentIndex() + 1; const lower = e.key.toLowerCase(); const match = list.findIndex((el, i) => i >= start && (el.textContent ?? '').trim().toLowerCase().startsWith(lower)); const found = match >= 0 ? match : list.findIndex((el) => (el.textContent ?? '').trim().toLowerCase().startsWith(lower)); if (found >= 0) { e.preventDefault(); focusIndex(found); } } } }; return (
{ ctx.setMenu(el); presence.setRef(el); // Keyboard-open focuses the first item. setOpen() also attempts this // synchronously; this ref-time microtask re-asserts focus once the // menu node exists. Skip disabled items. if (ctx.openedViaKeyboard()) { queueMicrotask(() => el.querySelector(ITEM_SELECTOR)?.focus()); } }} id={ctx.menuId} role="menu" aria-labelledby={ctx.triggerId} tabindex={-1} data-expanded={presence.state() === 'open' ? '' : undefined} data-closed={presence.state() === 'closed' ? '' : undefined} onKeyDown={onKeyDown} style={{ position: 'fixed', left: `${position.pos().x}px`, top: `${position.pos().y}px`, // hide (without unmounting) when the trigger scrolls out of view visibility: position.hidden() ? 'hidden' : 'visible', 'pointer-events': position.hidden() ? 'none' : undefined, }} class={cn( 'z-50 min-w-[8rem] rounded-lg bg-card p-1 kai-elevation', 'animate-in fade-in-0 zoom-in-95 data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95', props.class, )} > {props.children}
); } export function DropdownItem(props: { children: JSX.Element; class?: string; onSelect?: () => void; disabled?: boolean }) { const ctx = useDropdown(); const activate = () => { if (props.disabled) return; props.onSelect?.(); ctx.setOpen(false); }; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); } }} onPointerMove={(e) => { if (!props.disabled) (e.currentTarget as HTMLElement).focus(); }} class={cn( 'flex cursor-pointer items-center rounded-md px-2 py-1.5 text-sm outline-none transition-colors', 'hover:bg-muted focus:bg-muted', props.disabled && 'opacity-50 pointer-events-none', props.class, )} > {props.children}
); } /** * A thin, non-interactive divider between groups of items. * a11y: `role="separator"` — exposed to AT as a group boundary; not in the * roving-focus tab order (the `[role="menuitem"]` query skips it). */ export function DropdownSeparator(props: { class?: string }) { return