{
    "id": "bhaskar/command-menu",
    "kind": "component",
    "title": "Command Menu",
    "summary": "A Ctrl/Cmd+K style command list with live filtering, arrow-key navigation, groups and shortcut hints.",
    "author": "Admin",
    "stars": 0,
    "url": "https://ownlyplans.com/bhaskar/command-menu/",
    "stack": [
        "CSS",
        "HTML",
        "JavaScript",
        "React"
    ],
    "tools": [
        "claude-code",
        "cursor",
        "lovable"
    ],
    "difficulty": "beginner",
    "license": "mit",
    "version": "1.0",
    "overview": "The fastest way to give an app a power-user feel. Type to filter, move with the arrow keys, press Enter to run. Matches are highlighted, empty results get a friendly message, and the list uses proper listbox roles so screen readers follow along.\n\nWire each item's action to your router or app state. In the React version pass your own commands as a prop.",
    "steps": [],
    "files": [],
    "install": "npx shadcn@latest add https://ownlyplans.com/r/bhaskar/command-menu.json",
    "how_to_use": "Use the component code (html, css, js, or react) directly, or run the install command.",
    "component": {
        "category": "Navigation",
        "html": "<div class=\"cm\" role=\"dialog\" aria-label=\"Command menu\">\n  <div class=\"cm-search\"><svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg>\n    <input id=\"cm-q\" type=\"text\" placeholder=\"Type a command or search...\" autocomplete=\"off\" role=\"combobox\" aria-expanded=\"true\" aria-controls=\"cm-list\">\n    <kbd>Esc</kbd></div>\n  <ul id=\"cm-list\" class=\"cm-list\" role=\"listbox\"></ul>\n</div>",
        "css": ".cm{width:min(420px,100%);background:var(--stage);border:1px solid var(--line);border-radius:14px;box-shadow:0 18px 50px -20px rgba(0,0,0,.35);overflow:hidden;font-size:14px}\n.cm-search{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line)}\n.cm-search svg{width:18px;height:18px;fill:none;stroke:var(--muted);stroke-width:2;stroke-linecap:round;flex:none}\n.cm-search input{flex:1;min-width:0;border:0;background:none;color:var(--ink);font:inherit;outline:none}\n.cm-search input::placeholder{color:var(--muted)}\nkbd{font:600 11px/1 ui-monospace,monospace;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:4px 6px}\n.cm-list{list-style:none;margin:0;padding:6px;max-height:190px;overflow:auto}\n.cm-group{padding:8px 10px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}\n.cm-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border-radius:9px;cursor:pointer;color:var(--ink)}\n.cm-item[aria-selected=true]{background:color-mix(in srgb,var(--accent) 14%,transparent)}\n.cm-item mark{background:none;color:var(--accent);font-weight:700}\n.cm-item span:last-child{color:var(--muted);font-size:12px}\n.cm-empty{padding:22px;text-align:center;color:var(--muted)}",
        "js": "const items=[\n {g:'Navigation',t:'Go to dashboard',k:'G D'},{g:'Navigation',t:'Open settings',k:'G S'},{g:'Navigation',t:'View billing',k:'G B'},\n {g:'Actions',t:'Create new project',k:'N'},{g:'Actions',t:'Invite a teammate',k:'I'},{g:'Actions',t:'Copy page link',k:'C L'},\n {g:'Theme',t:'Switch to dark mode',k:'T D'},{g:'Theme',t:'Switch to light mode',k:'T L'}];\nconst q=document.getElementById('cm-q'),list=document.getElementById('cm-list');let shown=[],active=0;\nconst esc=s=>s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));\nfunction mark(t,s){if(!s)return esc(t);const i=t.toLowerCase().indexOf(s.toLowerCase());return i<0?esc(t):esc(t.slice(0,i))+'<mark>'+esc(t.slice(i,i+s.length))+'</mark>'+esc(t.slice(i+s.length))}\nfunction render(){const s=q.value.trim();shown=items.filter(i=>i.t.toLowerCase().includes(s.toLowerCase()));active=Math.min(active,Math.max(shown.length-1,0));\n if(!shown.length){list.innerHTML='<li class=\"cm-empty\">No results for \"'+esc(s)+'\"</li>';return}\n let html='',g='';shown.forEach((i,n)=>{if(i.g!==g){g=i.g;html+='<li class=\"cm-group\" role=\"presentation\">'+g+'</li>'}\n html+='<li class=\"cm-item\" role=\"option\" id=\"cm-'+n+'\" data-n=\"'+n+'\" aria-selected=\"'+(n===active)+'\"><span>'+mark(i.t,s)+'</span><span>'+i.k+'</span></li>'});\n list.innerHTML=html;q.setAttribute('aria-activedescendant','cm-'+active);const a=document.getElementById('cm-'+active);a&&a.scrollIntoView({block:'nearest'})}\nfunction run(n){const i=shown[n];if(!i)return;q.value='';q.placeholder='Ran: '+i.t;active=0;render()}\nq.addEventListener('input',()=>{active=0;render()});\nq.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();active=(active+1)%Math.max(shown.length,1);render()}\n else if(e.key==='ArrowUp'){e.preventDefault();active=(active-1+shown.length)%Math.max(shown.length,1);render()}\n else if(e.key==='Enter'){run(active)}else if(e.key==='Escape'){q.value='';render()}});\nlist.addEventListener('mousemove',e=>{const li=e.target.closest('.cm-item');if(li&&+li.dataset.n!==active){active=+li.dataset.n;render()}});\nlist.addEventListener('click',e=>{const li=e.target.closest('.cm-item');li&&run(+li.dataset.n)});\nrender();",
        "react": "\"use client\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\nexport type Command = { group: string; label: string; shortcut?: string; run: () => void };\n\nconst css = `\n.cm{width:min(420px,100%);background:#fff;border:1px solid #d8deeb;border-radius:14px;box-shadow:0 18px 50px -20px rgba(0,0,0,.35);overflow:hidden;font:14px/1.5 system-ui,sans-serif;color:#0b1020}\n.cm-search{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid #d8deeb}\n.cm-search input{flex:1;min-width:0;border:0;background:none;color:inherit;font:inherit;outline:none}\n.cm-kbd{font:600 11px/1 ui-monospace,monospace;color:#5c627c;border:1px solid #d8deeb;border-radius:6px;padding:4px 6px}\n.cm-list{list-style:none;margin:0;padding:6px;max-height:280px;overflow:auto}\n.cm-group{padding:8px 10px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:#5c627c}\n.cm-item{display:flex;justify-content:space-between;gap:12px;padding:9px 10px;border-radius:9px;cursor:pointer}\n.cm-item[aria-selected=true]{background:rgba(22,89,245,.12)}\n.cm-item mark{background:none;color:#1659f5;font-weight:700}\n.cm-hint{color:#5c627c;font-size:12px}\n.cm-empty{padding:22px;text-align:center;color:#5c627c}`;\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const i = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;\n  if (i < 0) return <>{text}</>;\n  return <>{text.slice(0, i)}<mark>{text.slice(i, i + query.length)}</mark>{text.slice(i + query.length)}</>;\n}\n\nexport function CommandMenu({ commands, placeholder = \"Type a command or search...\" }: { commands: Command[]; placeholder?: string }) {\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState(0);\n  const listRef = useRef<HTMLUListElement>(null);\n  const shown = useMemo(() => commands.filter((c) => c.label.toLowerCase().includes(query.trim().toLowerCase())), [commands, query]);\n\n  useEffect(() => setActive(0), [query]);\n  useEffect(() => {\n    listRef.current?.querySelector(`[data-n=\"${active}\"]`)?.scrollIntoView({ block: \"nearest\" });\n  }, [active]);\n\n  const run = (n: number) => { shown[n]?.run(); setQuery(\"\"); };\n  const onKey = (e: React.KeyboardEvent) => {\n    const len = Math.max(shown.length, 1);\n    if (e.key === \"ArrowDown\") { e.preventDefault(); setActive((a) => (a + 1) % len); }\n    else if (e.key === \"ArrowUp\") { e.preventDefault(); setActive((a) => (a - 1 + len) % len); }\n    else if (e.key === \"Enter\") run(active);\n    else if (e.key === \"Escape\") setQuery(\"\");\n  };\n\n  let group = \"\";\n  return (\n    <div className=\"cm\" role=\"dialog\" aria-label=\"Command menu\">\n      <style>{css}</style>\n      <div className=\"cm-search\">\n        <input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKey} placeholder={placeholder}\n          role=\"combobox\" aria-expanded=\"true\" aria-controls=\"cm-list\" aria-activedescendant={`cm-${active}`} />\n        <kbd className=\"cm-kbd\">Esc</kbd>\n      </div>\n      <ul id=\"cm-list\" className=\"cm-list\" role=\"listbox\" ref={listRef}>\n        {!shown.length && <li className=\"cm-empty\">No results for \"{query}\"</li>}\n        {shown.map((c, n) => {\n          const head = c.group !== group ? (group = c.group) : null;\n          return (\n            <li key={c.group + c.label} role=\"presentation\">\n              {head && <div className=\"cm-group\">{head}</div>}\n              <div id={`cm-${n}`} data-n={n} role=\"option\" aria-selected={n === active} className=\"cm-item\"\n                onMouseMove={() => setActive(n)} onClick={() => run(n)}>\n                <span><Highlight text={c.label} query={query.trim()} /></span>\n                {c.shortcut && <span className=\"cm-hint\">{c.shortcut}</span>}\n              </div>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}",
        "dependencies": [],
        "install": "npx shadcn@latest add https://ownlyplans.com/r/bhaskar/command-menu.json",
        "licence_notice": ""
    }
}