Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8dea461
feat(VCommandPalette): add new command palette component
MatthewAry Oct 17, 2025
743c68a
test(VCommandPalette): fix keyboard navigation edge cases
MatthewAry Oct 17, 2025
3806cdd
refactor(VCommandPalette): reorganize slot rendering
MatthewAry Oct 17, 2025
18f246b
test(VCommandPalette): add hotkey functionality tests for item selection
MatthewAry Oct 17, 2025
9bc8d73
refactor(VCommandPalette): Fix lint errors
MatthewAry Oct 17, 2025
cd4d141
Update packages/vuetify/src/labs/VCommandPalette/composables/useComma…
MatthewAry Nov 4, 2025
68460fc
Merge branch 'master' into matthewary/v-command-palette-r1
MatthewAry Nov 7, 2025
d7c80f8
Merge branch 'master' into matthewary/v-command-palette-r1
MatthewAry Nov 12, 2025
6f7a255
feat(VList): add navigationMode and navigationIndex props
MatthewAry Nov 13, 2025
7adc62b
Merge master into branch
MatthewAry Nov 13, 2025
0c3a67f
Merge branch 'temp' into matthewary/v-command-palette-r1
MatthewAry Nov 13, 2025
7624378
Initial Commit
MatthewAry Nov 13, 2025
ad41547
Merge master into branch
MatthewAry Nov 14, 2025
fe157e9
Made changes according to feedback from @johnleider
MatthewAry Nov 14, 2025
8e13563
Incorporate VList Changes
MatthewAry Nov 14, 2025
ee62c7a
Woops
MatthewAry Nov 14, 2025
def9e9c
Rough pass on docs - needs polish
MatthewAry Nov 14, 2025
537315d
Update packages/vuetify/src/components/VList/VList.tsx
MatthewAry Nov 14, 2025
c35d43a
Made changes according to feedback from @johnleider
MatthewAry Nov 14, 2025
5a17d7a
Merge branch 'master' into matthewary/feat-VList-navigation-modes
MatthewAry Nov 18, 2025
53e492a
Implement recommended changes from @J-Sek
MatthewAry Nov 18, 2025
1cacf0f
Add visual feedback as requested by @J-Sek
MatthewAry Nov 18, 2025
97e8682
fix scroll to active
J-Sek Nov 18, 2025
b0d5af7
Address concerns by @J-Sek
MatthewAry Nov 19, 2025
3fe829b
Merge 'matthewary/feat-VList-navigation-modes' into this one
MatthewAry Nov 19, 2025
6c63668
Update packages/vuetify/src/components/VList/VListItem.tsx
MatthewAry Nov 19, 2025
ca8453a
Merge 'matthewary/feat-VList-navigation-modes' into here
MatthewAry Nov 19, 2025
cb19f2d
Scroll Instant
MatthewAry Nov 19, 2025
a53eda2
sync
MatthewAry Nov 19, 2025
a36ea37
Delegate more responsibility to VList
MatthewAry Nov 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/vuetify/src/labs/VCommandPalette/VCommandPalette.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@use '../../styles/tools';

@include tools.layer('components') {
.v-command-palette {
&__list {
// Ensure proper scrolling behavior
overflow-y: auto;

.v-list-item {
// Active/selected state styling
&--active {
background-color: rgba(var(--v-theme-primary), 0.12);

&:hover {
background-color: rgba(var(--v-theme-primary), 0.16);
}
}

// Hover state
&:hover {
background-color: rgba(var(--v-theme-on-surface), 0.08);
}

// Focus visible for keyboard navigation
&:focus-visible {
outline: 2px solid rgb(var(--v-theme-primary));
outline-offset: -2px;
}
}

// Subheader styling
.v-list-subheader {
// Non-selectable, visual only
opacity: 0.7;
user-select: none;
}

// Divider styling
.v-divider {
margin-block: 4px;
}
}
}
}
308 changes: 308 additions & 0 deletions packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
/**
* VCommandPalette Component
*
* A keyboard-driven command palette component that provides a searchable
* dialog interface for executing commands and actions.
*/

