313 lines
10 KiB
JavaScript
313 lines
10 KiB
JavaScript
import { createVNode as _createVNode, createElementVNode as _createElementVNode, mergeProps as _mergeProps, normalizeClass as _normalizeClass, normalizeStyle as _normalizeStyle } from "vue";
|
|
// Styles
|
|
import "./VCommandPalette.css";
|
|
|
|
// Components
|
|
import { VCommandPaletteSymbol } from "./shared.js";
|
|
import { VCommandPaletteItem } from "./VCommandPaletteItem.js";
|
|
import { VDialog } from "../../components/VDialog/index.js";
|
|
import { makeVDialogProps } from "../../components/VDialog/VDialog.js";
|
|
import { VList } from "../../components/VList/index.js";
|
|
import { VSheet } from "../../components/VSheet/index.js";
|
|
import { VTextField } from "../../components/VTextField/index.js"; // Composables
|
|
import { useCommandPaletteNavigation } from "./composables/useCommandPaletteNavigation.js";
|
|
import { makeDensityProps } from "../../composables/density.js";
|
|
import { makeFilterProps, useFilter } from "../../composables/filter.js";
|
|
import { useHotkey } from "../../composables/hotkey/index.js";
|
|
import { useLocale } from "../../composables/locale.js";
|
|
import { useProxiedModel } from "../../composables/proxiedModel.js"; // Utilities
|
|
import { computed, nextTick, onUnmounted, provide, ref, shallowRef, toRef, watch, watchEffect } from 'vue';
|
|
import { isActionItem } from "./types.js";
|
|
import { genericComponent, omit, propsFactory, useRender } from "../../util/index.js"; // Types
|
|
export const makeVCommandPaletteProps = propsFactory({
|
|
modelValue: Boolean,
|
|
search: String,
|
|
items: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
default: '$vuetify.command.search'
|
|
},
|
|
inputIcon: {
|
|
type: String,
|
|
default: '$search'
|
|
},
|
|
hotkey: String,
|
|
closeOnSelect: {
|
|
type: Boolean,
|
|
default: true
|
|
},
|
|
noDataText: {
|
|
type: String,
|
|
default: '$vuetify.noDataText'
|
|
},
|
|
listProps: Object,
|
|
...makeFilterProps({
|
|
filterKeys: ['title', 'subtitle']
|
|
}),
|
|
...makeDensityProps(),
|
|
...omit(makeVDialogProps({
|
|
maxWidth: 500
|
|
}), ['modelValue'])
|
|
}, 'VCommandPalette');
|
|
export const VCommandPalette = genericComponent()({
|
|
name: 'VCommandPalette',
|
|
props: makeVCommandPaletteProps(),
|
|
emits: {
|
|
'update:modelValue': value => true,
|
|
'update:search': value => true,
|
|
'click:item': (item, event) => true,
|
|
'before-select': payload => true
|
|
},
|
|
setup(props, {
|
|
emit,
|
|
slots
|
|
}) {
|
|
const {
|
|
t
|
|
} = useLocale();
|
|
const isOpen = useProxiedModel(props, 'modelValue');
|
|
const searchQuery = useProxiedModel(props, 'search');
|
|
const searchInputRef = ref();
|
|
const dialogRef = ref();
|
|
const previouslyFocusedElement = shallowRef(null);
|
|
const internalItems = computed(() => props.items.map((item, index) => ({
|
|
value: index,
|
|
type: item.type,
|
|
raw: item,
|
|
...('title' in item && {
|
|
title: item.title
|
|
}),
|
|
...('subtitle' in item && {
|
|
subtitle: item.subtitle
|
|
})
|
|
})));
|
|
const {
|
|
filteredItems: filterResult
|
|
} = useFilter(props, internalItems, searchQuery);
|
|
const filteredItems = computed(() => filterResult.value.map(item => item.raw));
|
|
const itemsForList = computed(() => {
|
|
return filteredItems.value.map((item, idx) => ({
|
|
...item,
|
|
value: idx
|
|
}));
|
|
});
|
|
function executeItem(item, event) {
|
|
if ('onClick' in item && item.onClick) {
|
|
item.onClick(event, item.value);
|
|
}
|
|
emit('click:item', item, event);
|
|
if (!isActionItem(item) || !props.closeOnSelect) return;
|
|
let shouldClose = true;
|
|
emit('before-select', {
|
|
item,
|
|
event,
|
|
preventDefault: () => {
|
|
shouldClose = false;
|
|
}
|
|
});
|
|
if (shouldClose) {
|
|
isOpen.value = false;
|
|
}
|
|
}
|
|
const navigation = useCommandPaletteNavigation({
|
|
filteredItems,
|
|
onItemClick: (item, event) => {
|
|
executeItem(item, event);
|
|
}
|
|
});
|
|
provide(VCommandPaletteSymbol, {
|
|
items: computed(() => props.items),
|
|
filteredItems,
|
|
selectedIndex: navigation.selectedIndex,
|
|
search: searchQuery,
|
|
setSelectedIndex: navigation.setSelectedIndex
|
|
});
|
|
|
|
// Register main hotkey with cleanup - using toRef for reactivity
|
|
const hotkeyUnsubscribe = useHotkey(toRef(props, 'hotkey'), () => {
|
|
isOpen.value = !isOpen.value;
|
|
});
|
|
watchEffect(onCleanup => {
|
|
if (!isOpen.value) {
|
|
return;
|
|
}
|
|
const hotkeyUnsubscribes = [];
|
|
function registerItemHotkeys(items) {
|
|
items.forEach(item => {
|
|
if (isActionItem(item) && item.hotkey) {
|
|
const unsubscribe = useHotkey(item.hotkey, event => {
|
|
event.preventDefault();
|
|
executeItem(item, event);
|
|
}, {
|
|
inputs: true
|
|
});
|
|
hotkeyUnsubscribes.push(unsubscribe);
|
|
}
|
|
});
|
|
}
|
|
registerItemHotkeys(props.items);
|
|
onCleanup(() => {
|
|
hotkeyUnsubscribes.forEach(unsubscribe => unsubscribe?.());
|
|
});
|
|
});
|
|
function findNextSelectableIndex(startIndex, direction) {
|
|
const items = filteredItems.value;
|
|
if (items.length === 0) return -1;
|
|
let index = startIndex;
|
|
const maxIterations = items.length;
|
|
for (let i = 0; i < maxIterations; i++) {
|
|
index += direction;
|
|
if (index >= items.length) index = 0;
|
|
if (index < 0) index = items.length - 1;
|
|
if (isActionItem(items[index])) {
|
|
return index;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
function handleSearchKeydown(e) {
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
{
|
|
e.preventDefault();
|
|
const nextIndex = findNextSelectableIndex(navigation.selectedIndex.value, 1);
|
|
if (nextIndex !== -1) {
|
|
navigation.setSelectedIndex(nextIndex);
|
|
}
|
|
break;
|
|
}
|
|
case 'ArrowUp':
|
|
{
|
|
e.preventDefault();
|
|
const prevIndex = findNextSelectableIndex(navigation.selectedIndex.value, -1);
|
|
if (prevIndex !== -1) {
|
|
navigation.setSelectedIndex(prevIndex);
|
|
}
|
|
break;
|
|
}
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
navigation.executeSelected(e);
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
isOpen.value = false;
|
|
break;
|
|
}
|
|
}
|
|
watch(isOpen, (newValue, oldValue) => {
|
|
if (newValue && !oldValue) {
|
|
previouslyFocusedElement.value = document.activeElement;
|
|
searchQuery.value = '';
|
|
navigation.reset();
|
|
|
|
// Use requestAnimationFrame to ensure DOM is fully rendered
|
|
nextTick(() => {
|
|
requestAnimationFrame(() => {
|
|
if (searchInputRef.value && typeof searchInputRef.value.focus === 'function') {
|
|
searchInputRef.value.focus();
|
|
}
|
|
});
|
|
});
|
|
} else if (!newValue && oldValue) {
|
|
nextTick(() => {
|
|
previouslyFocusedElement.value?.focus({
|
|
preventScroll: true
|
|
});
|
|
previouslyFocusedElement.value = null;
|
|
});
|
|
}
|
|
}, {
|
|
immediate: true
|
|
});
|
|
onUnmounted(() => {
|
|
hotkeyUnsubscribe();
|
|
previouslyFocusedElement.value = null;
|
|
});
|
|
useRender(() => {
|
|
const dialogProps = VDialog.filterProps(omit(props, ['modelValue', 'class', 'style']));
|
|
return _createVNode(VDialog, _mergeProps({
|
|
"ref": dialogRef,
|
|
"class": "v-command-palette",
|
|
"modelValue": isOpen.value,
|
|
"onUpdate:modelValue": $event => isOpen.value = $event,
|
|
"scrollable": true
|
|
}, dialogProps), {
|
|
activator: slots.activator,
|
|
default: () => _createVNode(VSheet, {
|
|
"class": _normalizeClass(props.class),
|
|
"style": _normalizeStyle(props.style)
|
|
}, {
|
|
default: () => [slots.prepend?.(), _createElementVNode("div", {
|
|
"class": "v-command-palette__input-container"
|
|
}, [slots.input?.() ?? _createVNode(VTextField, {
|
|
"ref": searchInputRef,
|
|
"modelValue": searchQuery.value,
|
|
"onUpdate:modelValue": $event => searchQuery.value = $event,
|
|
"density": props.density,
|
|
"placeholder": t(props.placeholder),
|
|
"prependInnerIcon": props.inputIcon,
|
|
"autocomplete": "off",
|
|
"autofocus": true,
|
|
"singleLine": true,
|
|
"hideDetails": true,
|
|
"variant": "solo",
|
|
"flat": true,
|
|
"bgColor": "transparent",
|
|
"onKeydown": handleSearchKeydown
|
|
}, {
|
|
'append-inner': slots['input.append-inner']
|
|
})]), _createElementVNode("div", {
|
|
"class": "v-command-palette__content"
|
|
}, [filteredItems.value.length > 0 ? _createVNode(VList, _mergeProps({
|
|
"key": "list",
|
|
"class": "v-command-palette__list",
|
|
"density": props.density,
|
|
"items": itemsForList.value,
|
|
"itemType": "type",
|
|
"itemProps": true,
|
|
"activatable": true
|
|
}, props.listProps, {
|
|
"navigationStrategy": "track",
|
|
"navigationIndex": navigation.selectedIndex.value,
|
|
"onUpdate:navigationIndex": navigation.setSelectedIndex
|
|
}), {
|
|
prepend: slots['list.prepend'],
|
|
subheader: slots['list.subheader'],
|
|
item: ({
|
|
props: itemProps
|
|
}) => slots.item?.({
|
|
item: itemProps,
|
|
index: itemProps.index
|
|
}) ?? _createVNode(VCommandPaletteItem, {
|
|
"key": `item-${itemProps.index}`,
|
|
"item": itemProps,
|
|
"index": itemProps.index,
|
|
"onExecute": event => navigation.execute(itemProps.index, event)
|
|
}, {
|
|
prepend: slots['item.prepend'] ? () => slots['item.prepend']?.({
|
|
item: itemProps,
|
|
index: itemProps.index
|
|
}) : undefined,
|
|
title: slots['item.title'] ? () => slots['item.title']?.({
|
|
item: itemProps,
|
|
index: itemProps.index
|
|
}) : undefined,
|
|
append: slots['item.append'] ? () => slots['item.append']?.({
|
|
item: itemProps,
|
|
index: itemProps.index
|
|
}) : undefined
|
|
})
|
|
}) : _createElementVNode("div", {
|
|
"key": "no-data",
|
|
"class": "v-command-palette__no-data"
|
|
}, [slots['no-data']?.() || t(props.noDataText)])]), slots.append?.()]
|
|
})
|
|
});
|
|
});
|
|
}
|
|
});
|
|
//# sourceMappingURL=VCommandPalette.js.map
|