← 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 =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
})();