mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-04-24 16:56:14 -04:00
refactor(search): command palette design (#463)
This commit is contained in:
parent
732da08157
commit
bcb98b359c
18 changed files with 576 additions and 386 deletions
68
src/modules/command-palette/command-palette.store.ts
Normal file
68
src/modules/command-palette/command-palette.store.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import _ from 'lodash';
|
||||
import type { PaletteOption } from './command-palette.types';
|
||||
import { useToolStore } from '@/tools/tools.store';
|
||||
import { useFuzzySearch } from '@/composable/fuzzySearch';
|
||||
import { useStyleStore } from '@/stores/style.store';
|
||||
|
||||
import SunIcon from '~icons/mdi/white-balance-sunny';
|
||||
import GithubIcon from '~icons/mdi/github';
|
||||
import BugIcon from '~icons/mdi/bug-outline';
|
||||
|
||||
export const useCommandPaletteStore = defineStore('command-palette', () => {
|
||||
const toolStore = useToolStore();
|
||||
const styleStore = useStyleStore();
|
||||
const searchPrompt = ref('');
|
||||
|
||||
const toolsOptions = toolStore.tools.map(tool => ({
|
||||
...tool,
|
||||
to: tool.path,
|
||||
toolCategory: tool.category,
|
||||
category: 'Tools',
|
||||
}));
|
||||
|
||||
const searchOptions: PaletteOption[] = [
|
||||
...toolsOptions,
|
||||
{
|
||||
name: 'Toggle dark mode',
|
||||
description: 'Toggle dark mode on or off.',
|
||||
action: () => styleStore.toggleDark(),
|
||||
icon: SunIcon,
|
||||
category: 'Actions',
|
||||
keywords: ['dark', 'theme', 'toggle', 'mode', 'light', 'system'],
|
||||
},
|
||||
{
|
||||
name: 'Github repository',
|
||||
href: 'https://github.com/CorentinTh/it-tools',
|
||||
category: 'External',
|
||||
description: 'View the source code of it-tools on Github.',
|
||||
keywords: ['github', 'repo', 'repository', 'source', 'code'],
|
||||
icon: GithubIcon,
|
||||
},
|
||||
{
|
||||
name: 'Report a bug or an issue',
|
||||
description: 'Report a bug or an issue to help improve it-tools.',
|
||||
href: 'https://github.com/CorentinTh/it-tools/issues/new/choose',
|
||||
category: 'Actions',
|
||||
keywords: ['report', 'issue', 'bug', 'problem', 'error'],
|
||||
icon: BugIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const { searchResult } = useFuzzySearch({
|
||||
search: searchPrompt,
|
||||
data: searchOptions,
|
||||
options: {
|
||||
keys: [{ name: 'name', weight: 2 }, 'description', 'keywords', 'category'],
|
||||
threshold: 0.3,
|
||||
},
|
||||
});
|
||||
|
||||
const filteredSearchResult = computed(() =>
|
||||
_.chain(searchResult.value).groupBy('category').mapValues(categoryOptions => _.take(categoryOptions, 5)).value());
|
||||
|
||||
return {
|
||||
filteredSearchResult,
|
||||
searchPrompt,
|
||||
};
|
||||
});
|
13
src/modules/command-palette/command-palette.types.ts
Normal file
13
src/modules/command-palette/command-palette.types.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import type { Component } from 'vue';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
|
||||
export interface PaletteOption {
|
||||
name: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
action?: () => void
|
||||
to?: RouteLocationRaw
|
||||
category: string
|
||||
keywords?: string[]
|
||||
href?: string
|
||||
}
|
137
src/modules/command-palette/command-palette.vue
Normal file
137
src/modules/command-palette/command-palette.vue
Normal file
|
@ -0,0 +1,137 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import _ from 'lodash';
|
||||
import { useCommandPaletteStore } from './command-palette.store';
|
||||
import type { PaletteOption } from './command-palette.types';
|
||||
|
||||
const isModalOpen = ref(false);
|
||||
const inputRef = ref();
|
||||
const router = useRouter();
|
||||
const isMac = computed(() => window.navigator.userAgent.toLowerCase().includes('mac'));
|
||||
|
||||
const commandPaletteStore = useCommandPaletteStore();
|
||||
const { searchPrompt, filteredSearchResult } = storeToRefs(commandPaletteStore);
|
||||
|
||||
const keys = useMagicKeys({
|
||||
passive: false,
|
||||
onEventFired(e) {
|
||||
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.metaKey && e.key === 'k' && e.type === 'keydown') {
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
whenever(isModalOpen, () => inputRef.value?.focus());
|
||||
|
||||
whenever(keys.ctrl_k, open);
|
||||
whenever(keys.meta_k, open);
|
||||
whenever(keys.escape, close);
|
||||
|
||||
function open() {
|
||||
return isModalOpen.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isModalOpen.value = false;
|
||||
}
|
||||
|
||||
const selectedOptionIndex = ref(0);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const { key } = event;
|
||||
const isEnterPressed = key === 'Enter';
|
||||
const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key);
|
||||
const isArrowDown = key === 'ArrowDown';
|
||||
|
||||
if (isArrowUpOrDown) {
|
||||
const increment = isArrowDown ? 1 : -1;
|
||||
const maxIndex = Math.max(_.chain(filteredSearchResult.value).values().flatten().size().value() - 1, 0);
|
||||
|
||||
selectedOptionIndex.value = Math.min(Math.max(selectedOptionIndex.value + increment, 0), maxIndex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterPressed) {
|
||||
const option = _.chain(filteredSearchResult.value)
|
||||
.values()
|
||||
.flatten()
|
||||
.nth(selectedOptionIndex.value)
|
||||
.value();
|
||||
|
||||
activateOption(option);
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionIndex(option: PaletteOption) {
|
||||
return _.chain(filteredSearchResult.value)
|
||||
.values()
|
||||
.flatten()
|
||||
.findIndex(o => o === option)
|
||||
.value();
|
||||
}
|
||||
|
||||
function activateOption(option: PaletteOption) {
|
||||
if (option.action) {
|
||||
option.action();
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.to) {
|
||||
router.push(option.to);
|
||||
close();
|
||||
}
|
||||
|
||||
if (option.href) {
|
||||
window.open(option.href, '_blank');
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex-1>
|
||||
<c-button w-full important:justify-start @click="isModalOpen = true">
|
||||
<span flex items-center gap-3 op-40>
|
||||
|
||||
<icon-mdi-search />
|
||||
Search...
|
||||
|
||||
<span hidden flex-1 border border-current border-op-40 rounded border-solid px-5px py-3px sm:inline>
|
||||
{{ isMac ? 'Cmd' : 'Ctrl' }} + K
|
||||
</span>
|
||||
</span>
|
||||
</c-button>
|
||||
|
||||
<c-modal v-model:open="isModalOpen" class="palette-modal" shadow-xl important:max-w-650px important:pa-12px @keydown="handleKeydown">
|
||||
<c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable />
|
||||
|
||||
<div v-for="(options, category) in filteredSearchResult" :key="category">
|
||||
<div ml-3 mt-3 text-sm font-bold text-primary op-60>
|
||||
{{ category }}
|
||||
</div>
|
||||
<command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" />
|
||||
</div>
|
||||
</c-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.c-input-text {
|
||||
font-size: 18px;
|
||||
|
||||
::v-deep(.input-wrapper) {
|
||||
padding: 4px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.c-modal--overlay {
|
||||
align-items: flex-start !important;
|
||||
padding-top: 80px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,36 @@
|
|||
<script setup lang="ts">
|
||||
import type { PaletteOption } from '../command-palette.types';
|
||||
|
||||
const props = withDefaults(defineProps<{ option: PaletteOption; selected?: boolean }>(), {
|
||||
selected: false,
|
||||
});
|
||||
const emit = defineEmits(['activated']);
|
||||
const { option } = toRefs(props);
|
||||
|
||||
const { selected } = toRefs(props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="option"
|
||||
:aria-selected="selected"
|
||||
:class="{
|
||||
'text-white': selected,
|
||||
'bg-primary': selected,
|
||||
}"
|
||||
w-full flex cursor-pointer items-center overflow-hidden rounded pa-3 transition hover:bg-primary hover:text-white
|
||||
@click="() => emit('activated', option)"
|
||||
>
|
||||
<component :is="option.icon" v-if="option.icon" mr-3 h-30px w-30px shrink-0 op-50 />
|
||||
|
||||
<div flex-1 overflow-hidden>
|
||||
<div truncate font-bold lh-tight op-90>
|
||||
{{ option.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="option.description" truncate lh-tight op-60>
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
Loading…
Add table
Add a link
Reference in a new issue