"use client"; import * as React from "react"; import { motion } from "framer-motion"; import { IconCheck, IconSearch, IconSelector } from "@tabler/icons-react"; import { cn } from "../lib/utils"; import { transitions } from "../lib/motion"; import { Button } from "./button"; import { Kbd } from "./spinner"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; export interface CommandItem { value: string; label: string; description?: string; icon?: React.ReactNode; group?: string; shortcut?: string; /** Extra words that should match the search. */ keywords?: string[]; disabled?: boolean; } function score(item: CommandItem, query: string): number { if (!query) return 1; const needle = query.toLowerCase(); const label = item.label.toLowerCase(); if (label.startsWith(needle)) return 3; if (label.includes(needle)) return 2; const haystack = [item.description ?? "", ...(item.keywords ?? [])].join(" ").toLowerCase(); if (haystack.includes(needle)) return 1; // Subsequence match ("nwtk" → "new task") as a last resort. let position = 0; for (const character of needle) { position = label.indexOf(character, position); if (position === -1) return 0; position += 1; } return 0.5; } /** * A searchable list with keyboard navigation: type to filter, ↑/↓ to move, * Enter to choose. The highlight slides between items. */ export function CommandList({ items, onSelect, placeholder = "Search", selectedValue, emptyText = "No matches", autoFocus = true, className, }: { items: CommandItem[]; onSelect: (value: string) => void; placeholder?: string; selectedValue?: string | null; emptyText?: string; autoFocus?: boolean; className?: string; }) { const id = React.useId(); const [query, setQuery] = React.useState(""); const [active, setActive] = React.useState(0); const listRef = React.useRef(null); const filtered = React.useMemo( () => items .map((item, order) => ({ item, order, score: score(item, query.trim()) })) .filter((entry) => entry.score > 0) .sort((a, b) => b.score - a.score || a.order - b.order) .map((entry) => entry.item), [items, query], ); const groups = React.useMemo(() => { const map = new Map(); for (const item of filtered) { const key = query ? "" : (item.group ?? ""); map.set(key, [...(map.get(key) ?? []), item]); } return [...map.entries()]; }, [filtered, query]); const flat = groups.flatMap(([, entries]) => entries); React.useEffect(() => { listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active]); const choose = (item: CommandItem | undefined) => { if (item && !item.disabled) onSelect(item.value); }; return (
{flat.length === 0 ? (

{emptyText}

) : ( groups.map(([group, entries]) => (
{group ?

{group}

: null} {entries.map((item) => { const index = flat.indexOf(item); const isActive = index === active; return (
setActive(index)} onClick={() => choose(item)} className={cn( "relative flex cursor-default items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm", item.disabled ? "opacity-50" : "text-foreground", )} > {isActive ? ( ) : null} {item.icon ? {item.icon} : null} {item.label} {item.description ? {item.description} : null} {item.shortcut ? {item.shortcut} : null} {selectedValue === item.value ? : null}
); })}
)) )}
); } /** A select with search, for long option lists. */ export function Combobox({ items, value, onValueChange, placeholder = "Choose…", searchPlaceholder = "Search", className, }: { items: CommandItem[]; value: string | null; onValueChange: (value: string) => void; placeholder?: string; searchPlaceholder?: string; className?: string; }) { const [open, setOpen] = React.useState(false); const selected = items.find((item) => item.value === value); return ( { onValueChange(next); setOpen(false); }} /> ); }