1<template>
2 <DialogRoot :open="isOpen" @update:open="onOpenChange">
3 <DialogPortal>
4 <DialogOverlay
5 class="palette-overlay fixed inset-0 z-[100] bg-black/30 dark:bg-black/60"
6 />
7 <DialogContent
8 class="palette-content fixed left-1/2 top-[10%] z-[100] w-full max-w-[640px] -translate-x-1/2 overflow-hidden rounded-5 bg-surface-base shadow-2xl ring-1 ring-black/[0.06] focus-visible:outline-none dark:ring-white/[0.08]"
9 @open-auto-focus.prevent
10 @close-auto-focus.prevent="restoreFocus"
11 @escape-key-down.prevent="onEscape"
12 @keydown="onKeydown"
13 >
14 <DialogTitle class="sr-only">{{ __("Command Palette") }}</DialogTitle>
15
16 <!-- Collapses to zero height on dismiss, so the input glides up. -->
17 <Transition name="chip">
18 <div v-if="context" class="chip-row grid">
19 <div class="overflow-hidden">
20 <!-- Not a button: removal is backspace-only, advertised by esc's label. -->
21 <Badge class="chip mx-4 mt-3 max-w-[30%]">
22 <span class="shrink-0 text-ink-gray-7">{{
23 context.label
24 }}</span>
25 <span class="shrink-0 text-ink-gray-4">⋅</span>
26 <span class="truncate leading-snug text-ink-gray-5">{{
27 context.title || __("Ticket")
28 }}</span>
29 </Badge>
30 </div>
31 </div>
32 </Transition>
33
34 <!-- px-2 + size sm puts the glass on the row icons' axis, and the caret on
35 their titles' axis (8 + 8 = 16, 8 + 32 = 40). -->
36 <div
37 ref="inputWrapperRef"
38 class="flex items-center border-b border-outline-gray-1 bg-surface-base px-2 py-2"
39 >
40 <button
41 v-if="stepLabel"
42 type="button"
43 class="text-base-semibold ms-2 flex shrink-0 items-center gap-2 py-1 text-ink-gray-7"
44 @click="back"
45 >
46 {{ stepLabel }}
47 <LucideChevronRight class="size-3 text-ink-gray-4" />
48 </button>
49 <FormControl
50 type="text"
51 :model-value="query"
52 :placeholder="placeholder"
53 variant="ghost"
54 size="sm"
55 class="w-full !bg-surface-base"
56 spellcheck="false"
57 :aria-label="
58 context
59 ? __('Search commands for {0}', [context.label])
60 : __('Search commands')
61 "
62 role="combobox"
63 aria-expanded="true"
64 aria-controls="command-palette-list"
65 :aria-activedescendant="activeOptionId"
66 @update:model-value="onQueryChange"
67 >
68 <template #prefix>
69 <LucideSearch class="size-3.5 shrink-0 text-ink-gray-4" />
70 </template>
71 </FormControl>
72 </div>
73
74 <!-- Announces what no row can: result count, and list changes under debounce. -->
75 <div class="sr-only" aria-live="polite">{{ resultAnnouncement }}</div>
76
77 <div
78 id="command-palette-list"
79 ref="listRef"
80 role="listbox"
81 class="max-h-[380px] min-h-[7rem] overflow-y-auto py-2"
82 >
83 <!-- Keyed by level: drill in/out fades, typing never re-keys. -->
84 <Transition name="level" mode="out-in">
85 <div :key="levelKey">
86 <template v-if="flatItems.length">
87 <div
88 v-for="(group, groupIndex) in renderGroups"
89 :key="`${groupIndex}:${group.title}`"
90 role="group"
91 :aria-label="group.title || __('Ticket')"
92 >
93 <div
94 v-if="group.title"
95 class="px-4 pb-1 pt-2 text-xs font-medium text-ink-gray-4"
96 >
97 {{ group.title }}
98 </div>
99 <div
100 v-for="row in group.items"
101 :id="optionId(row.index)"
102 :key="`${group.title}:${row.command.id}`"
103 role="option"
104 :aria-selected="row.index === activeIndex"
105 class="cursor-pointer px-2"
106 :data-active="row.index === activeIndex"
107 @mousemove="onRowHover(row.command)"
108 @click="run(row.command)"
109 >
110 <CommandPaletteRow
111 :command="row.command"
112 :active="row.index === activeIndex"
113 />
114 </div>
115 </div>
116 </template>
117
118 <!-- presentation: a listbox must not hold a non-option child. -->
119 <div
120 v-else
121 role="presentation"
122 class="flex flex-col items-center py-12 text-ink-gray-4"
123 >
124 <component
125 :is="
126 isLoading
127 ? LucideLoaderCircle
128 : query
129 ? LucideSearchX
130 : LucideSearch
131 "
132 class="mb-2.5 size-8 opacity-40"
133 :class="{ 'animate-spin': isLoading }"
134 />
135 <span class="text-base">{{ emptyMessage }}</span>
136 </div>
137 </div>
138 </Transition>
139 </div>
140
141 <div
142 class="flex items-center gap-4 border-t border-outline-gray-1 px-4 py-2.5"
143 >
144 <span class="flex items-center gap-1.5 text-xs text-ink-gray-4">
145 <ShortcutKey keys="↑ ↓" />
146 {{ __("Navigate") }}
147 </span>
148 <span class="flex items-center gap-1.5 text-xs text-ink-gray-4">
149 <ShortcutKey keys="↵" />
150 {{ __("Select") }}
151 </span>
152 <!-- No ⌫ hint for the chip: esc's label already says "Remove context". -->
153 <span
154 v-if="stepLabel"
155 class="flex items-center gap-1.5 text-xs text-ink-gray-4"
156 >
157 <ShortcutKey keys="⌫" />
158 {{ __("Back") }}
159 </span>
160 <span
161 class="ms-auto flex items-center gap-1.5 text-xs text-ink-gray-4"
162 >
163 <ShortcutKey keys="esc" />
164 {{ escapeLabel }}
165 </span>
166 </div>
167 </DialogContent>
168 </DialogPortal>
169 </DialogRoot>
170</template>
171
172<script setup lang="ts">
173import { __ } from "@/translation";
174import { Badge, FormControl, useKeyboardShortcut } from "frappe-ui";
175import {
176 DialogContent,
177 DialogOverlay,
178 DialogPortal,
179 DialogRoot,
180 DialogTitle,
181} from "reka-ui";
182import { computed, nextTick, ref, watch } from "vue";
183
184import ShortcutKey from "@opendesk/shared/ShortcutKey.vue";
185import LucideChevronRight from "~icons/lucide/chevron-right";
186import LucideLoaderCircle from "~icons/lucide/loader-circle";
187import LucideSearch from "~icons/lucide/search";
188import LucideSearchX from "~icons/lucide/search-x";
189import CommandPaletteRow from "./CommandPaletteRow.vue";
190import type { Command } from "./paletteTypes";
191import {
192 back,
193 breadcrumb,
194 closePalette,
195 context,
196 depth,
197 dismissContext,
198 flatItems,
199 groups,
200 isLoading,
201 isOpen,
202 onQueryChange,
203 openPalette,
204 query,
205 resetPalette,
206 run,
207} from "./useCommandPalette";
208
209// FormControl doesn't expose its native input, so we grab it from the DOM.
210const inputWrapperRef = ref<HTMLElement | null>(null);
211const inputRef = computed(
212 () => inputWrapperRef.value?.querySelector("input") ?? null
213);
214const listRef = ref<HTMLElement | null>(null);
215const activeIndex = ref(0);
216const keyboardNav = ref(false);
217let focusBeforeOpen: HTMLElement | null = null;
218
219let activeRow: Command | null = null;
220
221function rememberActive() {
222 activeRow = flatItems.value[activeIndex.value] ?? null;
223}
224
225/** Rows paired with their flat index — highlighting compares indexes, not
226 * object identity, which a "Recent" clone shares with its original. */
227const renderGroups = computed(() => {
228 let index = 0;
229 return groups.value.map((group) => ({
230 title: group.title,
231 items: group.items.map((command) => ({ command, index: index++ })),
232 }));
233});
234
235const optionId = (index: number) => `command-palette-option-${index}`;
236const activeOptionId = computed(() =>
237 flatItems.value.length ? optionId(activeIndex.value) : undefined
238);
239
240/** Per-level: an empty drill-down is not "No commands found". */
241const emptyMessage = computed(() => {
242 if (isLoading.value) return __("Searching…");
243 if (query.value) return __('No results for "{0}"', query.value);
244 if (stepLabel.value) return __("Nothing left in {0}", stepLabel.value);
245 return __("No commands found");
246});
247
248const resultAnnouncement = computed(() => {
249 if (isLoading.value) return __("Searching…");
250 if (!flatItems.value.length) return emptyMessage.value;
251 return __("{0} results", String(flatItems.value.length));
252});
253
254const stepLabel = computed(() => (depth.value ? breadcrumb.value.at(-1) : ""));
255
256// Re-keys only on level/chip changes, never on typing.
257const levelKey = computed(() => `${depth.value}:${context.value ? "c" : ""}`);
258// Esc peels one layer at a time, so the label tracks what it will actually do.
259const escapeLabel = computed(() => {
260 if (query.value) return __("Clear");
261 if (stepLabel.value) return __("Back");
262 return context.value ? __("Remove context") : __("Close");
263});
264const placeholder = computed(() =>
265 stepLabel.value ? __("Search…") : __("Search tickets or type a command…")
266);
267
268/** Bound to the dialog, not the input, so keys still work with focus on the back
269 * button. Enter on a button must not double up with its own click. */
270function onKeydown(event: KeyboardEvent) {
271 const onButton = (event.target as HTMLElement | null)?.closest?.("button");
272 if (event.key === "Enter" && onButton) return;
273 // Ctrl+N/P are what readline-shaped hands reach for, and Raycast honours them.
274 const down =
275 event.key === "ArrowDown" || (event.ctrlKey && event.key === "n");
276 const up = event.key === "ArrowUp" || (event.ctrlKey && event.key === "p");
277 if (down) {
278 event.preventDefault();
279 moveActive(1);
280 } else if (up) {
281 event.preventDefault();
282 moveActive(-1);
283 } else if (event.key === "Home") {
284 event.preventDefault();
285 setActive(0);
286 } else if (event.key === "End") {
287 event.preventDefault();
288 setActive(flatItems.value.length - 1);
289 } else if (event.key === "Enter") {
290 event.preventDefault();
291 const command = flatItems.value[activeIndex.value];
292 if (command) run(command);
293 } else if (
294 event.key === "ArrowRight" &&
295 !(event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) &&
296 caretAtEnd()
297 ) {
298 // → drills in, mirroring ⌫ back — only at the caret's end so editing stays
299 // native, and never on childless rows: running is Enter's job.
300 const command = flatItems.value[activeIndex.value];
301 if (command?.children) {
302 event.preventDefault();
303 run(command);
304 }
305 } else if (event.key === "Backspace" && !query.value) {
306 // One layer per press, outermost last: step, then context.
307 if (depth.value) {
308 event.preventDefault();
309 back();
310 } else if (context.value) {
311 event.preventDefault();
312 dismissContext();
313 }
314 }
315}
316
317function moveActive(delta: number) {
318 setActive(activeIndex.value + delta);
319}
320
321function caretAtEnd(): boolean {
322 const input = inputRef.value;
323 return (
324 !!input &&
325 input.selectionStart === input.selectionEnd &&
326 input.selectionEnd === input.value.length
327 );
328}
329
330function setActive(index: number) {
331 keyboardNav.value = true;
332 const last = flatItems.value.length - 1;
333 activeIndex.value = Math.min(Math.max(index, 0), last);
334 rememberActive();
335 scrollActiveIntoView();
336}
337
338// Arrowing scrolls rows under a still cursor; that hover must not steal the highlight.
339function onRowHover(command: Command) {
340 if (keyboardNav.value) {
341 keyboardNav.value = false;
342 return;
343 }
344 activeIndex.value = flatItems.value.indexOf(command);
345 rememberActive();
346}
347
348/**
349 * Esc peels one layer at a time: the query, then the step, then the context,
350 * then close.
351 */
352function onEscape() {
353 if (query.value) onQueryChange("");
354 else if (depth.value) back();
355 else if (context.value) dismissContext();
356 else closePalette();
357}
358
359function onOpenChange(open: boolean) {
360 isOpen.value = open;
361}
362
363/** Only when the close left focus nowhere — a command's own focus move (saved
364 * reply → composer) must win. */
365function restoreFocus() {
366 const target = focusBeforeOpen;
367 focusBeforeOpen = null;
368 if (!target?.isConnected) return;
369 if (document.activeElement !== document.body) return;
370 target.focus();
371}
372
373function scrollActiveIntoView() {
374 nextTick(() => {
375 listRef.value
376 ?.querySelector('[data-active="true"]')
377 ?.scrollIntoView({ block: "nearest" });
378 });
379}
380
381// Late search results rebuild `groups`; follow the highlighted row to its new
382// index or Enter runs whatever landed on top. Matched on group+id — recomputes
383// re-create every object, and a "Recent" clone shares its original's id.
384watch(groups, () => {
385 const index = activeRow
386 ? flatItems.value.findIndex(
387 (command) =>
388 command.id === activeRow!.id && command.group === activeRow!.group
389 )
390 : -1;
391 activeIndex.value = Math.max(index, 0);
392 rememberActive();
393});
394
395// Reset on open, not close — resetting on close revived the dismissed chip
396// mid-fade. Close-side focus restore lives in @close-auto-focus, which fires
397// after the exit animation, when a command's own focus move is known.
398watch(isOpen, (open) => {
399 if (!open) return;
400 resetPalette();
401 // No DialogTrigger, so nothing restores focus on its own.
402 focusBeforeOpen = document.activeElement as HTMLElement | null;
403 activeIndex.value = 0;
404 rememberActive();
405 nextTick(() => inputRef.value?.focus());
406});
407
408// Drilling into a sub-list swaps the whole list out; refocus for the next query.
409watch(depth, () => nextTick(() => inputRef.value?.focus()));
410
411// frappe-ui's useKeyboardShortcut: the local one suppresses bindings in inputs
412// and dialogs, so Cmd+K couldn't open from a filter box. ProseMirror keeps its
413// own Mod-k (insert link), so bow out there. `allowInDialog` is only for closing
414// from the palette's own dialog: opening over another one (Settings) would stack
415// the palette, and the popovers its commands open, above that dialog.
416useKeyboardShortcut({
417 combo: "Mod+K",
418 description: __("Open command palette"),
419 group: __("General"),
420 allowInInput: true,
421 allowInDialog: true,
422 enabled: () => {
423 const focused = document.activeElement;
424 if (focused?.closest?.(".ProseMirror")) return false;
425 return isOpen.value || !focused?.closest?.('[role="dialog"]');
426 },
427 handler: () => (isOpen.value ? closePalette() : openPalette()),
428});
429</script>
430
431<style scoped>
432/* Native inputs fall back to the OS "Field" color (white) unless cleared explicitly. */
433:deep(input[data-slot="control"]) {
434 background-color: transparent;
435}
436
437/* Open carries the scale and lift; close is a plain fade so dismissal feels fast. */
438.palette-content[data-state="open"] {
439 animation: palette-in 160ms cubic-bezier(0.16, 1, 0.3, 1);
440}
441
442.palette-content[data-state="closed"] {
443 animation: palette-out 110ms ease-in;
444}
445
446.palette-overlay[data-state="open"] {
447 animation: fade-in 160ms ease-out;
448}
449
450.palette-overlay[data-state="closed"] {
451 animation: fade-out 110ms ease-in;
452}
453
454@keyframes palette-in {
455 from {
456 opacity: 0;
457 transform: translate(-50%, -6px) scale(0.98);
458 }
459 to {
460 opacity: 1;
461 transform: translate(-50%, 0) scale(1);
462 }
463}
464
465@keyframes palette-out {
466 from {
467 opacity: 1;
468 }
469 to {
470 opacity: 0;
471 }
472}
473
474@keyframes fade-in {
475 from {
476 opacity: 0;
477 }
478 to {
479 opacity: 1;
480 }
481}
482
483@keyframes fade-out {
484 from {
485 opacity: 1;
486 }
487 to {
488 opacity: 0;
489 }
490}
491
492/* grid-template-rows animates to intrinsic height; max-height guesswork cannot. */
493.chip-row {
494 grid-template-rows: 1fr;
495 transition: grid-template-rows 180ms cubic-bezier(0.16, 1, 0.3, 1);
496}
497
498.chip-enter-active .chip,
499.chip-leave-active .chip {
500 transition: opacity 140ms ease, transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
501}
502
503.chip-enter-from,
504.chip-leave-to {
505 grid-template-rows: 0fr;
506}
507
508.chip-enter-from .chip,
509.chip-leave-to .chip {
510 opacity: 0;
511 transform: translateY(-4px) scale(0.96);
512}
513
514/* Enter-only: a leave fade kept old rows visible while Enter targeted the new
515 level, so a fast double-Enter fired a command the user couldn't see. */
516.level-enter-active {
517 transition: opacity 100ms ease, transform 100ms ease;
518}
519
520.level-enter-from {
521 opacity: 0;
522 transform: translateY(4px);
523}
524
525@media (prefers-reduced-motion: reduce) {
526 .palette-content,
527 .palette-overlay,
528 .chip-row,
529 .chip,
530 .level-enter-active {
531 animation: none !important;
532 transition: none !important;
533 }
534}
535</style>
536