Give the expand chevrons the BooCoder outline-button look (border-border bg-background, hover:bg-muted, filled when expanded) instead of the borderless ghost style. Applies to both BooChat's flat menu and BooCoder's grouped menu.
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { ChevronRight } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface SlashCommandItem {
|
|
name: string;
|
|
// One-liner shown always; also the fallback body when the row is expanded.
|
|
description?: string;
|
|
// Optional fuller explanation revealed when the row's chevron is expanded.
|
|
// When absent, the expanded panel falls back to `description` (un-truncated)
|
|
// — we never fabricate detail for commands that don't carry it (e.g. raw
|
|
// agent `/help` passthrough rows).
|
|
details?: string;
|
|
}
|
|
|
|
export interface SlashCommandGroup {
|
|
label: string;
|
|
items: SlashCommandItem[];
|
|
icon?: ReactNode;
|
|
}
|
|
|
|
interface Props {
|
|
query: string;
|
|
items: SlashCommandItem[];
|
|
// Optional segmented rendering. When provided, items are shown under labeled
|
|
// group headers (in order). `items` is ignored. BooChat passes only `items`
|
|
// (flat) so its menu is unchanged — grouping is opt-in.
|
|
groups?: SlashCommandGroup[];
|
|
inputRef: RefObject<HTMLElement | null>;
|
|
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,
|
|
groups,
|
|
inputRef,
|
|
onSelect,
|
|
onClose,
|
|
emptyLabel = 'No commands available',
|
|
}: Props) {
|
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
// At most one row's detail panel is open at a time; null = all collapsed.
|
|
// Indexed against the flat `filtered` list (same index space as highlight).
|
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
// When grouped, filter each group and drop empties; otherwise the flat list.
|
|
const filteredGroups = useMemo(
|
|
() =>
|
|
groups
|
|
? groups
|
|
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
|
|
.filter((g) => g.items.length > 0)
|
|
: null,
|
|
[groups, query],
|
|
);
|
|
// Flat list drives keyboard nav + Enter selection across all groups.
|
|
const filtered = useMemo(
|
|
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
|
|
[filteredGroups, items, query],
|
|
);
|
|
|
|
const [rect, setRect] = useState<DOMRect | null>(
|
|
() => inputRef.current?.getBoundingClientRect() ?? null,
|
|
);
|
|
const [vvTick, setVvTick] = useState(0);
|
|
|
|
// Filtering reshuffles indices, so any open detail panel is stale — collapse it.
|
|
useEffect(() => { setHighlightIndex(0); setExpandedIndex(null); }, [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 === 'ArrowRight') {
|
|
// Expand the highlighted row in place (separate affordance from select).
|
|
// Only acts when the row has something to reveal, so caret-right still
|
|
// works on non-expandable rows; Enter/Tab selection is untouched.
|
|
const target = filtered[highlightIndex];
|
|
if (!target || !(target.details ?? target.description)) return;
|
|
e.preventDefault();
|
|
setExpandedIndex(highlightIndex);
|
|
} else if (e.key === 'ArrowLeft') {
|
|
// Collapse the highlighted row if it's the open one; otherwise let the
|
|
// caret move as usual.
|
|
if (expandedIndex !== highlightIndex) return;
|
|
e.preventDefault();
|
|
setExpandedIndex(null);
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose();
|
|
}
|
|
}
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [filtered, highlightIndex, expandedIndex, 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]);
|
|
|
|
useEffect(() => {
|
|
if (expandedIndex == null) return;
|
|
const el = popoverRef.current?.querySelector('[data-expanded="true"]');
|
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
}, [expandedIndex]);
|
|
|
|
const style = useMemo<CSSProperties>(() => {
|
|
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 renderItem = (item: SlashCommandItem, i: number) => {
|
|
const expanded = i === expandedIndex;
|
|
// Fuller text when provided, else the one-liner shown un-truncated. Never
|
|
// invented — a row with neither shows an honest placeholder.
|
|
const detailText = item.details ?? item.description ?? null;
|
|
return (
|
|
<div
|
|
key={`${i}-${item.name}`}
|
|
role="option"
|
|
aria-selected={i === highlightIndex}
|
|
data-highlighted={i === highlightIndex}
|
|
data-expanded={expanded}
|
|
className={cn(
|
|
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
|
i === highlightIndex && 'bg-muted',
|
|
)}
|
|
onMouseEnter={() => setHighlightIndex(i)}
|
|
onClick={() => onSelect(item.name)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
|
|
{item.description && (
|
|
<div className="text-xs text-muted-foreground truncate">{item.description}</div>
|
|
)}
|
|
</div>
|
|
{/* Chevron is a SEPARATE affordance: stopPropagation keeps the row's
|
|
click (select) from firing; preventDefault on mousedown keeps focus
|
|
on the composer so typing/arrow-nav continues uninterrupted. */}
|
|
<button
|
|
type="button"
|
|
aria-label={expanded ? `Hide details for /${item.name}` : `Show details for /${item.name}`}
|
|
aria-expanded={expanded}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setHighlightIndex(i);
|
|
setExpandedIndex((prev) => (prev === i ? null : i));
|
|
}}
|
|
className="-mr-1 -mt-0.5 flex shrink-0 items-center justify-center rounded-md border border-border bg-background p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
|
|
>
|
|
<ChevronRight
|
|
className={cn(
|
|
'size-3.5 transition-transform duration-200 motion-reduce:transition-none',
|
|
expanded && 'rotate-90',
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
{/* grid-rows 0fr→1fr animates height with no fixed value; motion-reduce
|
|
disables the transition for users who ask for less motion. */}
|
|
<div
|
|
className={cn(
|
|
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
|
|
expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
|
)}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div className="mt-1.5 border-t border-border/40 pt-1.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words">
|
|
{detailText ?? <span className="italic text-muted-foreground/70">No description provided.</span>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
let runningIndex = -1;
|
|
const popover = filtered.length === 0 ? (
|
|
<div
|
|
ref={popoverRef}
|
|
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] p-2"
|
|
style={style}
|
|
>
|
|
<div className="text-xs text-muted-foreground px-2 py-1">
|
|
{query ? `No command starts with "/${query}"` : emptyLabel}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
ref={popoverRef}
|
|
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
|
|
style={style}
|
|
>
|
|
{filteredGroups
|
|
? filteredGroups.map((g) => (
|
|
<div key={g.label}>
|
|
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 flex items-center gap-1.5">
|
|
{g.icon}
|
|
{g.label}
|
|
</div>
|
|
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
|
</div>
|
|
))
|
|
: filtered.map((item, i) => renderItem(item, i))}
|
|
</div>
|
|
);
|
|
|
|
return createPortal(popover, document.body);
|
|
}
|