import { For, Show, type JSX, createSignal, splitProps } from 'solid-js'; import { ChevronRight } from 'lucide-solid'; import { cn } from '../utils/cn'; import { renderIcon } from './icon'; import { Badge } from './badge'; /** Status tone for a nav item's dot. `primary` is the theme accent; the rest use * the kit's tool hues (the same colors as kai-status / kai-progress-bar). Generic * enough for thread/onboarding states (success = "Completed", info = "Working") * and task-run states (neutral = queued, info = running, success = done, * error = failed). */ export type NavStatusTone = 'primary' | 'info' | 'success' | 'warning' | 'error' | 'neutral'; /** Dot hue per `tone`, mirroring the kai-status / kai-progress-bar token map. */ const STATUS_DOT_BG: Record = { primary: 'bg-primary', info: 'bg-tool-blue', success: 'bg-tool-green', warning: 'bg-tool-amber', error: 'bg-tool-red', neutral: 'bg-muted-foreground', }; /** A small colored status indicator on a nav row. The `label` (when set) is folded * into the row's accessible name; `pulse` adds an animated ping ring. */ export interface NavItemStatus { tone: NavStatusTone; /** Short status word ("Working", "Failed"). Shown as muted text and announced. */ label?: string; /** Animated ping ring (disabled under prefers-reduced-motion). */ pulse?: boolean; } export interface KaiNavItem { id: string; label?: string; /** Leading icon: a named icon, an image URL / data-URI, or plain text. */ icon?: string; /** Trailing text pill (e.g. "Beta"). */ badge?: string; /** Trailing icon (a named icon), e.g. an edit/compose affordance. */ trailing?: string; disabled?: boolean; /** Nested items. When present, the row becomes a collapsible group: a parent * row with a disclosure chevron over an indented child list. Recurses for * arbitrary depth (primarily 2 levels, e.g. project -> threads). */ children?: KaiNavItem[]; /** A small colored status dot (+ optional label) on the row. */ status?: NavItemStatus; /** Right-aligned muted trailing text (e.g. a relative time, "24d ago"). * Distinct from `trailing` (a hover icon). */ meta?: string; /** An interactive trailing action button (`icon` is a named icon; `label` is * its accessible name). Activating it fires `onItemAction` and does NOT select * the row. Distinct from `trailing` (a decorative hover icon, not a button). */ action?: { icon: string; label: string }; /** Render an interactive trailing close (×) button. Activating it fires * `onItemClose` and does NOT select the row. */ closable?: boolean; } export interface NavProps extends JSX.HTMLAttributes { items?: KaiNavItem[]; /** Active item id (drives aria-current + the selected look). */ value?: string; onItemSelect?: (id: string) => void; /** A row's trailing `action` button was activated (not a select). */ onItemAction?: (id: string, action?: { icon: string; label: string }) => void; /** A `closable` row's trailing close button was activated (not a select). */ onItemClose?: (id: string) => void; /** Ids of group items collapsed on first render. Groups default to expanded. */ defaultCollapsed?: string[]; } export function Nav(props: NavProps) { const [local, rest] = splitProps(props, [ 'items', 'value', 'onItemSelect', 'onItemAction', 'onItemClose', 'defaultCollapsed', 'class', ]); // Groups default to expanded; `defaultCollapsed` seeds the closed set. Using a // Set keyed by item id keeps expand state independent of the items reference, // so late-arriving items (the element sets `items` post-construction) are open. const [collapsed, setCollapsed] = createSignal>( new Set(local.defaultCollapsed ?? []), { equals: false }, ); const isExpanded = (id: string) => !collapsed().has(id); const toggle = (id: string) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); return ( ); } interface NavRowsProps { items: KaiNavItem[]; depth: number; value?: string; onItemSelect?: (id: string) => void; onItemAction?: (id: string, action?: { icon: string; label: string }) => void; onItemClose?: (id: string) => void; isExpanded: (id: string) => boolean; toggle: (id: string) => void; } /** Render one nesting level. Recurses through `children`. */ function NavRows(props: NavRowsProps) { // When a level has any groups, every row reserves a chevron-width disclosure // column (chevron for groups, a spacer for leaves) so icons/labels line up. const hasGroups = () => props.items.some((i) => (i.children?.length ?? 0) > 0); return ( {(item) => { const isGroup = () => (item.children?.length ?? 0) > 0; const expanded = () => props.isExpanded(item.id); const active = () => props.value === item.id; const tone = () => item.status?.tone ?? 'neutral'; // Fold the status word + meta into the accessible name so AT gets them // (the dot/label/meta nodes are aria-hidden to avoid double-announcing). const ariaLabel = () => { if (!item.status?.label && !item.meta) return undefined; return [item.label, item.status?.label, item.meta].filter(Boolean).join(', '); }; const onClick = () => { if (item.disabled) return; if (isGroup()) props.toggle(item.id); else props.onItemSelect?.(item.id); }; // Native buttons handle Enter/Space; add ArrowRight/Left for tree parity. const onKeyDown = (e: KeyboardEvent) => { if (!isGroup() || item.disabled) return; if (e.key === 'ArrowRight' && !expanded()) { e.preventDefault(); props.toggle(item.id); } else if (e.key === 'ArrowLeft' && expanded()) { e.preventDefault(); props.toggle(item.id); } }; return ( <> {/* The row is a non-interactive wrapper: the selectable item button and the trailing action/close button are SIBLINGS, never nested (a button inside a button is invalid HTML — browsers hoist it out — and trips axe's nested-interactive). Each control owns its own click, so a trailing-button click never reaches the item button's select handler; no event-delegation discrimination is needed. */}
); }}
); }