diff --git a/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts b/vue/src/components/CommandPaletteComponent/CommandPaletteComponent.spec.ts
new file mode 100644
index 0000000..ceaecda
--- /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 0000000..0a8a3e5
--- /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 0000000..49289d3
--- /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 e7e787b..9dd3210 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 016db97..f4595c8 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 a0b414c..a3fd9f3 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 21d07bc..29fd1a1 100644
--- a/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue
+++ b/vue/src/containers/LayoutContainer/SideNavigationComponent/MainComponent/MainComponent.vue
@@ -30,15 +30,10 @@