import { useDebouncedRef } from '@basmilius/common'; import type { FluxCommandSource, FluxCommandSourceItem, FluxCommandSubAction } from '@flux-ui/types'; import { computed, nextTick, ref, type Ref, shallowRef, unref, watch } from 'vue'; export type CommandPaletteResultItem = { readonly globalIndex: number; readonly sourceKey: string; readonly sourceLabel: string; readonly item: FluxCommandSourceItem; }; export type CommandPaletteGroup = { readonly sourceKey: string; readonly sourceLabel: string; readonly startIndex: number; readonly items: CommandPaletteResultItem[]; }; export function useCommandPalette(params: { readonly sources: Ref; readonly itemRefs: Readonly | null | undefined>>; }): { readonly search: Ref; readonly activeTab: Ref; readonly highlightedIndex: Ref; readonly isLoading: Ref; readonly isTransitioningBack: Ref; readonly subActionTarget: Ref; readonly filteredItems: Ref; readonly groupedItems: Ref; readonly subActions: Ref; readonly activeTabSource: Ref; readonly tabs: Ref; readonly totalItems: Ref; setSearch: (value: string) => void; setActiveTab: (key: string | null) => void; enterSubActions: (item: FluxCommandSourceItem) => void; onKeyNavigate: (evt: KeyboardEvent, onClose: () => void, onActivate: (item: FluxCommandSourceItem) => void) => void; reset: () => void; } { const search = ref(''); const activeTab = ref(null); const highlightedIndex = ref(-1); const subActionTarget = ref(null); const isKeyboardNav = ref(false); const isLoading = ref(false); const isTransitioningBack = ref(false); const savedState = ref<{ readonly search: string; readonly highlightedIndex: number; } | null>(null); const asyncResults = shallowRef>(new Map()); const debouncedSearch = useDebouncedRef(search, 300) as unknown as Ref; let fetchGeneration = 0; const filteredItems = computed(() => { const query = unref(search).toLowerCase().trim(); const tab = unref(activeTab); const sources = unref(params.sources); const asyncMap = unref(asyncResults); const results: CommandPaletteResultItem[] = []; for (const source of sources) { if (tab && source.key !== tab) { continue; } if (source.fetchSearch) { const items = query ? asyncMap.get(source.key) : source.items; if (items) { for (const item of items) { results.push({ globalIndex: results.length, sourceKey: source.key, sourceLabel: source.label, item }); } } continue; } for (const item of source.items) { if (query && !item.label.toLowerCase().includes(query) && !(item.subLabel?.toLowerCase().includes(query) ?? false)) { continue; } results.push({ globalIndex: results.length, sourceKey: source.key, sourceLabel: source.label, item }); } } return results; }); const groupedItems = computed(() => { const items = unref(filteredItems); const groups = new Map(); for (const result of items) { if (!groups.has(result.sourceKey)) { groups.set(result.sourceKey, { sourceKey: result.sourceKey, sourceLabel: result.sourceLabel, startIndex: result.globalIndex, items: [] }); } (groups.get(result.sourceKey)!.items as CommandPaletteResultItem[]).push(result); } return Array.from(groups.values()); }); const tabs = computed(() => unref(params.sources).filter(source => source.tab)); const activeTabSource = computed(() => { const tab = unref(activeTab); if (!tab) { return null; } return unref(tabs).find(source => source.key === tab) ?? null; }); const subActions = computed(() => { const actions = unref(subActionTarget)?.subActions ?? []; const query = unref(search).toLowerCase().trim(); if (!query) { return actions; } return actions.filter(action => action.label.toLowerCase().includes(query)); }); const totalItems = computed(() => { if (unref(subActionTarget)) { return unref(subActions).length; } return unref(filteredItems).length; }); function setSearch(value: string): void { search.value = value; highlightedIndex.value = value.trim() ? 0 : -1; if (value.trim() && !unref(subActionTarget) && unref(params.sources).some(s => s.fetchSearch)) { isLoading.value = true; } else { isLoading.value = false; } } function setActiveTab(key: string | null): void { const allTabs = unref(tabs); const tabKeys: (string | null)[] = [null, ...allTabs.map(t => t.key)]; isTransitioningBack.value = tabKeys.indexOf(key) < tabKeys.indexOf(unref(activeTab)); activeTab.value = key; search.value = ''; highlightedIndex.value = -1; subActionTarget.value = null; } function selectHighlighted(onClose: () => void, onActivate: (item: FluxCommandSourceItem) => void): void { const index = unref(highlightedIndex); const target = unref(subActionTarget); if (target) { const action = unref(subActions)[index]; if (action) { action.onActivate(); onClose(); } return; } const result = unref(filteredItems)[index]; if (!result) { return; } if (result.item.subActions?.length) { enterSubActions(result.item); } else { result.item.onActivate(); onActivate(result.item); } } function enterSubActions(item: FluxCommandSourceItem): void { savedState.value = {search: unref(search), highlightedIndex: unref(highlightedIndex)}; subActionTarget.value = item; search.value = ''; highlightedIndex.value = 0; } function navigateTab(direction: number): void { const allTabs = unref(tabs); if (allTabs.length === 0) { return; } const tabKeys: (string | null)[] = [null, ...allTabs.map(tab => tab.key)]; const currentIndex = tabKeys.indexOf(unref(activeTab)); const nextIndex = (currentIndex + direction + tabKeys.length) % tabKeys.length; setActiveTab(tabKeys[nextIndex]); } function restoreState(): void { subActionTarget.value = null; const state = unref(savedState); if (state) { search.value = state.search; isKeyboardNav.value = true; highlightedIndex.value = state.highlightedIndex; savedState.value = null; } else { highlightedIndex.value = -1; } } function onKeyNavigate(evt: KeyboardEvent, onClose: () => void, onActivate: (item: FluxCommandSourceItem) => void): void { const total = unref(totalItems); const current = unref(highlightedIndex); switch (evt.key) { case 'ArrowDown': evt.preventDefault(); isKeyboardNav.value = true; highlightedIndex.value = Math.min(total - 1, current + 1); break; case 'ArrowUp': evt.preventDefault(); isKeyboardNav.value = true; highlightedIndex.value = total === 0 ? -1 : Math.max(0, current - 1); break; case 'ArrowLeft': if (!unref(search) && !unref(subActionTarget)) { evt.preventDefault(); navigateTab(-1); } break; case 'ArrowRight': if (!unref(search) && !unref(subActionTarget)) { evt.preventDefault(); navigateTab(1); } break; case 'Enter': evt.preventDefault(); selectHighlighted(onClose, onActivate); break; case 'Escape': evt.preventDefault(); if (unref(subActionTarget)) { restoreState(); } else { onClose(); } break; case 'Backspace': if (!unref(search)) { if (unref(subActionTarget)) { evt.preventDefault(); restoreState(); } else if (unref(activeTab)) { evt.preventDefault(); activeTab.value = null; highlightedIndex.value = -1; } } break; } } function reset(): void { search.value = ''; activeTab.value = null; highlightedIndex.value = -1; subActionTarget.value = null; asyncResults.value = new Map(); isLoading.value = false; fetchGeneration++; } watch(debouncedSearch, async (query) => { if (unref(subActionTarget)) { return; } const trimmed = query.trim(); if (!trimmed) { asyncResults.value = new Map(); isLoading.value = false; return; } const tab = unref(activeTab); const sources = unref(params.sources); const asyncSources = sources.filter(s => { if (!s.fetchSearch) { return false; } return !tab || s.key === tab; }); if (asyncSources.length === 0) { isLoading.value = false; return; } const generation = ++fetchGeneration; isLoading.value = true; try { const fetched = await Promise.all( asyncSources.map(async (source) => ({ key: source.key, items: await source.fetchSearch!(trimmed) })) ); if (generation !== fetchGeneration) { return; } const map = new Map(); for (const {key, items} of fetched) { map.set(key, items); } asyncResults.value = map; } finally { if (generation === fetchGeneration) { isLoading.value = false; } } }); watch(highlightedIndex, (index) => { if (index < 0 || !unref(isKeyboardNav)) { return; } isKeyboardNav.value = false; nextTick(() => unref(params.itemRefs)?.[index]?.$el?.scrollIntoView({block: 'nearest'})); }); watch(totalItems, (total) => { const current = unref(highlightedIndex); if (current >= total) { highlightedIndex.value = Math.max(-1, total - 1); } }); return { search, activeTab, activeTabSource, highlightedIndex, isLoading, isTransitioningBack, subActionTarget, filteredItems, groupedItems, subActions, tabs, totalItems, setSearch, setActiveTab, enterSubActions, onKeyNavigate, reset }; }