From aeb1c878b806be34d057b077926abadceff98aca Mon Sep 17 00:00:00 2001 From: Samuel Monroe Date: Wed, 11 Mar 2026 14:28:10 +0100 Subject: [PATCH] Add Command Palette component with global shortcut and route integration - Implemented `CommandPaletteComponent` with keyboard navigation, route filtering, and localization support. - Integrated global `Cmd/Ctrl + K` shortcut for activation. - Updated `LayoutContainer` to include the Command Palette. - Extracted reusable route entry utilities and updated existing navigation logic for consistency. - Added tests for route entry extraction, palette functionality, and shortcut behavior. --- .../CommandPaletteComponent.spec.ts | 252 ++++++++++++++++ .../CommandPaletteComponent.vue | 274 ++++++++++++++++++ .../CommandPaletteComponent/index.ts | 1 + .../LayoutContainer/LayoutContainer.mdx | 6 +- .../LayoutContainer/LayoutContainer.spec.ts | 10 + .../LayoutContainer/LayoutContainer.vue | 2 + .../MainComponent/MainComponent.vue | 84 +----- .../LayoutContainer/routeEntries.spec.ts | 178 ++++++++++++ vue/src/containers/storybook/overview.mdx | 1 + vue/src/demo/router.ts | 4 + vue/src/lib.ts | 1 + vue/src/locales/en/navigation.json | 8 +- vue/src/router/index.ts | 1 + vue/src/router/routeEntries.ts | 125 ++++++++ vue/src/router/router.mdx | 23 +- vue/src/router/types.ts | 1 + 16 files changed, 880 insertions(+), 91 deletions(-) create mode 100644 vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts create mode 100644 vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue create mode 100644 vue/src/components/CommandPaletteComponent/index.ts create mode 100644 vue/src/containers/LayoutContainer/routeEntries.spec.ts create mode 100644 vue/src/router/routeEntries.ts diff --git a/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts new file mode 100644 index 00000000..ceaecda3 --- /dev/null +++ b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts @@ -0,0 +1,252 @@ +/* eslint-disable vue/one-component-per-file */ +import { describe, it, expect, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router' +import { defineComponent } from 'vue' +import CommandPaletteComponent from './CommandPaletteComponent.vue' +import { createLibI18n } from '@/locales' + +const StubView = defineComponent({ + template: '
', +}) + +const PrimeDialogStub = defineComponent({ + name: 'PrimeDialogStub', + props: { + visible: { + type: Boolean, + default: false, + }, + }, + emits: ['update:visible'], + template: ` +
+
+
+
+ `, +}) + +const PrimeInputTextStub = defineComponent({ + name: 'PrimeInputTextStub', + inheritAttrs: false, + props: { + modelValue: { + type: String, + default: '', + }, + }, + emits: ['update:modelValue', 'keydown'], + template: ` + + `, +}) + +const PrimeListboxStub = defineComponent({ + name: 'PrimeListboxStub', + inheritAttrs: false, + props: { + options: { + type: Array, + default: () => [], + }, + modelValue: { + type: String, + default: null, + }, + optionLabel: { + type: String, + default: 'label', + }, + optionValue: { + type: String, + default: 'value', + }, + }, + emits: ['update:modelValue'], + template: ` + + `, +}) + +function createTestRouter() { + const routes: RouteRecordRaw[] = [ + { + path: '/', + redirect: '/home', + }, + { + path: '/home', + name: 'home', + component: StubView, + meta: { + wefa: { + title: 'Home', + showInCommandPalette: true, + }, + }, + }, + { + path: '/showcase', + name: 'showcase', + component: StubView, + meta: { + wefa: { + title: 'Showcase', + showInCommandPalette: true, + }, + }, + }, + { + path: '/playground', + name: 'playground', + component: StubView, + meta: { + wefa: { + title: 'Playground', + showInCommandPalette: true, + section: 'Examples', + }, + }, + }, + { + path: '/users/:id', + name: 'userDetails', + component: StubView, + meta: { + wefa: { + title: 'User Details', + showInCommandPalette: true, + }, + }, + }, + { + path: '/hidden', + name: 'hidden', + component: StubView, + meta: { + wefa: { + title: 'Hidden', + }, + }, + }, + ] + + return createRouter({ + history: createMemoryHistory(), + routes, + }) +} + +describe('CommandPaletteComponent', () => { + let router: ReturnType + + beforeEach(async () => { + router = createTestRouter() + await router.push('/home') + await router.isReady() + }) + + function mountComponent() { + return mount(CommandPaletteComponent, { + global: { + plugins: [router, createLibI18n({})], + stubs: { + Dialog: PrimeDialogStub, + InputText: PrimeInputTextStub, + Listbox: PrimeListboxStub, + }, + }, + }) + } + + it('opens with Cmd+K and Ctrl+K', async () => { + const wrapper = mountComponent() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(true) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true })) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(true) + }) + + it('filters actions by search query', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + await wrapper.get('[data-test="command-palette-search"]').setValue('play') + + expect(wrapper.text()).toContain('Playground') + expect(wrapper.text()).not.toContain('Home') + }) + + it('navigates with arrow keys and executes selected action with Enter', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + const searchInput = wrapper.get('[data-test="command-palette-search"]') + await searchInput.trigger('keydown', { key: 'ArrowDown' }) + await searchInput.trigger('keydown', { key: 'Enter' }) + await flushPromises() + + expect(router.currentRoute.value.path).toBe('/showcase') + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + }) + + it('closes on Escape', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + await wrapper.get('[data-test="command-palette-search"]').trigger('keydown', { key: 'Escape' }) + await flushPromises() + + expect(wrapper.find('[data-test="command-palette-search"]').exists()).toBe(false) + }) + + it('only shows routes marked for command palette and skips dynamic routes', async () => { + const wrapper = mountComponent() + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })) + await flushPromises() + + expect(wrapper.text()).toContain('Home') + expect(wrapper.text()).toContain('Showcase') + expect(wrapper.text()).toContain('Playground') + expect(wrapper.text()).not.toContain('Hidden') + expect(wrapper.text()).not.toContain('User Details') + }) +}) diff --git a/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue new file mode 100644 index 00000000..0a8a3e58 --- /dev/null +++ b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.vue @@ -0,0 +1,274 @@ + + + diff --git a/vue/src/components/CommandPaletteComponent/index.ts b/vue/src/components/CommandPaletteComponent/index.ts new file mode 100644 index 00000000..49289d30 --- /dev/null +++ b/vue/src/components/CommandPaletteComponent/index.ts @@ -0,0 +1 @@ +export { default as CommandPaletteComponent } from './CommandPaletteComponent.vue' diff --git a/vue/src/containers/LayoutContainer/LayoutContainer.mdx b/vue/src/containers/LayoutContainer/LayoutContainer.mdx index e7e787b9..9dd32105 100644 --- a/vue/src/containers/LayoutContainer/LayoutContainer.mdx +++ b/vue/src/containers/LayoutContainer/LayoutContainer.mdx @@ -12,6 +12,7 @@ import * as LayoutContainerStories from './LayoutContainer.stories' - desktop side navigation - mobile drawer navigation - route-aware breadcrumb +- global command palette (`Cmd/Ctrl + K`) - child route content through `` The stories below focus on router metadata setup, because navigation links are generated directly from the router tree. @@ -20,7 +21,7 @@ The stories below focus on router metadata setup, because navigation links are g ### Wefa Route Metadata -Uses `meta.wefa.showInNavigation` and `meta.wefa.section`. +Uses `meta.wefa.showInNavigation`, `meta.wefa.section`, and `meta.wefa.showInCommandPalette`. @@ -34,6 +35,7 @@ Uses `meta.wefa.showInNavigation` and `meta.wefa.section`. title: 'Dashboard', icon: 'pi pi-chart-line', showInNavigation: true, + showInCommandPalette: true, section: 'Insights', }, }, @@ -109,6 +111,7 @@ const routes = [ title: 'Home', icon: 'pi pi-home', showInNavigation: true, + showInCommandPalette: true, }, }, }, @@ -121,6 +124,7 @@ const routes = [ title: 'Showcase', icon: 'pi pi-bars', showInNavigation: true, + showInCommandPalette: true, }, }, }, diff --git a/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts b/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts index 016db972..f4595c83 100644 --- a/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts +++ b/vue/src/containers/LayoutContainer/LayoutContainer.spec.ts @@ -59,6 +59,11 @@ const ConfirmDialogStub = defineComponent({ template: '
', }) +const CommandPaletteStub = defineComponent({ + name: 'CommandPaletteComponent', + template: '
', +}) + describe('LayoutContainer', () => { it('passes project title to side and mobile navigation components', () => { const wrapper = mount(LayoutContainer, { @@ -73,6 +78,7 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) @@ -94,6 +100,7 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) @@ -114,11 +121,13 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) expect(wrapper.find('[data-test="router-view"]').exists()).toBe(true) + expect(wrapper.find('[data-test="command-palette"]').exists()).toBe(true) expect(wrapper.find('[data-test="toast"]').exists()).toBe(true) expect(wrapper.find('[data-test="confirm-dialog"]').exists()).toBe(true) }) @@ -137,6 +146,7 @@ describe('LayoutContainer', () => { RouterView: RouterViewStub, Toast: ToastStub, ConfirmDialog: ConfirmDialogStub, + CommandPaletteComponent: CommandPaletteStub, }, }, }) diff --git a/vue/src/containers/LayoutContainer/LayoutContainer.vue b/vue/src/containers/LayoutContainer/LayoutContainer.vue index a0b414c0..a3fd9f39 100644 --- a/vue/src/containers/LayoutContainer/LayoutContainer.vue +++ b/vue/src/containers/LayoutContainer/LayoutContainer.vue @@ -23,6 +23,7 @@ + @@ -32,6 +33,7 @@ import SideNavigationComponent from '@/containers/LayoutContainer/SideNavigationComponent/SideNavigationComponent.vue' import { AutoroutedBreadcrumb } from '@/components/AutoroutedBreadcrumb' import MobileNavigationComponent from '@/containers/LayoutContainer/MobileNavigationComponent/MobileNavigationComponent.vue' +import { CommandPaletteComponent } from '@/components/CommandPaletteComponent' import { setupDepthTracker } from '../helpers' import Toast from 'primevue/toast' import ConfirmDialog from 'primevue/confirmdialog' diff --git a/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue b/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue index 21d07bc9..29fd1a1e 100644 --- a/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue +++ b/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue @@ -30,15 +30,10 @@