Skip to content

Commit 7f13f28

Browse files
authored
feat(js): add sandbox command palette (#8672)
1 parent 8cc3129 commit 7f13f28

3 files changed

Lines changed: 263 additions & 0 deletions

File tree

.changeset/polite-papers-watch.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/clerk-js/sandbox/app.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { PageMocking, type MockScenario } from '@clerk/msw';
22
import * as l from '../../localizations';
33
import { dark, neobrutalism, shadcn, shadesOfPurple } from '../../ui/src/themes';
44
import type { Clerk as ClerkType } from '../';
5+
import { initCommandPalette } from './cmdk';
56
import * as scenarios from './scenarios';
67

78
interface ComponentPropsControl {
@@ -176,6 +177,8 @@ window.AVAILABLE_SCENARIOS = AVAILABLE_SCENARIOS.reduce(
176177
{} as Record<AvailableScenario, AvailableScenario>,
177178
);
178179

180+
initCommandPalette();
181+
179182
const Clerk = window.Clerk;
180183
function assertClerkIsLoaded(c: ClerkType | undefined): asserts c is ClerkType {
181184
if (!c) {
@@ -514,6 +517,10 @@ void (async () => {
514517

515518
document.addEventListener('keydown', e => {
516519
if (e.key === '/') {
520+
const target = e.target as HTMLElement | null;
521+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
522+
return;
523+
}
517524
leftSidebar?.classList.toggle('hidden');
518525
pane.hidden = !pane.hidden;
519526
}

packages/clerk-js/sandbox/cmdk.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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

Comments
 (0)