From 388fcdaed212941c9ca37d56b93f9f0161050dde Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 27 May 2026 14:39:54 -0400 Subject: [PATCH] feat(js): add sandbox command palette --- .changeset/polite-papers-watch.md | 2 + packages/clerk-js/sandbox/app.ts | 7 + packages/clerk-js/sandbox/cmdk.ts | 254 ++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 .changeset/polite-papers-watch.md create mode 100644 packages/clerk-js/sandbox/cmdk.ts diff --git a/.changeset/polite-papers-watch.md b/.changeset/polite-papers-watch.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/polite-papers-watch.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts index 3a94d2a6c2d..ce846c511c8 100644 --- a/packages/clerk-js/sandbox/app.ts +++ b/packages/clerk-js/sandbox/app.ts @@ -2,6 +2,7 @@ import { PageMocking, type MockScenario } from '@clerk/msw'; import * as l from '../../localizations'; import { dark, neobrutalism, shadcn, shadesOfPurple } from '../../ui/src/themes'; import type { Clerk as ClerkType } from '../'; +import { initCommandPalette } from './cmdk'; import * as scenarios from './scenarios'; interface ComponentPropsControl { @@ -176,6 +177,8 @@ window.AVAILABLE_SCENARIOS = AVAILABLE_SCENARIOS.reduce( {} as Record, ); +initCommandPalette(); + const Clerk = window.Clerk; function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType { if (!c) { @@ -514,6 +517,10 @@ void (async () => { document.addEventListener('keydown', e => { if (e.key === '/') { + const target = e.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) { + return; + } leftSidebar?.classList.toggle('hidden'); pane.hidden = !pane.hidden; } diff --git a/packages/clerk-js/sandbox/cmdk.ts b/packages/clerk-js/sandbox/cmdk.ts new file mode 100644 index 00000000000..6cc0d6a6e32 --- /dev/null +++ b/packages/clerk-js/sandbox/cmdk.ts @@ -0,0 +1,254 @@ +interface CommandItem { + title: string; + hint?: string; + group: string; + keywords: string; + run: () => void; +} + +const ROOT_HTML = ` + +`; + +function escapeHtml(value: string): string { + return String(value).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function collectNavItems(): CommandItem[] { + const items: CommandItem[] = []; + const links = document.querySelectorAll('[data-sidebar] nav-link'); + links.forEach(el => { + const href = el.getAttribute('href') ?? ''; + const label = el.getAttribute('label') ?? ''; + const component = el.getAttribute('component') ?? ''; + const group = el.closest('nav-group')?.getAttribute('label') ?? 'Navigate'; + items.push({ + title: label, + hint: component, + group, + keywords: `${label} ${component} ${group} ${href}`.toLowerCase(), + run: () => { + window.location.href = href; + }, + }); + }); + return items; +} + +function collectAppearanceItems(): CommandItem[] { + return [ + { + title: 'Toggle dark mode', + group: 'Appearance', + keywords: 'toggle dark mode theme appearance', + run: () => { + const on = document.documentElement.classList.toggle('dark'); + localStorage.setItem('clerk-js-sandbox-dark-mode', on ? 'on' : 'off'); + }, + }, + { + title: 'Toggle sidebar', + group: 'Appearance', + keywords: 'toggle sidebar collapse expand', + run: () => { + const collapsed = document.documentElement.hasAttribute('data-sidebar-collapsed'); + if (collapsed) { + document.documentElement.removeAttribute('data-sidebar-collapsed'); + localStorage.removeItem('clerk-js-sandbox-sidebar-collapsed'); + } else { + document.documentElement.setAttribute('data-sidebar-collapsed', ''); + localStorage.setItem('clerk-js-sandbox-sidebar-collapsed', '1'); + } + }, + }, + ]; +} + +function buildItems(): CommandItem[] { + return [...collectNavItems(), ...collectAppearanceItems()]; +} + +export function initCommandPalette(): void { + const container = document.createElement('div'); + container.innerHTML = ROOT_HTML.trim(); + const root = container.firstElementChild as HTMLElement; + document.body.appendChild(root); + + const input = root.querySelector('[data-cmdk-input]')!; + const list = root.querySelector('[data-cmdk-list]')!; + + let filtered: CommandItem[] = []; + let activeIndex = 0; + + function render() { + const query = input.value.trim().toLowerCase(); + const all = buildItems(); + filtered = query ? all.filter(it => it.keywords.includes(query)) : all; + activeIndex = filtered.length ? 0 : -1; + + if (!filtered.length) { + list.innerHTML = '
No results
'; + return; + } + + let html = ''; + let currentGroup: string | null = null; + filtered.forEach((it, idx) => { + if (it.group !== currentGroup) { + currentGroup = it.group; + html += `
${escapeHtml(currentGroup)}
`; + } + const hint = it.hint + ? `${escapeHtml(it.hint)}` + : ''; + html += ``; + }); + list.innerHTML = html; + updateSelection(); + } + + function updateSelection() { + const nodes = list.querySelectorAll('.cmdk-item'); + nodes.forEach((node, idx) => { + if (idx === activeIndex) { + node.setAttribute('aria-selected', 'true'); + node.scrollIntoView({ block: 'nearest' }); + } else { + node.removeAttribute('aria-selected'); + } + }); + } + + function isOpen() { + return !root.classList.contains('hidden'); + } + + function open() { + root.classList.remove('hidden'); + root.classList.add('flex'); + input.value = ''; + render(); + setTimeout(() => input.focus(), 0); + } + + function close() { + root.classList.add('hidden'); + root.classList.remove('flex'); + } + + function runActive() { + const it = filtered[activeIndex]; + if (!it) return; + close(); + it.run(); + } + + document.addEventListener('keydown', e => { + const isMac = navigator.platform.toLowerCase().includes('mac'); + const mod = isMac ? e.metaKey : e.ctrlKey; + if (mod && e.key.toLowerCase() === 'k') { + e.preventDefault(); + if (isOpen()) close(); + else open(); + return; + } + if (!isOpen()) return; + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (filtered.length) { + activeIndex = (activeIndex + 1) % filtered.length; + updateSelection(); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (filtered.length) { + activeIndex = (activeIndex - 1 + filtered.length) % filtered.length; + updateSelection(); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + runActive(); + } + }); + + input.addEventListener('input', render); + + list.addEventListener('click', e => { + const target = (e.target as HTMLElement).closest('.cmdk-item'); + if (!target) return; + activeIndex = parseInt(target.getAttribute('data-idx') ?? '0', 10); + runActive(); + }); + + list.addEventListener('mousemove', e => { + const target = (e.target as HTMLElement).closest('.cmdk-item'); + if (!target) return; + const idx = parseInt(target.getAttribute('data-idx') ?? '0', 10); + if (idx !== activeIndex) { + activeIndex = idx; + updateSelection(); + } + }); + + root.addEventListener('click', e => { + if (e.target === root) close(); + }); +}