/* ───────────────────────────────────────────────────────────────────────── PixelDropdown — button + dropdown menu of actions, keyboard navigable. Two APIs supported additively: 1. items[] sugar (legacy): 2. Compositional: Option kinds (in items[] form): - 'item' (default) — selectable row. - 'separator' — horizontal divider, non-interactive. - 'header' — group label, non-interactive. - 'submenu' — placeholder kind (renders chevron, not yet nested-navigable). - 'checkbox' — toggleable row, shows ✓ when selected. - 'radio' — exclusive selection row, shows ● when selected. Extra fields: - shortcut — kbd string rendered right-aligned ("⌘K", "Ctrl+S"). - tone — 'red' (and others) tones styling; 'red' = destructive. - disabled — non-interactive, skipped by keyboard. Typeahead: while open, typing a printable character jumps highlight to the first item whose label starts with the typed prefix (resets after 600ms). ───────────────────────────────────────────────────────────────────────── */ import React, { forwardRef, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'; import { autoUpdate, offset as floatingOffset, shift, useFloating, } from '@floating-ui/react-dom'; import { Tone, Surface, Option, cn, useClickOutside, surfaceClasses, useEffectiveSurface, ChevronDownIcon, } from '../common'; import { PixelButton } from '../actions'; import { useEscape } from '../hooks/useEscape'; import { useControllableState } from '../hooks/useControllableState'; export type DropdownItemKind = 'item' | 'separator' | 'header' | 'submenu' | 'checkbox' | 'radio'; /** * Extended option shape accepted by {@link PixelDropdown} via `items[]`. * Backward-compat: plain `{value,label,icon}` still works (defaults to `kind:'item'`). */ export type DropdownOption = Option & { disabled?: boolean; tone?: Tone; kind?: DropdownItemKind; shortcut?: string; /** For 'checkbox'/'radio' kinds: current checked state (display-only). */ checked?: boolean; }; /** Public prop bag for {@link PixelDropdown}. */ export interface PixelDropdownProps { /** Trigger button label (legacy items[] API). */ label?: string; /** Items to render (legacy items[] API). */ items?: DropdownOption[]; /** Called with the selected item's `value` (legacy items[] API). */ onSelect?: (value: string) => void; /** Trigger tone. */ tone?: Tone; /** Optional icon at the right side of the trigger button. Defaults to a chevron. */ icon?: React.ReactNode; /** Disables the trigger button. */ disabled?: boolean; /** Visual surface override. Falls back to nearest `` surface. */ surface?: Surface; /** ARIA label override when the trigger label is purely decorative. */ ariaLabel?: string; /** When using compositional API, children replace items[] rendering. */ children?: React.ReactNode; } /* ─── Compositional context ─────────────────────────────────────────────── */ interface DropdownContextValue { open: boolean; setOpen: (v: boolean) => void; surface: Surface; menuId: string; containerRef: React.RefObject; highlightedValue: string | null; setHighlightedValue: (v: string | null) => void; registerItem: (value: string, disabled: boolean) => void; unregisterItem: (value: string) => void; getOrderedValues: () => string[]; typeaheadJump: (char: string) => void; labelMap: Map; registerItemHandler: (value: string, onSelect: (() => void) | undefined) => void; selectHighlighted: () => void; } const DropdownContext = React.createContext(null); function useDropdownContext(component: string): DropdownContextValue { const ctx = useContext(DropdownContext); if (!ctx) throw new Error(`${component} must be used inside `); return ctx; } const TYPEAHEAD_RESET_MS = 600; function isPrintableChar(key: string): boolean { return key.length === 1 && !!key.match(/\S/); } function toneTextClass(tone: Tone | undefined): string { if (!tone) return ''; if (tone === 'red') return 'text-retro-red'; if (tone === 'green') return 'text-retro-green'; if (tone === 'cyan') return 'text-retro-cyan'; if (tone === 'gold') return 'text-retro-gold'; if (tone === 'purple') return 'text-retro-purple'; if (tone === 'pink') return 'text-retro-pink'; return ''; } /* ─── Root / compositional sub-components ───────────────────────────────── */ interface DropdownRootProps { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; surface?: Surface; children: React.ReactNode; } function DropdownRoot({ open: openProp, defaultOpen = false, onOpenChange, surface: surfaceProp, children }: DropdownRootProps) { const surface = useEffectiveSurface(surfaceProp); const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange, }); const menuId = useId(); const containerRef = useRef(null); const itemsRef = useRef<{ value: string; disabled: boolean }[]>([]); const labelMapRef = useRef>(new Map()); const onSelectMapRef = useRef void) | undefined>>(new Map()); const highlightedValueRef = useRef(null); const [highlightedValue, _setHighlightedValue] = useState(null); const setHighlightedValue = useCallback((v: string | null) => { highlightedValueRef.current = v; _setHighlightedValue(v); }, []); const typeaheadBuf = useRef(''); const typeaheadTimer = useRef | null>(null); const registerItem = useCallback((value: string, disabled: boolean) => { const existing = itemsRef.current.findIndex((i) => i.value === value); if (existing >= 0) itemsRef.current[existing] = { value, disabled }; else itemsRef.current.push({ value, disabled }); }, []); const unregisterItem = useCallback((value: string) => { itemsRef.current = itemsRef.current.filter((i) => i.value !== value); labelMapRef.current.delete(value); onSelectMapRef.current.delete(value); }, []); const registerItemHandler = useCallback((value: string, onSelect: (() => void) | undefined) => { onSelectMapRef.current.set(value, onSelect); }, []); const getOrderedValues = useCallback(() => itemsRef.current.filter((i) => !i.disabled).map((i) => i.value), []); const selectHighlighted = useCallback(() => { const v = highlightedValueRef.current; if (!v) return; const handler = onSelectMapRef.current.get(v); handler?.(); setOpen(false); setHighlightedValue(null); }, [setHighlightedValue, setOpen]); const typeaheadJump = useCallback((char: string) => { if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current); typeaheadBuf.current = (typeaheadBuf.current + char).toLowerCase(); const buf = typeaheadBuf.current; const ordered = getOrderedValues(); // Prefer items whose label starts with buf; fall back to substring match. let match = ordered.find((v) => (labelMapRef.current.get(v) || '').toLowerCase().startsWith(buf)); if (!match) match = ordered.find((v) => (labelMapRef.current.get(v) || '').toLowerCase().includes(buf)); if (match) setHighlightedValue(match); typeaheadTimer.current = setTimeout(() => { typeaheadBuf.current = ''; }, TYPEAHEAD_RESET_MS); }, [getOrderedValues, setHighlightedValue]); useClickOutside(containerRef, () => { setOpen(false); setHighlightedValue(null); }); useEscape(() => { setOpen(false); setHighlightedValue(null); }, open); useEffect(() => { if (!open) setHighlightedValue(null); }, [open, setHighlightedValue]); useEffect(() => () => { if (typeaheadTimer.current) clearTimeout(typeaheadTimer.current); }, []); const labelMap = labelMapRef.current; const ctx: DropdownContextValue = useMemo(() => ({ open, setOpen, surface, menuId, containerRef, highlightedValue, setHighlightedValue, registerItem, unregisterItem, getOrderedValues, typeaheadJump, labelMap, registerItemHandler, selectHighlighted, }), [open, setOpen, surface, menuId, highlightedValue, setHighlightedValue, registerItem, unregisterItem, getOrderedValues, typeaheadJump, labelMap, registerItemHandler, selectHighlighted]); const onKey = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const ordered = getOrderedValues(); if (ordered.length === 0) return; if (!open) { setOpen(true); setHighlightedValue(ordered[0]); return; } const idx = highlightedValue ? ordered.indexOf(highlightedValue) : -1; const next = e.key === 'ArrowDown' ? ordered[Math.min(idx + 1, ordered.length - 1)] : ordered[Math.max(idx - 1, 0)]; setHighlightedValue(next ?? ordered[0]); return; } if (e.key === 'Home') { e.preventDefault(); const ordered = getOrderedValues(); if (ordered.length) { setOpen(true); setHighlightedValue(ordered[0]); } return; } if (e.key === 'End') { e.preventDefault(); const ordered = getOrderedValues(); if (ordered.length) { setOpen(true); setHighlightedValue(ordered[ordered.length - 1]); } return; } if ((e.key === 'Enter' || e.key === ' ') && open && highlightedValueRef.current) { e.preventDefault(); selectHighlighted(); return; } if (open && isPrintableChar(e.key)) { typeaheadJump(e.key); } }; return (
{children}
); } interface DropdownTriggerProps { children?: React.ReactNode; tone?: Tone; icon?: React.ReactNode; disabled?: boolean; ariaLabel?: string; } const DropdownTrigger = forwardRef(function DropdownTrigger( { children, tone = 'neutral', icon, disabled = false, ariaLabel }, ref, ) { const { open, setOpen, surface, menuId } = useDropdownContext('PixelDropdown.Trigger'); return ( } onClick={() => setOpen(!open)} aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={ariaLabel} > {children} ); }); DropdownTrigger.displayName = 'PixelDropdown.Trigger'; interface DropdownContentProps { children?: React.ReactNode; className?: string; } const DropdownContent = forwardRef(function DropdownContent( { children, className }, ref, ) { const { open, surface, menuId, containerRef } = useDropdownContext('PixelDropdown.Content'); const s = surfaceClasses(surface); const { refs, floatingStyles } = useFloating({ open, placement: 'bottom-start', whileElementsMounted: autoUpdate, elements: { reference: containerRef.current }, middleware: [floatingOffset(6), shift({ padding: 8 })], }); const setRefs = (node: HTMLDivElement | null) => { refs.setFloating(node); if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }; if (!open) return null; return ( ); }); DropdownContent.displayName = 'PixelDropdown.Content'; interface DropdownItemProps extends Omit, 'value' | 'onSelect'> { /** Item identity for highlight/typeahead registration (NOT the HTML form `value`). */ value?: string; children: React.ReactNode; /** Selection callback (NOT the DOM `select` event). */ onSelect?: () => void; disabled?: boolean; /** Visually marks the row red; equivalent to `tone="red"`. */ destructive?: boolean; tone?: Tone; icon?: React.ReactNode; shortcut?: string; } const DropdownItem = forwardRef(function DropdownItem( { value, children, onSelect, disabled = false, destructive = false, tone, icon, shortcut, className, onClick, onMouseEnter, ...rest }, ref, ) { const { setOpen, surface, highlightedValue, setHighlightedValue, registerItem, unregisterItem, labelMap, registerItemHandler } = useDropdownContext('PixelDropdown.Item'); const s = surfaceClasses(surface); const autoIdRaw = useId(); const itemValue = value ?? autoIdRaw; const effectiveTone: Tone | undefined = destructive ? 'red' : tone; useEffect(() => { registerItem(itemValue, disabled); if (typeof children === 'string') labelMap.set(itemValue, children); return () => unregisterItem(itemValue); }, [itemValue, disabled, registerItem, unregisterItem, labelMap, children]); // Keep the latest onSelect handler registered for keyboard activation. useEffect(() => { registerItemHandler(itemValue, disabled ? undefined : onSelect); }, [itemValue, disabled, onSelect, registerItemHandler]); const isHighlighted = highlightedValue === itemValue; return ( ); }); DropdownItem.displayName = 'PixelDropdown.Item'; function DropdownSeparator() { return
; } (DropdownSeparator as React.FC).displayName = 'PixelDropdown.Separator'; function DropdownHeader({ children }: { children: React.ReactNode }) { return (
{children}
); } (DropdownHeader as React.FC<{ children: React.ReactNode }>).displayName = 'PixelDropdown.Header'; interface DropdownCheckboxItemProps extends Omit { checked?: boolean; } const DropdownCheckboxItem = forwardRef(function DropdownCheckboxItem( { checked, children, ...rest }, ref, ) { return ( {checked ? '✓' : ''}} > {children} ); }); DropdownCheckboxItem.displayName = 'PixelDropdown.CheckboxItem'; interface DropdownRadioItemProps extends Omit { checked?: boolean; } const DropdownRadioItem = forwardRef(function DropdownRadioItem( { checked, children, ...rest }, ref, ) { return ( {checked ? '●' : ''}} > {children} ); }); DropdownRadioItem.displayName = 'PixelDropdown.RadioItem'; /* ─── Main forwardRef component (items[] sugar OR composition) ──────────── */ interface PixelDropdownComponent extends React.ForwardRefExoticComponent> { Root: typeof DropdownRoot; Trigger: typeof DropdownTrigger; Content: typeof DropdownContent; Item: typeof DropdownItem; Separator: typeof DropdownSeparator; Header: typeof DropdownHeader; CheckboxItem: typeof DropdownCheckboxItem; RadioItem: typeof DropdownRadioItem; } const PixelDropdownBase = forwardRef(function PixelDropdown( { label, items, onSelect, tone = 'neutral', icon, disabled = false, surface: surfaceProp, ariaLabel, children }, ref, ) { // Compositional form: forward children inside a Root + a shared container ref. if (children) { return (
{children}
); } // Legacy items[] form preserved 1:1 (with extended kinds support). return ( {})} tone={tone} icon={icon} disabled={disabled} ariaLabel={ariaLabel} /> ); }); interface ItemsRendererProps { label: string; items: DropdownOption[]; onSelect: (value: string) => void; tone: Tone; icon?: React.ReactNode; disabled: boolean; ariaLabel?: string; } const ItemsRenderer = forwardRef(function ItemsRenderer( { label, items, onSelect, tone, icon, disabled, ariaLabel }, ref, ) { // Read context from the Root we sit inside. return (
{label} {items.map((item, idx) => { const kind: DropdownItemKind = item.kind ?? 'item'; if (kind === 'separator') return ; if (kind === 'header') return {item.label}; const common = { value: item.value, disabled: item.disabled, tone: item.tone, shortcut: item.shortcut, onSelect: () => onSelect(item.value), }; if (kind === 'checkbox') { return ( {item.label} ); } if (kind === 'radio') { return ( {item.label} ); } // 'item' or 'submenu' (submenu = item w/ chevron affordance for now) return ( ▸ : undefined)} > {item.label} ); })}
); }); ItemsRenderer.displayName = 'PixelDropdown.ItemsRenderer'; export const PixelDropdown = PixelDropdownBase as PixelDropdownComponent; PixelDropdown.Root = DropdownRoot; PixelDropdown.Trigger = DropdownTrigger; PixelDropdown.Content = DropdownContent; PixelDropdown.Item = DropdownItem; PixelDropdown.Separator = DropdownSeparator; PixelDropdown.Header = DropdownHeader; PixelDropdown.CheckboxItem = DropdownCheckboxItem; PixelDropdown.RadioItem = DropdownRadioItem;