|
| 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