components/command-menu/command-menu.react.tsx
184 lines and 23 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| Caps | 30 to 37 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| CommandMenu | 41 to 196 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:49> | 49 to 49 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:50> | 50 to 50 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| optionId | 52 to 52 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:55> | 55 to 58 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| run | 57 to 57 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:61> | 61 to 65 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:73> | 73 to 98 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:82> | 82 to 93 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:84> | 84 to 88 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:89> | 89 to 91 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:94> | 94 to 97 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:100> | 100 to 102 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:104> | 104 to 106 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| choose | 108 to 112 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:137> | 137 to 140 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:141> | 141 to 155 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:159> | 159 to 180 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:161> | 161 to 178 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:168> | 168 to 171 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:173> | 173 to 173 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
| <anonymous:174> | 174 to 174 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: mutation testing has not run on this repo yet |
Imports
It imports components/command-menu/command-menu.ts, components/dialog/dialog.react.tsx, components/listbox/listbox.react.tsx, components/shortcuts/shortcuts.react.tsx, components/shortcuts/shortcuts.ts. Imported by components/command-menu/examples.tsx.
Source
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Dialog } from "../dialog/dialog.react.tsx";
import { Listbox, ListboxEmpty, Option, OptionGroup } from "../listbox/listbox.react.tsx";
import { register } from "../shortcuts/shortcuts.ts";
import { Keys } from "../shortcuts/shortcuts.react.tsx";
import { emptyText, filterCommands, groupCommands, SEARCHING, step, type Command } from "./command-menu.ts";
export type { Command } from "./command-menu.ts";
export { SEARCHING, STOP_GROUP, STOP_HINTS, stopCommand } from "./command-menu.ts";
export interface CommandMenuProps {
open: boolean;
onOpenChange: (open: boolean) => void;
commands: Command[];
// The input's accessible name (a visually hidden label).
label?: string;
placeholder?: string;
// Binds Ctrl K, Cmd K and "/" to open it. On by default.
bindKeys?: boolean;
// Results from an async source as the person types (books, people, records). Called with the
// trimmed query and an AbortSignal, `searchDelay` ms (180) after the last key, never for an
// empty query; the signal aborts when the query changes or the menu closes. Its commands come
// after the matching `commands`, in their own groups and order, unfiltered; their ids must not
// repeat a command's. The last results stay until new ones arrive; a failed search keeps them.
search?: (query: string, signal: AbortSignal) => Promise<Command[]>;
searchDelay?: number;
}
// The shortcut caps of a command, after the words that say it is a shortcut.
function Caps({ spec }: { spec: string | string[] }) {
return (
<>
<span className="cap-sr-only">, shortcut </span>
<Keys spec={spec} />
</>
);
}
const SEARCH_ICON = <path fill="currentColor" fillRule="evenodd" d="M7 2.5a4.5 4.5 0 1 0 2.7 8.1l3 3 1.1-1.1-3-3A4.5 4.5 0 0 0 7 2.5zm0 1.5a3 3 0 1 1 0 6 3 3 0 0 1 0-6z" />;
export function CommandMenu({ open, onOpenChange, commands, label = "Search commands", placeholder = "Search commands", bindKeys = true, search, searchDelay = 180 }: CommandMenuProps) {
const id = useId();
const dlg = useRef<HTMLDialogElement | null>(null);
const input = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const [found, setFound] = useState<Command[]>([]);
const [busy, setBusy] = useState(false);
const shown = useMemo(() => [...filterCommands(commands, query), ...found], [commands, query, found]);
const groups = useMemo(() => groupCommands(shown), [shown]);
const cur = shown.length ? Math.min(active, shown.length - 1) : -1;
const optionId = (c: Command) => `${id}-o-${c.id}`;
const activeCommand = cur >= 0 ? shown[cur] : undefined;
useEffect(() => {
if (!bindKeys) return;
return register({ key: ["Mod+k", "/"], label: "Open the command menu", group: "General", run: () => onOpenChange(true) });
}, [bindKeys, onOpenChange]);
// A fresh menu each time it opens: nothing typed, the first command active.
useEffect(() => {
if (!open) return;
setQuery("");
setActive(0);
}, [open]);
// The live search: debounced, the one before aborted, nothing for an empty query. Read through
// a ref, so an inline function does not start a search on every render.
const q = query.trim();
const searchRef = useRef(search);
searchRef.current = search;
const live = search != null;
useEffect(() => {
const run = searchRef.current;
if (!run || !open || !q) {
setFound([]);
setBusy(false);
return;
}
const ctrl = new AbortController();
setBusy(true);
const timer = setTimeout(() => {
run(q, ctrl.signal).then(
(results) => {
if (ctrl.signal.aborted) return;
setFound(results);
setBusy(false);
},
() => {
if (!ctrl.signal.aborted) setBusy(false);
},
);
}, searchDelay);
return () => {
clearTimeout(timer);
ctrl.abort();
};
}, [live, searchDelay, open, q]);
useEffect(() => {
if (activeCommand) document.getElementById(optionId(activeCommand))?.scrollIntoView({ block: "nearest" });
}, [activeCommand]);
const setRef = useCallback((el: HTMLDialogElement | null) => {
dlg.current = el;
}, []);
const choose = (c: Command | undefined) => {
if (!c) return;
dlg.current?.close();
c.run();
};
return (
<Dialog ref={setRef} className="cap-cmd" open={open} onOpenChange={onOpenChange} placement="top" size="lg" closeButton={false} initialFocus={input} aria-label="Command menu">
<div className="cap-cmd-search">
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">
{SEARCH_ICON}
</svg>
<label className="cap-sr-only" htmlFor={`${id}-input`}>
{label}
</label>
<input
ref={input}
className="cap-cmd-input"
id={`${id}-input`}
type="text"
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={shown.length > 0}
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={activeCommand ? optionId(activeCommand) : undefined}
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setActive(step(cur, e.key === "ArrowDown" ? 1 : -1, shown.length));
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
setActive(e.key === "Home" ? 0 : Math.max(0, shown.length - 1));
} else if (e.key === "Enter") {
e.preventDefault();
choose(activeCommand);
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
dlg.current?.close();
}
}}
/>
</div>
<Listbox id={`${id}-list`} className="cap-cmd-list" mode="input" controlled aria-label="Commands" aria-busy={busy || undefined} hidden={shown.length === 0}>
{groups.map((g) => (
<OptionGroup label={g.group} key={g.group}>
{g.items.map((c) => (
<Option
key={c.id}
id={optionId(c)}
active={c === activeCommand}
hint={c.hint}
keys={c.shortcut ? <Caps spec={c.shortcut} /> : undefined}
onPointerMove={() => {
const i = shown.indexOf(c);
if (i !== cur) setActive(i);
}}
// The input keeps focus: a press on an option must not take it.
onPointerDown={(e) => e.preventDefault()}
onClick={() => choose(c)}
>
{c.label}
</Option>
))}
</OptionGroup>
))}
</Listbox>
<ListboxEmpty>{shown.length === 0 ? (busy ? SEARCHING : emptyText(query)) : ""}</ListboxEmpty>
<div className="cap-cmd-foot" aria-hidden="true">
<span>
<kbd>↑</kbd> <kbd>↓</kbd> move
</span>
<span>
<kbd>Enter</kbd> run
</span>
<span>
<kbd>Esc</kbd> close
</span>
</div>
</Dialog>
);
}