// Components
import { VCard, VCardText } from '@/components/VCard'
import { VDialog } from '@/components/VDialog'
import { VList } from '@/components/VList'
import { VTextField } from '@/components/VTextField'

// Composables
import { makeDensityProps, useDensity } from '@/composables/density'
import { makeFilterProps } from '@/composables/filter'
import { useHotkey } from '@/composables/hotkey'
import { useLocale } from '@/composables/locale'
import { useProxiedModel } from '@/composables/proxiedModel'
import { makeThemeProps, provideTheme } from '@/composables/theme'
import { makeTransitionProps } from '@/composables/transition'

// Utilities
import { computed, nextTick, ref, shallowRef, watch, watchEffect } from 'vue'
import { genericComponent, propsFactory, useRender } from '@/util'

// Types
import type { PropType, Ref, VNode } from 'vue'

// Internal
import { provideCommandPaletteContext } from './composables/useCommandPaletteContext'
import { useCommandPaletteNavigation } from './composables/useCommandPaletteNavigation'
import { isActionItem } from './types'

// Types
import type { VCommandPaletteItem as VCommandPaletteItemType } from './types'

// Internal
import { VCommandPaletteItemComponent } from './VCommandPaletteItem'

export const makeVCommandPaletteProps = propsFactory({
// === Model/State ===
modelValue: Boolean,
search: String,

// === Items & Content ===
items: {
type: Array as PropType<VCommandPaletteItemType[]>,
default: () => [],
},

// === Search/Filter Props ===
...makeFilterProps({
filterKeys: ['title', 'subtitle'],
}),

// === UX Props ===
placeholder: String,
hotkey: String,
noDataText: String,

// === Dialog Props (first-class) ===
location: String,
activator: [String, Object],
dialogProps: Object as PropType<Record<string, any>>,

// === Appearance Props ===
...makeThemeProps(),
...makeDensityProps(),
...makeTransitionProps(),
}, 'VCommandPalette')

