Skip to content

Commit 9fb8bf3

Browse files
committed
feat(ui): add command palette for quick navigation and actions
1 parent 62a9db0 commit 9fb8bf3

28 files changed

+2863
-2
lines changed

app/app.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ if (import.meta.client) {
144144
<NuxtPage />
145145
</div>
146146

147+
<CommandPalette />
148+
147149
<AppFooter />
148150

149151
<ScrollToTop />

app/components/AppFooter.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const closeModal = () => modalRef.value?.close?.()
5353
{{ $t('shortcuts.section.global') }}
5454
</p>
5555
<ul class="mb-6 flex flex-col gap-2">
56+
<li class="flex gap-2 items-center">
57+
<kbd class="kbd">⌘K</kbd>/<kbd class="kbd">Ctrl+K</kbd>
58+
<span>{{ $t('shortcuts.command_palette') }}</span>
59+
</li>
5660
<li class="flex gap-2 items-center">
5761
<kbd class="kbd">/</kbd>
5862
<span>{{ $t('shortcuts.focus_search') }}</span>
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
<script setup lang="ts">
2+
import type { CommandPaletteCommand } from '~/types/command-palette'
3+
4+
const { isOpen, query, close, toggle, view, setView } = useCommandPalette()
5+
const { groupedCommands, flatCommands, hasResults, submitSearchQuery, trimmedQuery } =
6+
useCommandPaletteCommands()
7+
const keyboardShortcuts = useKeyboardShortcuts()
8+
const route = useRoute()
9+
10+
const modalRef = useTemplateRef<{
11+
showModal: () => void
12+
close: () => void
13+
}>('modalRef')
14+
const inputRef = useTemplateRef<{
15+
focus: () => void
16+
}>('inputRef')
17+
18+
const activeIndex = shallowRef(-1)
19+
const previouslyFocused = shallowRef<HTMLElement | null>(null)
20+
21+
const dialogId = 'command-palette-modal'
22+
const inputId = `${dialogId}-input`
23+
const descriptionId = `${dialogId}-description`
24+
const statusId = `${dialogId}-status`
25+
const resultsId = `${dialogId}-results`
26+
27+
const inputDescribedBy = computed(() => `${descriptionId} ${statusId}`)
28+
const isLanguageView = computed(() => view.value === 'languages')
29+
const modalSubtitle = computed(() =>
30+
isLanguageView.value ? $t('command_palette.subtitle_languages') : $t('command_palette.subtitle'),
31+
)
32+
33+
const statusMessage = computed(() => {
34+
const count = flatCommands.value.length
35+
36+
if (!count && trimmedQuery.value) {
37+
return $t('command_palette.status.no_matches_search', { query: trimmedQuery.value })
38+
}
39+
40+
if (trimmedQuery.value) {
41+
return $t('command_palette.status.matching', { count }, count)
42+
}
43+
44+
return $t('command_palette.status.available', { count }, count)
45+
})
46+
47+
const commandIndexMap = computed(() => {
48+
return new Map(flatCommands.value.map((command, index) => [command.id, index]))
49+
})
50+
51+
function getDialog() {
52+
return document.querySelector<HTMLDialogElement>(`#${dialogId}`)
53+
}
54+
55+
function getInputElement() {
56+
return document.querySelector<HTMLInputElement>(`#${inputId}`)
57+
}
58+
59+
function getCommandElements() {
60+
return Array.from(
61+
getDialog()?.querySelectorAll<HTMLButtonElement>('[data-command-item="true"]') ?? [],
62+
)
63+
}
64+
65+
function focusInput() {
66+
inputRef.value?.focus()
67+
}
68+
69+
function focusCommand(index: number) {
70+
const elements = getCommandElements()
71+
const element = elements[index]
72+
if (!element) return
73+
74+
activeIndex.value = index
75+
element.focus()
76+
}
77+
78+
async function handleCommandSelect(command: CommandPaletteCommand) {
79+
await command.action()
80+
}
81+
82+
function handleGlobalKeydown(event: KeyboardEvent) {
83+
if (event.isComposing) return
84+
85+
const isToggleShortcut =
86+
event.key.toLowerCase() === 'k' &&
87+
(event.metaKey || event.ctrlKey) &&
88+
!event.altKey &&
89+
!event.shiftKey
90+
91+
if (isToggleShortcut) {
92+
if (!keyboardShortcuts.value) return
93+
94+
event.preventDefault()
95+
toggle()
96+
return
97+
}
98+
99+
if (!isOpen.value) return
100+
101+
if (event.key === 'ArrowDown') {
102+
event.preventDefault()
103+
const currentIndex = getCommandElements().findIndex(el => el === document.activeElement)
104+
const nextIndex =
105+
currentIndex < 0 ? 0 : Math.min(currentIndex + 1, flatCommands.value.length - 1)
106+
focusCommand(nextIndex)
107+
return
108+
}
109+
110+
if (event.key === 'ArrowUp') {
111+
event.preventDefault()
112+
const currentIndex = getCommandElements().findIndex(el => el === document.activeElement)
113+
if (currentIndex <= 0) {
114+
activeIndex.value = -1
115+
focusInput()
116+
return
117+
}
118+
119+
focusCommand(currentIndex - 1)
120+
return
121+
}
122+
123+
if (event.key === 'Enter' && document.activeElement === getInputElement()) {
124+
const firstCommand = flatCommands.value[0]
125+
if (!firstCommand) {
126+
if (!trimmedQuery.value) return
127+
128+
event.preventDefault()
129+
void submitSearchQuery()
130+
return
131+
}
132+
133+
event.preventDefault()
134+
void handleCommandSelect(firstCommand)
135+
}
136+
}
137+
138+
function handleDialogClose() {
139+
if (isOpen.value) {
140+
close()
141+
return
142+
}
143+
144+
activeIndex.value = -1
145+
previouslyFocused.value?.focus()
146+
previouslyFocused.value = null
147+
}
148+
149+
function handleBack() {
150+
setView('root')
151+
}
152+
153+
watch(
154+
isOpen,
155+
async open => {
156+
const dialog = getDialog()
157+
158+
if (open) {
159+
previouslyFocused.value =
160+
document.activeElement instanceof HTMLElement ? document.activeElement : null
161+
await nextTick()
162+
if (!dialog?.open) {
163+
modalRef.value?.showModal()
164+
}
165+
await nextTick()
166+
focusInput()
167+
activeIndex.value = -1
168+
return
169+
}
170+
171+
if (dialog?.open) {
172+
modalRef.value?.close()
173+
return
174+
}
175+
176+
activeIndex.value = -1
177+
previouslyFocused.value?.focus()
178+
previouslyFocused.value = null
179+
},
180+
{ flush: 'post' },
181+
)
182+
183+
watch(
184+
() => route.fullPath,
185+
() => {
186+
if (isOpen.value) {
187+
close()
188+
}
189+
},
190+
)
191+
192+
watch(query, () => {
193+
activeIndex.value = -1
194+
})
195+
196+
useEventListener(document, 'keydown', handleGlobalKeydown)
197+
</script>
198+
199+
<template>
200+
<Modal
201+
ref="modalRef"
202+
:id="dialogId"
203+
:modalTitle="$t('command_palette.title')"
204+
:modalSubtitle="modalSubtitle"
205+
class="max-w-2xl p-0 overflow-hidden"
206+
@close="handleDialogClose"
207+
>
208+
<div class="-mx-6 -mt-6">
209+
<p :id="descriptionId" class="sr-only">
210+
{{ $t('command_palette.instructions') }}
211+
</p>
212+
<p :id="statusId" class="sr-only" role="status" aria-live="polite">
213+
{{ statusMessage }}
214+
</p>
215+
216+
<div class="border-b border-border px-4 py-4 sm:px-6">
217+
<button
218+
v-if="isLanguageView"
219+
type="button"
220+
class="mb-3 inline-flex items-center gap-2 rounded-md px-2 py-1 font-mono text-xs text-fg-muted transition-colors duration-150 hover:text-fg focus-visible:outline-accent/70"
221+
@click="handleBack"
222+
>
223+
<span class="i-lucide:arrow-left rtl-flip inline-block h-3.5 w-3.5" aria-hidden="true" />
224+
{{ $t('command_palette.back') }}
225+
</button>
226+
<label :for="inputId" class="sr-only">{{ $t('command_palette.input_label') }}</label>
227+
<InputBase
228+
:id="inputId"
229+
ref="inputRef"
230+
v-model="query"
231+
type="search"
232+
:placeholder="$t('command_palette.placeholder')"
233+
no-correct
234+
size="large"
235+
class="w-full"
236+
:aria-describedby="inputDescribedBy"
237+
:aria-controls="resultsId"
238+
/>
239+
</div>
240+
241+
<div
242+
:id="resultsId"
243+
class="max-h-[60vh] overflow-y-auto px-2 py-3 sm:px-3"
244+
:aria-label="$t('command_palette.results_label')"
245+
role="region"
246+
>
247+
<p v-if="!hasResults" class="px-3 py-6 text-center text-sm text-fg-muted">
248+
{{ $t('command_palette.empty') }}
249+
</p>
250+
<p v-if="!hasResults && trimmedQuery" class="px-3 pb-6 text-center text-sm text-fg-subtle">
251+
{{ $t('command_palette.empty_search_hint', { query: trimmedQuery }) }}
252+
</p>
253+
254+
<div v-else class="flex flex-col gap-3">
255+
<section
256+
v-for="group in groupedCommands"
257+
:key="group.id"
258+
:aria-labelledby="`${dialogId}-group-${group.id}`"
259+
>
260+
<h3
261+
:id="`${dialogId}-group-${group.id}`"
262+
class="px-3 pb-1 text-xs font-mono uppercase tracking-wide text-fg-subtle"
263+
>
264+
{{ group.label }}
265+
</h3>
266+
267+
<ul class="m-0 flex list-none flex-col gap-1 p-0">
268+
<li v-for="command in group.items" :key="command.id">
269+
<button
270+
type="button"
271+
class="w-full cursor-pointer rounded-lg border border-transparent px-3 py-3 text-start transition-colors duration-150 hover:border-border hover:bg-bg-subtle focus-visible:outline-accent/70"
272+
:class="
273+
activeIndex === (commandIndexMap.get(command.id) ?? -1)
274+
? 'border-border bg-bg-subtle'
275+
: ''
276+
"
277+
data-command-item="true"
278+
:aria-current="command.active ? 'true' : undefined"
279+
@click="void handleCommandSelect(command)"
280+
@focus="activeIndex = commandIndexMap.get(command.id) ?? -1"
281+
@mouseenter="activeIndex = commandIndexMap.get(command.id) ?? -1"
282+
>
283+
<span class="flex items-center gap-3">
284+
<span
285+
class="inline-block h-4 w-4 shrink-0 text-fg-subtle"
286+
:class="command.iconClass"
287+
aria-hidden="true"
288+
/>
289+
<span class="min-w-0 flex-1">
290+
<span class="block truncate text-sm font-medium text-fg">
291+
{{ command.label }}
292+
</span>
293+
</span>
294+
<span
295+
v-if="command.badge"
296+
class="rounded border border-border bg-bg px-2 py-0.5 text-xs font-mono text-fg-muted"
297+
>
298+
{{ command.badge }}
299+
</span>
300+
<span
301+
v-if="command.active"
302+
class="rounded border border-accent/30 bg-accent/10 px-2 py-0.5 text-xs font-mono text-accent"
303+
>
304+
{{ command.activeLabel || $t('command_palette.current') }}
305+
</span>
306+
<span
307+
v-if="command.external"
308+
class="i-lucide:external-link inline-block h-3.5 w-3.5 shrink-0 text-fg-subtle"
309+
aria-hidden="true"
310+
/>
311+
<span v-if="command.external" class="sr-only">
312+
{{ $t('command_palette.links.external') }}
313+
</span>
314+
</span>
315+
</button>
316+
</li>
317+
</ul>
318+
</section>
319+
</div>
320+
</div>
321+
</div>
322+
</Modal>
323+
</template>

0 commit comments

Comments
 (0)