Skip to content

Commit 0fee863

Browse files
chore: use modal manager for command palette (#374)
1 parent a177626 commit 0fee863

File tree

4 files changed

+161
-142
lines changed

4 files changed

+161
-142
lines changed
Lines changed: 0 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,145 +1,14 @@
11
<script lang="ts">
22
import { shortcuts } from '$lib/actions/shortcut.js';
3-
import CloseButton from '$lib/components/CloseButton/CloseButton.svelte';
4-
import CommandPaletteItem from '$lib/components/CommandPalette/CommandPaletteItem.svelte';
5-
import Icon from '$lib/components/Icon/Icon.svelte';
6-
import Input from '$lib/components/Input/Input.svelte';
7-
import Modal from '$lib/components/Modal/Modal.svelte';
8-
import ModalBody from '$lib/components/Modal/ModalBody.svelte';
9-
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
10-
import ModalHeader from '$lib/components/Modal/ModalHeader.svelte';
11-
import Stack from '$lib/components/Stack/Stack.svelte';
12-
import Text from '$lib/components/Text/Text.svelte';
133
import { commandPaletteManager } from '$lib/services/command-palette-manager.svelte';
14-
import { t } from '$lib/services/translation.svelte.js';
15-
import type { TranslationProps } from '$lib/types.js';
16-
import { mdiArrowDown, mdiArrowUp, mdiKeyboardEsc, mdiKeyboardReturn, mdiMagnify } from '@mdi/js';
17-
18-
type Props = {
19-
translations?: TranslationProps<
20-
'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'
21-
>;
22-
};
23-
24-
let { translations }: Props = $props();
25-
26-
let inputElement = $state<HTMLInputElement | null>(null);
274
285
const handleOpen = () => commandPaletteManager.open();
29-
const handleClose = () => commandPaletteManager.close();
30-
const handleUp = (event: KeyboardEvent) => handleNavigate(event, 'up');
31-
const handleDown = (event: KeyboardEvent) => handleNavigate(event, 'down');
32-
const handleSelect = (event: KeyboardEvent) => handleNavigate(event, 'select');
33-
const handleNavigate = async (event: KeyboardEvent, direction: 'up' | 'down' | 'select') => {
34-
if (!commandPaletteManager.isOpen) {
35-
return;
36-
}
37-
38-
event.preventDefault();
39-
40-
switch (direction) {
41-
case 'up': {
42-
commandPaletteManager.up();
43-
break;
44-
}
45-
46-
case 'down': {
47-
commandPaletteManager.down();
48-
break;
49-
}
50-
51-
case 'select': {
52-
await commandPaletteManager.select();
53-
break;
54-
}
55-
}
56-
};
576
</script>
587

598
<svelte:window
609
use:shortcuts={[
6110
{ shortcut: { key: 'k', meta: true }, onShortcut: handleOpen },
6211
{ shortcut: { key: 'k', ctrl: true }, onShortcut: handleOpen },
6312
{ shortcut: { key: '/' }, preventDefault: true, onShortcut: handleOpen },
64-
{ shortcut: { key: 'ArrowUp' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleUp },
65-
{ shortcut: { key: 'ArrowDown' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleDown },
66-
{ shortcut: { key: 'k', ctrl: true }, ignoreInputFields: false, onShortcut: handleUp },
67-
{ shortcut: { key: 'k', meta: true }, ignoreInputFields: false, onShortcut: handleUp },
68-
{ shortcut: { key: 'j', ctrl: true }, ignoreInputFields: false, onShortcut: handleDown },
69-
{ shortcut: { key: 'j', meta: true }, ignoreInputFields: false, onShortcut: handleDown },
70-
{ shortcut: { key: 'Enter' }, ignoreInputFields: false, onShortcut: handleSelect },
71-
{ shortcut: { key: 'Escape' }, onShortcut: handleClose },
7213
]}
7314
/>
74-
75-
{#if commandPaletteManager.isOpen}
76-
<Modal size="large" onClose={handleClose} closeOnBackdropClick>
77-
<ModalHeader>
78-
<div class="flex place-items-center gap-1">
79-
<Input
80-
bind:ref={inputElement}
81-
bind:value={commandPaletteManager.query}
82-
placeholder={t('search_placeholder', translations)}
83-
leadingIcon={mdiMagnify}
84-
tabindex={1}
85-
/>
86-
<div>
87-
<CloseButton onclick={() => commandPaletteManager.close()} class="md:hidden" />
88-
</div>
89-
</div>
90-
</ModalHeader>
91-
<ModalBody>
92-
<Stack gap={2}>
93-
{#if commandPaletteManager.query}
94-
{#if commandPaletteManager.results.length === 0}
95-
<Text>{t('search_no_results', translations)}</Text>
96-
{/if}
97-
{:else if commandPaletteManager.recentItems.length > 0}
98-
<Text>{t('search_recently_used', translations)}</Text>
99-
{:else}
100-
<Text>{t('command_palette_prompt_default', translations)}</Text>
101-
{/if}
102-
103-
{#if commandPaletteManager.results.length > 0}
104-
<div class="flex flex-col">
105-
{#each commandPaletteManager.results as item, i (i)}
106-
<CommandPaletteItem
107-
{item}
108-
selected={commandPaletteManager.selectedIndex === i}
109-
onRemove={commandPaletteManager.query ? undefined : () => commandPaletteManager.remove(i)}
110-
onSelect={() => commandPaletteManager.select(i)}
111-
/>
112-
{/each}
113-
</div>
114-
{/if}
115-
</Stack>
116-
</ModalBody>
117-
<ModalFooter>
118-
<div class="flex w-full justify-around">
119-
<div class="flex gap-4">
120-
<div class="flex place-items-center gap-1">
121-
<span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
122-
<Icon icon={mdiKeyboardReturn} size="1rem" />
123-
</span>
124-
<Text size="small">to select</Text>
125-
</div>
126-
127-
<div class="flex place-items-center gap-1">
128-
<span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
129-
<Icon icon={mdiArrowUp} size="1rem" />
130-
<Icon icon={mdiArrowDown} size="1rem" />
131-
</span>
132-
<Text size="small">to navigate</Text>
133-
</div>
134-
135-
<div class="flex place-items-center gap-1">
136-
<span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
137-
<Icon icon={mdiKeyboardEsc} size="1rem" />
138-
</span>
139-
<Text size="small">to close</Text>
140-
</div>
141-
</div>
142-
</div>
143-
</ModalFooter>
144-
</Modal>
145-
{/if}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<script lang="ts">
2+
import { shortcuts } from '$lib/actions/shortcut.js';
3+
import CloseButton from '$lib/components/CloseButton/CloseButton.svelte';
4+
import CommandPaletteItem from '$lib/components/CommandPalette/CommandPaletteItem.svelte';
5+
import Icon from '$lib/components/Icon/Icon.svelte';
6+
import Input from '$lib/components/Input/Input.svelte';
7+
import Modal from '$lib/components/Modal/Modal.svelte';
8+
import ModalBody from '$lib/components/Modal/ModalBody.svelte';
9+
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
10+
import ModalHeader from '$lib/components/Modal/ModalHeader.svelte';
11+
import Stack from '$lib/components/Stack/Stack.svelte';
12+
import Text from '$lib/components/Text/Text.svelte';
13+
import {
14+
commandPaletteManager,
15+
type CommandPaletteTranslations,
16+
} from '$lib/services/command-palette-manager.svelte.js';
17+
import { t } from '$lib/services/translation.svelte.js';
18+
import { mdiArrowDown, mdiArrowUp, mdiKeyboardEsc, mdiKeyboardReturn, mdiMagnify } from '@mdi/js';
19+
20+
type Props = {
21+
onClose: () => void;
22+
translations?: CommandPaletteTranslations;
23+
};
24+
25+
const handleUp = (event: KeyboardEvent) => handleNavigate(event, 'up');
26+
const handleDown = (event: KeyboardEvent) => handleNavigate(event, 'down');
27+
const handleSelect = (event: KeyboardEvent) => handleNavigate(event, 'select');
28+
const handleNavigate = async (event: KeyboardEvent, direction: 'up' | 'down' | 'select') => {
29+
event.preventDefault();
30+
31+
switch (direction) {
32+
case 'up': {
33+
commandPaletteManager.up();
34+
break;
35+
}
36+
37+
case 'down': {
38+
commandPaletteManager.down();
39+
break;
40+
}
41+
42+
case 'select': {
43+
await commandPaletteManager.select();
44+
break;
45+
}
46+
}
47+
};
48+
49+
const { onClose, translations }: Props = $props();
50+
</script>
51+
52+
<svelte:window
53+
use:shortcuts={[
54+
{ shortcut: { key: 'ArrowUp' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleUp },
55+
{ shortcut: { key: 'ArrowDown' }, preventDefault: false, ignoreInputFields: false, onShortcut: handleDown },
56+
{ shortcut: { key: 'k', ctrl: true }, ignoreInputFields: false, onShortcut: handleUp },
57+
{ shortcut: { key: 'k', meta: true }, ignoreInputFields: false, onShortcut: handleUp },
58+
{ shortcut: { key: 'j', ctrl: true }, ignoreInputFields: false, onShortcut: handleDown },
59+
{ shortcut: { key: 'j', meta: true }, ignoreInputFields: false, onShortcut: handleDown },
60+
{ shortcut: { key: 'Enter' }, ignoreInputFields: false, onShortcut: handleSelect },
61+
]}
62+
/>
63+
64+
<Modal size="large" {onClose} closeOnBackdropClick>
65+
<ModalHeader>
66+
<div class="flex place-items-center gap-1">
67+
<Input
68+
bind:value={commandPaletteManager.query}
69+
placeholder={t('search_placeholder', translations)}
70+
leadingIcon={mdiMagnify}
71+
tabindex={1}
72+
/>
73+
<div>
74+
<CloseButton onclick={() => commandPaletteManager.close()} class="md:hidden" />
75+
</div>
76+
</div>
77+
</ModalHeader>
78+
<ModalBody>
79+
<Stack gap={2}>
80+
{#if commandPaletteManager.query}
81+
{#if commandPaletteManager.results.length === 0}
82+
<Text>{t('search_no_results', translations)}</Text>
83+
{/if}
84+
{:else if commandPaletteManager.recentItems.length > 0}
85+
<Text>{t('search_recently_used', translations)}</Text>
86+
{:else}
87+
<Text>{t('command_palette_prompt_default', translations)}</Text>
88+
{/if}
89+
90+
{#if commandPaletteManager.results.length > 0}
91+
<div class="flex flex-col">
92+
{#each commandPaletteManager.results as item, i (i)}
93+
<CommandPaletteItem
94+
{item}
95+
selected={commandPaletteManager.selectedIndex === i}
96+
onRemove={commandPaletteManager.query ? undefined : () => commandPaletteManager.remove(i)}
97+
onSelect={() => commandPaletteManager.select(i)}
98+
/>
99+
{/each}
100+
</div>
101+
{/if}
102+
</Stack>
103+
</ModalBody>
104+
<ModalFooter>
105+
<div class="flex w-full justify-around">
106+
<div class="flex gap-4">
107+
<div class="flex place-items-center gap-1">
108+
<span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
109+
<Icon icon={mdiKeyboardReturn} size="1rem" />
110+
</span>
111+
<Text size="small">to select</Text>
112+
</div>
113+
114+
<div class="flex place-items-center gap-1">
115+
<span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
116+
<Icon icon={mdiArrowUp} size="1rem" />
117+
<Icon icon={mdiArrowDown} size="1rem" />
118+
</span>
119+
<Text size="small">to navigate</Text>
120+
</div>
121+
122+
<div class="flex place-items-center gap-1">
123+
<span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
124+
<Icon icon={mdiKeyboardEsc} size="1rem" />
125+
</span>
126+
<Text size="small">to close</Text>
127+
</div>
128+
</div>
129+
</div>
130+
</ModalFooter>
131+
</Modal>

packages/ui/src/lib/services/command-palette-manager.svelte.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { goto } from '$app/navigation';
2+
import CommandPaletteModal from '$lib/internal/CommandPaletteModal.svelte';
3+
import type { TranslationProps } from '$lib/types.js';
4+
import { modalManager } from './modal-manager.svelte.js';
25

36
export type CommandItem = {
47
icon: string;
@@ -7,7 +10,11 @@ export type CommandItem = {
710
title: string;
811
description?: string;
912
text: string;
10-
} & ({ href: string } | { action: () => void });
13+
} & ({ href: string } | { action: () => void | Promise<void> });
14+
15+
export type CommandPaletteTranslations = TranslationProps<
16+
'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'
17+
>;
1118

1219
export const asText = (...items: unknown[]) => {
1320
return items
@@ -31,36 +38,46 @@ const isMatch = (item: CommandItem, query: string): boolean => {
3138

3239
class CommandPaletteManager {
3340
isEnabled = $state(false);
34-
isOpen = $state(false);
3541
query = $state('');
3642
selectedIndex = $state(0);
37-
private normalizedQuery = $derived(this.query.toLowerCase());
43+
#normalizedQuery = $derived(this.query.toLowerCase());
44+
#modal?: { close: () => Promise<void> };
45+
#translations: CommandPaletteTranslations = {};
3846

3947
items: CommandItem[] = [];
40-
filteredItems = $derived(this.items.filter((item) => isMatch(item, this.normalizedQuery)).slice(0, 100));
48+
filteredItems = $derived(this.items.filter((item) => isMatch(item, this.#normalizedQuery)).slice(0, 100));
4149
recentItems = $state<CommandItem[]>([]);
4250
results = $derived(this.query ? this.filteredItems : this.recentItems);
4351

4452
enable() {
4553
this.isEnabled = true;
4654
}
4755

56+
setTranslations(translations: CommandPaletteTranslations = {}) {
57+
this.#translations = translations;
58+
}
59+
4860
async open() {
49-
if (!this.isEnabled || this.isOpen) {
61+
if (!this.isEnabled) {
5062
return;
5163
}
5264

5365
this.selectedIndex = 0;
54-
this.isOpen = true;
66+
const { close, onClose } = modalManager.open(CommandPaletteModal, { translations: this.#translations });
67+
this.#modal = { close };
68+
void onClose.then(() => this.handleClose());
5569
}
5670

5771
close() {
58-
if (!this.isEnabled || !this.isOpen) {
72+
if (!this.#modal) {
5973
return;
6074
}
6175

76+
return this.#modal.close();
77+
}
78+
79+
handleClose() {
6280
this.query = '';
63-
this.isOpen = false;
6481
}
6582

6683
async select(selectedIndex?: number) {
@@ -84,7 +101,7 @@ class CommandPaletteManager {
84101
await selected.action();
85102
}
86103

87-
this.close();
104+
await this.close();
88105
}
89106

90107
remove(index: number) {
@@ -101,7 +118,6 @@ class CommandPaletteManager {
101118

102119
reset() {
103120
this.items = [];
104-
this.isOpen = false;
105121
this.query = '';
106122
}
107123

packages/ui/src/routes/+layout.svelte

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@
7979
}
8080
8181
commandPaletteManager.enable();
82+
commandPaletteManager.setTranslations({
83+
command_palette_prompt_default: 'Quickly find components, links, and commands',
84+
});
8285
</script>
8386

8487
<AppShell>
@@ -142,4 +145,4 @@
142145
</section>
143146
</AppShell>
144147

145-
<CommandPalette translations={{ command_palette_prompt_default: 'Quickly find components, links, and commands' }} />
148+
<CommandPalette />

0 commit comments

Comments
 (0)