← back to Marketing Command Center

public/cmd-palette.js

198 lines

// Command palette — ⌘K / Ctrl-K (or "/" when not typing) to jump to any panel.
// Self-contained IIFE, zero deps. app.js hands it the panel list + group map via
// window.cmdPalette.setData(PANELS); reads window.MCC_GROUP_MAP / MCC_HIDDEN_HOST /
// MCC_NAV_ORDER (all set by app.js boot) for group labels + default ordering.
// The whole point is that it feels INSTANT: on open we render + focus BEFORE the
// open animation paints, so the first keystroke always lands.
(function () {
  const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

  let allItems = [];   // [{id,title,icon,pending,group,hostLabel}]
  let filtered = [];
  let activeIdx = -1;
  let isOpen = false;
  let prevFocus = null;

  const byId = id => document.getElementById(id);

  function injectCSS() {
    if (byId('cmd-palette-css')) return;
    const st = document.createElement('style'); st.id = 'cmd-palette-css';
    st.textContent = `
    /* z-index above every in-panel drawer/popover/toast (clients drawer + amplify popover + quickpost toast all sit at 9999) */
    #cmd-backdrop{position:fixed;inset:0;background:rgba(20,15,10,.5);z-index:10000;animation:cmdfade .12s ease}
    #cmd-modal{position:fixed;top:16vh;left:50%;transform:translateX(-50%);width:min(560px,calc(100vw - 28px));z-index:10001;animation:cmdslide .14s ease}
    #cmd-shell{background:#fbf9f5;border:1px solid #e3ddd0;border-radius:14px;box-shadow:0 24px 80px rgba(20,15,8,.30),0 2px 8px rgba(20,15,8,.12);overflow:hidden}
    #cmd-input-wrap{display:flex;align-items:center;gap:10px;padding:13px 15px;border-bottom:1px solid #e7e1d6}
    #cmd-search-icon{font-size:17px;color:#8a8275;flex:none}
    #cmd-input{flex:1;border:0;background:transparent;font:500 15px/1 Inter,-apple-system,sans-serif;color:#2a2620;outline:none;padding:0}
    #cmd-input::placeholder{color:#a49a88}
    #cmd-esc-hint{flex:none;font:600 10px/1 Inter,sans-serif;background:#f4efe7;border:1px solid #e7e1d6;border-radius:5px;padding:3px 7px;color:#8a8275;letter-spacing:.3px}
    #cmd-results{list-style:none;margin:0;padding:6px;max-height:56vh;overflow-y:auto;scroll-padding:6px}
    #cmd-results::-webkit-scrollbar{width:8px}#cmd-results::-webkit-scrollbar-thumb{background:#e0d8cc;border-radius:8px}
    .cmd-item{display:flex;align-items:center;gap:12px;padding:9px 11px;border-radius:9px;cursor:pointer}
    .cmd-item:hover{background:#f4efe7}
    .cmd-item[aria-selected="true"]{background:linear-gradient(120deg,#f0e8d4,#ecdfc9);box-shadow:inset 0 0 0 2px #d8c19a}
    .cmd-icon{width:24px;text-align:center;font-size:16px;flex:none}
    .cmd-label{display:flex;flex-direction:column;gap:1px;min-width:0}
    .cmd-title{font:500 13.5px/1.3 Inter,sans-serif;color:#2a2620;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .cmd-group{font:400 11px/1 Inter,sans-serif;color:#8a8275;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .cmd-pending .cmd-title{opacity:.5}
    .cmd-empty{padding:16px 12px;color:#8a8275;font-size:12.5px}
    @keyframes cmdfade{from{opacity:0}to{opacity:1}}
    @keyframes cmdslide{from{opacity:0;transform:translateX(-50%) translateY(-6px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
    /* Sidebar "Search panels…" trigger */
    #cmd-trigger{display:flex;align-items:center;gap:8px;margin:2px 10px 8px;padding:8px 11px;border-radius:9px;
      border:1px solid #2f2a24;background:transparent;color:#b7ad9c;font:400 12.5px/1 Inter,sans-serif;cursor:pointer;width:calc(100% - 20px)}
    #cmd-trigger:hover{background:#241f1b;border-color:#d8c19a;color:#d8c19a}
    #cmd-trigger .k{margin-left:auto;font-size:10px;opacity:.65;letter-spacing:.3px}
    @media(max-width:860px){#cmd-modal{top:10vh}#cmd-trigger .k{display:none}}`;
    document.head.appendChild(st);
  }

  function injectDOM() {
    if (byId('cmd-modal')) return;
    injectCSS();
    const backdrop = document.createElement('div');
    backdrop.id = 'cmd-backdrop'; backdrop.hidden = true; backdrop.setAttribute('aria-hidden', 'true');
    const modal = document.createElement('div');
    modal.id = 'cmd-modal'; modal.hidden = true;
    modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Jump to panel');
    modal.innerHTML = `<div id="cmd-shell">
      <div id="cmd-input-wrap">
        <span id="cmd-search-icon" aria-hidden="true">⌕</span>
        <input id="cmd-input" type="text" placeholder="Jump to panel…" autocomplete="off" spellcheck="false"
               aria-autocomplete="list" aria-controls="cmd-results" aria-activedescendant="">
        <kbd id="cmd-esc-hint">Esc</kbd>
      </div>
      <ul id="cmd-results" role="listbox" aria-label="Panels"></ul></div>`;
    document.body.appendChild(backdrop);
    document.body.appendChild(modal);
    document.addEventListener('keydown', onDocKeydown);
    modal.addEventListener('keydown', onModalKeydown);
    backdrop.addEventListener('mousedown', close);
    byId('cmd-input').addEventListener('input', () => filter(byId('cmd-input').value));
  }

  function injectTrigger() {
    const tabs = byId('tabs'); if (!tabs || byId('cmd-trigger')) return;
    const t = document.createElement('button');
    t.id = 'cmd-trigger'; t.type = 'button'; t.setAttribute('aria-label', 'Search panels (Command K)');
    const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
    t.innerHTML = `<span aria-hidden="true">⌕</span><span>Search panels…</span><span class="k">${mac ? '⌘K' : 'Ctrl K'}</span>`;
    t.onclick = open;
    tabs.insertBefore(t, tabs.firstChild);
  }

  function buildItems(panels) {
    const gmap = window.MCC_GROUP_MAP || {};
    const hostOf = window.MCC_HIDDEN_HOST || {};
    return panels.map(p => {
      const hostId = hostOf[p.id];
      const group = gmap[p.id] || (hostId ? (gmap[hostId] || 'More') : 'More');
      const hostLabel = hostId ? ` · via ${(panels.find(x => x.id === hostId) || {}).title || hostId}` : '';
      return { id: p.id, title: p.title, icon: p.icon, pending: !!p.pending, group, hostLabel };
    });
  }

  function setData(panels) {
    allItems = buildItems(panels || []);
    injectTrigger();
    filter('');
  }

  function filter(q) {
    const raw = String(q || '').trim().toLowerCase();
    if (!raw) {
      const order = window.MCC_NAV_ORDER || [];
      filtered = allItems.slice().sort((a, b) => {
        if (!!a.pending !== !!b.pending) return a.pending ? 1 : -1;
        const ai = order.indexOf(a.id), bi = order.indexOf(b.id);
        return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
      });
    } else {
      const t1 = [], t2 = [], t3 = [];
      for (const it of allItems) {
        const t = it.title.toLowerCase(), g = (it.group + it.hostLabel).toLowerCase();
        if (t.startsWith(raw)) t1.push(it);
        else if (t.includes(raw)) t2.push(it);
        else if (g.includes(raw)) t3.push(it);
      }
      filtered = t1.concat(t2, t3);
    }
    activeIdx = filtered.length ? 0 : -1;
    render();
  }

  function render() {
    const ul = byId('cmd-results');
    if (!filtered.length) { ul.innerHTML = '<li class="cmd-empty">No panels match.</li>'; byId('cmd-input').setAttribute('aria-activedescendant', ''); return; }
    ul.innerHTML = filtered.map((p, i) =>
      `<li id="cmd-item-${esc(p.id)}" role="option" aria-selected="${i === activeIdx}" class="cmd-item${p.pending ? ' cmd-pending' : ''}" data-id="${esc(p.id)}">
         <span class="cmd-icon" aria-hidden="true">${esc(p.icon || '▪')}</span>
         <span class="cmd-label"><span class="cmd-title">${esc(p.title)}</span><span class="cmd-group">${esc(p.group)}${esc(p.hostLabel)}</span></span>
       </li>`).join('');
    ul.querySelectorAll('.cmd-item[data-id]').forEach(li =>
      li.addEventListener('mousedown', e => { e.preventDefault(); navigate(li.dataset.id); }));
    setActive(activeIdx);
  }

  function setActive(idx) {
    activeIdx = idx;
    const items = byId('cmd-results').querySelectorAll('.cmd-item[data-id]');
    items.forEach((li, i) => li.setAttribute('aria-selected', String(i === idx)));
    if (idx >= 0 && items[idx]) { items[idx].scrollIntoView({ block: 'nearest' }); byId('cmd-input').setAttribute('aria-activedescendant', items[idx].id); }
    else byId('cmd-input').setAttribute('aria-activedescendant', '');
  }

  function open() {
    if (isOpen) return;
    isOpen = true; prevFocus = document.activeElement;
    byId('cmd-backdrop').hidden = false; byId('cmd-backdrop').removeAttribute('aria-hidden');
    byId('cmd-modal').hidden = false;
    const input = byId('cmd-input'); input.value = '';
    filter('');        // render BEFORE focus/paint so the first keystroke lands
    input.focus();
    document.body.style.overflow = 'hidden';
  }

  function close() {
    if (!isOpen) return;
    isOpen = false;
    byId('cmd-backdrop').hidden = true; byId('cmd-backdrop').setAttribute('aria-hidden', 'true');
    byId('cmd-modal').hidden = true;
    document.body.style.overflow = '';
    activeIdx = -1;
    if (prevFocus && prevFocus.focus) prevFocus.focus();
    prevFocus = null;
  }

  function navigate(id) {
    close();
    document.body.classList.remove('nav-open');   // close mobile drawer
    location.hash = id;
  }

  function onDocKeydown(e) {
    if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); isOpen ? close() : open(); return; }
    if (e.key === '/' && !isOpen) {
      // Only fire "/" from a non-interactive context (page body) — never while a
      // form field, button, or nav link has focus, so "/" doesn't pop unexpectedly.
      const ae = document.activeElement, tag = ae && ae.tagName;
      if (!ae || ae === document.body || (!['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(tag) && !ae.isContentEditable && ae.tabIndex < 0)) { e.preventDefault(); open(); }
    }
  }
  function onModalKeydown(e) {
    if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); if (filtered.length) setActive((activeIdx + 1) % filtered.length); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); if (filtered.length) setActive((activeIdx - 1 + filtered.length) % filtered.length); }
    else if (e.key === 'Enter') { e.preventDefault(); if (activeIdx >= 0 && filtered[activeIdx]) navigate(filtered[activeIdx].id); }
    else if (e.key === 'Tab') { e.preventDefault(); close(); }   // ARIA combobox: Tab dismisses + restores focus (not a black hole)
  }

  window.cmdPalette = { setData, open, close };
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', injectDOM);
  else injectDOM();
})();