import { useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, RefObject } from 'react'; import { createPortal } from 'react-dom'; import { cn } from '@/lib/utils'; export interface SlashCommandItem { name: string; description?: string; } interface Props { query: string; items: SlashCommandItem[]; inputRef: RefObject; onSelect: (name: string) => void; onClose: () => void; emptyLabel?: string; } const DROPDOWN_HEIGHT_BUDGET = 320; function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandItem[] { const q = query.toLowerCase(); const filtered = q ? items.filter((s) => s.name.toLowerCase().startsWith(q)) : items; return [...filtered].sort((a, b) => a.name.localeCompare(b.name)); } export function SlashCommandPicker({ query, items, inputRef, onSelect, onClose, emptyLabel = 'No commands available', }: Props) { const [highlightIndex, setHighlightIndex] = useState(0); const popoverRef = useRef(null); const filtered = useMemo(() => filterByPrefix(items, query), [items, query]); const [rect, setRect] = useState( () => inputRef.current?.getBoundingClientRect() ?? null, ); const [vvTick, setVvTick] = useState(0); useEffect(() => { setHighlightIndex(0); }, [query]); useEffect(() => { function recalc() { setRect(inputRef.current?.getBoundingClientRect() ?? null); setVvTick((t) => t + 1); } recalc(); const vv = window.visualViewport; vv?.addEventListener('resize', recalc); vv?.addEventListener('scroll', recalc); window.addEventListener('resize', recalc); return () => { vv?.removeEventListener('resize', recalc); vv?.removeEventListener('scroll', recalc); window.removeEventListener('resize', recalc); }; }, [inputRef]); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); } else if (e.key === 'Enter' || e.key === 'Tab') { if (filtered.length === 0) return; e.preventDefault(); const target = filtered[highlightIndex] ?? filtered[0]; if (target) onSelect(target.name); } else if (e.key === 'Escape') { e.preventDefault(); onClose(); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [filtered, highlightIndex, onSelect, onClose]); useEffect(() => { function handleMouseDown(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); } } document.addEventListener('mousedown', handleMouseDown); return () => document.removeEventListener('mousedown', handleMouseDown); }, [onClose]); useEffect(() => { const el = popoverRef.current?.querySelector('[data-highlighted="true"]'); if (el) el.scrollIntoView({ block: 'nearest' }); }, [highlightIndex]); const style = useMemo(() => { if (!rect) return { display: 'none' }; const vv = window.visualViewport; const vvOffsetTop = vv?.offsetTop ?? 0; const vvHeight = vv?.height ?? window.innerHeight; // Visible region in layout-viewport coords (what position:fixed uses) const visibleTop = vvOffsetTop; const visibleBottom = vvOffsetTop + vvHeight; const spaceAbove = rect.top - visibleTop; const spaceBelow = visibleBottom - rect.bottom; if (spaceAbove >= Math.min(DROPDOWN_HEIGHT_BUDGET, spaceBelow)) { // Place above: clamp to visible top const popupTop = Math.max(visibleTop, rect.top - DROPDOWN_HEIGHT_BUDGET); return { position: 'fixed', top: popupTop, left: rect.left, maxHeight: rect.top - popupTop, }; } // Place below: clamp to visible bottom return { position: 'fixed', top: rect.bottom, left: rect.left, maxHeight: Math.min(DROPDOWN_HEIGHT_BUDGET, visibleBottom - rect.bottom), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [rect, vvTick]); const popover = filtered.length === 0 ? (
{query ? `No command starts with "/${query}"` : emptyLabel}
) : (
{filtered.map((item, i) => (
setHighlightIndex(i)} onClick={() => onSelect(item.name)} >
/{item.name}
{item.description && (
{item.description}
)}
))}
); return createPortal(popover, document.body); }