/**
* VCommandPalette Component
*/
export const VCommandPalette = genericComponent()({
name: 'VCommandPalette',

props: makeVCommandPaletteProps(),

emits: {
'update:modelValue': (value: boolean) => true,
'update:search': (value: string) => true,
'click:item': (item: VCommandPaletteItemType, event: MouseEvent | KeyboardEvent) => true,
},

setup (props, { emit, slots }) {
const { t } = useLocale()

// Dialog state
const isOpen = useProxiedModel(props, 'modelValue')
const searchQuery = useProxiedModel(props, 'search') as Ref<string>

// Theme and density
const { themeClasses } = provideTheme(props)
const { densityClasses } = useDensity(props)

// Refs for focus management
const searchInputRef = ref<InstanceType<typeof VTextField>>()
const dialogRef = ref<InstanceType<typeof VDialog>>()
const previouslyFocusedElement = shallowRef<HTMLElement | null>(null)

/**
* Simple filter implementation for MVP
* Filters items based on search query matching title and subtitle
*/
const filteredItems = computed(() => {
if (!searchQuery.value || !searchQuery.value.trim()) {
return props.items
}

const query = searchQuery.value.toLowerCase()

return props.items.filter(item => {
const titleMatch = 'title' in item && item.title && String(item.title).toLowerCase().includes(query)
const subtitleMatch = 'subtitle' in item && item.subtitle && String(item.subtitle).toLowerCase().includes(query)
return titleMatch || subtitleMatch
})
})

/**
* Initialize navigation composable
*/
const navigation = useCommandPaletteNavigation({
filteredItems,
onItemClick: (item, event) => {
if ('onClick' in item && item.onClick) {
item.onClick(event, item.value)
}
emit('click:item', item, event)
isOpen.value = false
},
isScopeActive: () => isOpen.value,
})

/**
* Provide context for future custom layout support
*/
provideCommandPaletteContext({
items: computed(() => props.items),
filteredItems,
selectedIndex: navigation.selectedIndex,
activeDescendantId: navigation.activeDescendantId,
search: searchQuery,
setSelectedIndex: navigation.setSelectedIndex,
})

/**
* Register global hotkey to toggle palette
*/
if (props.hotkey) {
useHotkey(props.hotkey, () => {
isOpen.value = !isOpen.value
})
}

/**
* Register item-level hotkeys (only when palette is open)
*/
watchEffect(onCleanup => {
if (!isOpen.value) {
return
}

const hotkeyUnsubscribes: Array<() => void> = []

function registerItemHotkeys (items: VCommandPaletteItemType[]) {
items.forEach(item => {
if (isActionItem(item) && item.hotkey) {
const unsubscribe = useHotkey(item.hotkey, event => {
event.preventDefault()
if (item.onClick) {
item.onClick(event as KeyboardEvent, item.value)
}
emit('click:item', item, event as KeyboardEvent)
isOpen.value = false
}, { inputs: true })
hotkeyUnsubscribes.push(unsubscribe)
}
})
}

registerItemHotkeys(props.items)

onCleanup(() => {
hotkeyUnsubscribes.forEach(unsubscribe => unsubscribe?.())
})
})

/**
* Handle dialog open/close for focus management
*/
watch(isOpen, (newValue, oldValue) => {
if (newValue && !oldValue) {
previouslyFocusedElement.value = document.activeElement as HTMLElement | null
searchQuery.value = ''
navigation.reset()

// Auto-select first item
nextTick(() => {
navigation.setSelectedIndex(0)
const input = searchInputRef.value?.$el?.querySelector('input')
if (input) {
input.focus()
}
})
} else if (!newValue && oldValue) {
nextTick(() => {
previouslyFocusedElement.value?.focus({ preventScroll: true })
previouslyFocusedElement.value = null
})
}
})

/**
* Compute merged dialog props
*/
const computedDialogProps = computed(() => {
const baseProps: Record<string, any> = {
modelValue: isOpen.value,
'onUpdate:modelValue': (v: boolean) => {
isOpen.value = v
},
scrollable: true,
...(props.dialogProps || {}),
}

if (props.location) {
baseProps.location = props.location
}
if (props.activator) {
baseProps.activator = props.activator
}

return baseProps
})

useRender((): VNode => (
<VDialog
ref={ dialogRef }
{ ...computedDialogProps.value }
class="v-command-palette"
>
{{
default: () => (
<VCard
class={[
themeClasses.value,
densityClasses.value,
]}
>
{ /* @ts-expect-error slots type is inferred as 'default' only */ }
{ (slots.prepend as any)?.() }

<div class="px-4 py-2">
<VTextField
ref={ searchInputRef }
v-model={ searchQuery.value }
placeholder={ props.placeholder || t('$vuetify.command.search') }
prependInnerIcon="mdi-magnify"
singleLine
hideDetails
variant="solo"
flat
bgColor="transparent"
/>
</div>

<VCardText class="pa-0">
{ filteredItems.value.length > 0 ? (
<VList
key="list"
class="v-command-palette__list"
role="listbox"
aria-label={ `${filteredItems.value.length} options available` }
aria-activedescendant={ navigation.activeDescendantId.value }
>
{ filteredItems.value.map((item, index) => (
<VCommandPaletteItemComponent
key={ `item-${index}` }
item={ item }
index={ index }
isSelected={ navigation.selectedIndex.value === index }
onExecute={ (event: any) => {
navigation.executeSelected(event)
}}
/>
))}
</VList>
) : (
<div key="no-data" class="pa-4 text-center text-disabled">
{ /* @ts-expect-error slots type is inferred as 'default' only */ }
{ (slots['no-data'] as any)?.() || (props.noDataText || t('$vuetify.noDataText')) }
</div>
)}
</VCardText>

{ /* @ts-expect-error slots type is inferred as 'default' only */ }
{ (slots.append as any)?.() }
</VCard>
),
}}
</VDialog>
))
},
})

export type VCommandPalette = InstanceType<typeof VCommandPalette>
Loading