|
| 1 | +interface CommandItem { |
| 2 | + title: string; |
| 3 | + hint?: string; |
| 4 | + group: string; |
| 5 | + keywords: string; |
| 6 | + run: () => void; |
| 7 | +} |
| 8 | + |
| 9 | +const ROOT_HTML = ` |
| 10 | +<div |
| 11 | + id="cmdk" |
| 12 | + class="fixed inset-0 z-50 hidden items-start justify-center bg-black/40 px-4 pt-[18vh] backdrop-blur-sm dark:bg-black/60" |
| 13 | + role="dialog" |
| 14 | + aria-modal="true" |
| 15 | + aria-label="Command palette" |
| 16 | +> |
| 17 | + <div |
| 18 | + class="border-sidebar-border bg-sidebar text-sidebar-foreground w-full max-w-lg overflow-hidden rounded-lg border shadow-2xl" |
| 19 | + > |
| 20 | + <div class="border-sidebar-border flex items-center gap-2 border-b px-3"> |
| 21 | + <svg |
| 22 | + xmlns="http://www.w3.org/2000/svg" |
| 23 | + viewBox="0 0 24 24" |
| 24 | + fill="none" |
| 25 | + stroke="currentColor" |
| 26 | + stroke-width="2" |
| 27 | + stroke-linecap="round" |
| 28 | + stroke-linejoin="round" |
| 29 | + class="text-sidebar-muted-foreground size-4 shrink-0" |
| 30 | + aria-hidden="true" |
| 31 | + > |
| 32 | + <circle cx="11" cy="11" r="8" /> |
| 33 | + <path d="m21 21-4.3-4.3" /> |
| 34 | + </svg> |
| 35 | + <input |
| 36 | + data-cmdk-input |
| 37 | + type="text" |
| 38 | + autocomplete="off" |
| 39 | + spellcheck="false" |
| 40 | + placeholder="Type a command or search…" |
| 41 | + class="placeholder:text-sidebar-muted-foreground w-full bg-transparent py-3 text-sm outline-none" |
| 42 | + /> |
| 43 | + <kbd |
| 44 | + class="border-sidebar-border text-sidebar-muted-foreground hidden rounded border px-1.5 py-0.5 text-[10px] font-medium sm:inline-block" |
| 45 | + >ESC</kbd |
| 46 | + > |
| 47 | + </div> |
| 48 | + <div data-cmdk-list class="max-h-[50vh] overflow-y-auto p-1" role="listbox"></div> |
| 49 | + <div |
| 50 | + class="border-sidebar-border text-sidebar-muted-foreground flex items-center gap-3 border-t px-3 py-2 text-[10px]" |
| 51 | + > |
| 52 | + <span class="flex items-center gap-1" |
| 53 | + ><kbd class="border-sidebar-border rounded border px-1 py-0.5">↑</kbd |
| 54 | + ><kbd class="border-sidebar-border rounded border px-1 py-0.5">↓</kbd> Navigate</span |
| 55 | + > |
| 56 | + <span class="flex items-center gap-1" |
| 57 | + ><kbd class="border-sidebar-border rounded border px-1 py-0.5">↵</kbd> Select</span |
| 58 | + > |
| 59 | + <span class="flex items-center gap-1" |
| 60 | + ><kbd class="border-sidebar-border rounded border px-1 py-0.5">⌘</kbd |
| 61 | + ><kbd class="border-sidebar-border rounded border px-1 py-0.5">K</kbd> Toggle</span |
| 62 | + > |
| 63 | + </div> |
| 64 | + </div> |
| 65 | +</div> |
| 66 | +`; |
| 67 | + |
| 68 | +function escapeHtml(value: string): string { |
| 69 | + return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); |
| 70 | +} |
| 71 | + |
| 72 | +function collectNavItems(): CommandItem[] { |
| 73 | + const items: CommandItem[] = []; |
| 74 | + const links = document.querySelectorAll('[data-sidebar] nav-link'); |
| 75 | + links.forEach(el => { |
| 76 | + const href = el.getAttribute('href') ?? ''; |
| 77 | + const label = el.getAttribute('label') ?? ''; |
| 78 | + const component = el.getAttribute('component') ?? ''; |
| 79 | + const group = el.closest('nav-group')?.getAttribute('label') ?? 'Navigate'; |
| 80 | + items.push({ |
| 81 | + title: label, |
| 82 | + hint: component, |
| 83 | + group, |
| 84 | + keywords: `${label} ${component} ${group} ${href}`.toLowerCase(), |
| 85 | + run: () => { |
| 86 | + window.location.href = href; |
| 87 | + }, |
| 88 | + }); |
| 89 | + }); |
| 90 | + return items; |
| 91 | +} |
| 92 | + |
| 93 | +function collectAppearanceItems(): CommandItem[] { |
| 94 | + return [ |
| 95 | + { |
| 96 | + title: 'Toggle dark mode', |
| 97 | + group: 'Appearance', |
| 98 | + keywords: 'toggle dark mode theme appearance', |
| 99 | + run: () => { |
| 100 | + const on = document.documentElement.classList.toggle('dark'); |
| 101 | + localStorage.setItem('clerk-js-sandbox-dark-mode', on ? 'on' : 'off'); |
| 102 | + }, |
| 103 | + }, |
| 104 | + { |
| 105 | + title: 'Toggle sidebar', |
| 106 | + group: 'Appearance', |
| 107 | + keywords: 'toggle sidebar collapse expand', |
| 108 | + run: () => { |
| 109 | + const collapsed = document.documentElement.hasAttribute('data-sidebar-collapsed'); |
| 110 | + if (collapsed) { |
| 111 | + document.documentElement.removeAttribute('data-sidebar-collapsed'); |
| 112 | + localStorage.removeItem('clerk-js-sandbox-sidebar-collapsed'); |
| 113 | + } else { |
| 114 | + document.documentElement.setAttribute('data-sidebar-collapsed', ''); |
| 115 | + localStorage.setItem('clerk-js-sandbox-sidebar-collapsed', '1'); |
| 116 | + } |
| 117 | + }, |
| 118 | + }, |
| 119 | + ]; |
| 120 | +} |
| 121 | + |
| 122 | +function buildItems(): CommandItem[] { |
| 123 | + return [...collectNavItems(), ...collectAppearanceItems()]; |
| 124 | +} |
| 125 | + |
| 126 | +export function initCommandPalette(): void { |
| 127 | + const container = document.createElement('div'); |
| 128 | + container.innerHTML = ROOT_HTML.trim(); |
| 129 | + const root = container.firstElementChild as HTMLElement; |
| 130 | + document.body.appendChild(root); |
| 131 | + |
| 132 | + const input = root.querySelector<HTMLInputElement>('[data-cmdk-input]')!; |
| 133 | + const list = root.querySelector<HTMLDivElement>('[data-cmdk-list]')!; |
| 134 | + |
| 135 | + let filtered: CommandItem[] = []; |
| 136 | + let activeIndex = 0; |
| 137 | + |
| 138 | + function render() { |
| 139 | + const query = input.value.trim().toLowerCase(); |
| 140 | + const all = buildItems(); |
| 141 | + filtered = query ? all.filter(it => it.keywords.includes(query)) : all; |
| 142 | + activeIndex = filtered.length ? 0 : -1; |
| 143 | + |
| 144 | + if (!filtered.length) { |
| 145 | + list.innerHTML = '<div class="text-sidebar-muted-foreground px-3 py-6 text-center text-xs">No results</div>'; |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + let html = ''; |
| 150 | + let currentGroup: string | null = null; |
| 151 | + filtered.forEach((it, idx) => { |
| 152 | + if (it.group !== currentGroup) { |
| 153 | + currentGroup = it.group; |
| 154 | + html += `<div class="text-sidebar-muted-foreground px-2 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wide">${escapeHtml(currentGroup)}</div>`; |
| 155 | + } |
| 156 | + const hint = it.hint |
| 157 | + ? `<span class="text-sidebar-muted-foreground group-aria-selected:text-sidebar-accent-foreground/70 max-w-[11rem] shrink truncate font-mono text-[9px]/none" style="font-family: var(--font-mono);">${escapeHtml(it.hint)}</span>` |
| 158 | + : ''; |
| 159 | + html += `<button type="button" role="option" data-idx="${idx}" class="cmdk-item group flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-selected:bg-sidebar-accent-strong aria-selected:text-sidebar-accent-foreground"><span class="min-w-0 truncate">${escapeHtml(it.title)}</span>${hint}</button>`; |
| 160 | + }); |
| 161 | + list.innerHTML = html; |
| 162 | + updateSelection(); |
| 163 | + } |
| 164 | + |
| 165 | + function updateSelection() { |
| 166 | + const nodes = list.querySelectorAll<HTMLElement>('.cmdk-item'); |
| 167 | + nodes.forEach((node, idx) => { |
| 168 | + if (idx === activeIndex) { |
| 169 | + node.setAttribute('aria-selected', 'true'); |
| 170 | + node.scrollIntoView({ block: 'nearest' }); |
| 171 | + } else { |
| 172 | + node.removeAttribute('aria-selected'); |
| 173 | + } |
| 174 | + }); |
| 175 | + } |
| 176 | + |
| 177 | + function isOpen() { |
| 178 | + return !root.classList.contains('hidden'); |
| 179 | + } |
| 180 | + |
| 181 | + function open() { |
| 182 | + root.classList.remove('hidden'); |
| 183 | + root.classList.add('flex'); |
| 184 | + input.value = ''; |
| 185 | + render(); |
| 186 | + setTimeout(() => input.focus(), 0); |
| 187 | + } |
| 188 | + |
| 189 | + function close() { |
| 190 | + root.classList.add('hidden'); |
| 191 | + root.classList.remove('flex'); |
| 192 | + } |
| 193 | + |
| 194 | + function runActive() { |
| 195 | + const it = filtered[activeIndex]; |
| 196 | + if (!it) return; |
| 197 | + close(); |
| 198 | + it.run(); |
| 199 | + } |
| 200 | + |
| 201 | + document.addEventListener('keydown', e => { |
| 202 | + const isMac = navigator.platform.toLowerCase().includes('mac'); |
| 203 | + const mod = isMac ? e.metaKey : e.ctrlKey; |
| 204 | + if (mod && e.key.toLowerCase() === 'k') { |
| 205 | + e.preventDefault(); |
| 206 | + if (isOpen()) close(); |
| 207 | + else open(); |
| 208 | + return; |
| 209 | + } |
| 210 | + if (!isOpen()) return; |
| 211 | + if (e.key === 'Escape') { |
| 212 | + e.preventDefault(); |
| 213 | + close(); |
| 214 | + } else if (e.key === 'ArrowDown') { |
| 215 | + e.preventDefault(); |
| 216 | + if (filtered.length) { |
| 217 | + activeIndex = (activeIndex + 1) % filtered.length; |
| 218 | + updateSelection(); |
| 219 | + } |
| 220 | + } else if (e.key === 'ArrowUp') { |
| 221 | + e.preventDefault(); |
| 222 | + if (filtered.length) { |
| 223 | + activeIndex = (activeIndex - 1 + filtered.length) % filtered.length; |
| 224 | + updateSelection(); |
| 225 | + } |
| 226 | + } else if (e.key === 'Enter') { |
| 227 | + e.preventDefault(); |
| 228 | + runActive(); |
| 229 | + } |
| 230 | + }); |
| 231 | + |
| 232 | + input.addEventListener('input', render); |
| 233 | + |
| 234 | + list.addEventListener('click', e => { |
| 235 | + const target = (e.target as HTMLElement).closest<HTMLElement>('.cmdk-item'); |
| 236 | + if (!target) return; |
| 237 | + activeIndex = parseInt(target.getAttribute('data-idx') ?? '0', 10); |
| 238 | + runActive(); |
| 239 | + }); |
| 240 | + |
| 241 | + list.addEventListener('mousemove', e => { |
| 242 | + const target = (e.target as HTMLElement).closest<HTMLElement>('.cmdk-item'); |
| 243 | + if (!target) return; |
| 244 | + const idx = parseInt(target.getAttribute('data-idx') ?? '0', 10); |
| 245 | + if (idx !== activeIndex) { |
| 246 | + activeIndex = idx; |
| 247 | + updateSelection(); |
| 248 | + } |
| 249 | + }); |
| 250 | + |
| 251 | + root.addEventListener('click', e => { |
| 252 | + if (e.target === root) close(); |
| 253 | + }); |
| 254 | +} |
0 commit comments