Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions packages/api-generator/src/locale/en/VCommandPalette.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
},
"slots": {
"prepend": "Content to render above the search input, inside the command palette card. Useful for headers, breadcrumbs, or instructions.",
"input": "Custom search input field. Replaces the default **v-text-field**. Useful for providing a completely custom search implementation.",
"input": "Custom search input field. Replaces the default **v-text-field**. Bind the slot's **props** to the input to keep keyboard navigation and screen reader announcements, and connect it to **v-model:search** for filtering.",
"input.append-inner": "Content appended inside the search input field. Useful for adding icons or buttons within the input.",
"append": "Content to render below the items list, inside the command palette card. Useful for footers, keyboard shortcut hints, or additional actions.",
"no-data": "Custom content to display when no items match the search query. Replaces the default no-data message.",
"list.prepend": "Content to render above the items list, inside the command palette card.",
"list.subheader": "Slot for customizing the rendering of subheader items in the list.",
"item": "Replaces the rendering of each item. Bind **item** to a **v-list-item** (`v-bind=\"item\"`) to keep the keyboard highlight and screen reader announcements.",
"item": "Replaces the rendering of each item. Bind the slot's **props** to a **v-list-item** to keep the keyboard highlight, click handling and screen reader announcements.",
"item.prepend": "Content to display before each item's title.",
"item.title": "Slot for customizing the title of each item.",
"item.append": "Content to display after each item's title, replaces hotkey (if provided)."
Expand Down
40 changes: 40 additions & 0 deletions packages/docs/src/examples/v-command-palette/slot-input.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<template>
<div class="text-center my-3">
<v-btn text="Open" @click="model = !model"></v-btn>

<v-command-palette
v-model="model"
v-model:search="search"
:items="items"
max-width="500"
>
<template v-slot:input="{ props }">
<v-text-field
v-model="search"
v-bind="props"
autocomplete="off"
class="pa-2"
placeholder="Jump to a page..."
prepend-inner-icon="mdi-compass-outline"
variant="underlined"
clearable
hide-details
></v-text-field>
</template>
</v-command-palette>
</div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'

const model = shallowRef(false)
const search = shallowRef('')

const items = ref([
{ title: 'Dashboard', prependIcon: 'mdi-view-dashboard-outline', value: 'dashboard' },
{ title: 'Projects', prependIcon: 'mdi-folder-outline', value: 'projects' },
{ title: 'Team', prependIcon: 'mdi-account-group-outline', value: 'team' },
{ title: 'Settings', prependIcon: 'mdi-cog-outline', value: 'settings' },
])
</script>
31 changes: 31 additions & 0 deletions packages/docs/src/examples/v-command-palette/slot-item.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<template>
<div class="text-center my-3">
<v-btn text="Open" @click="model = !model"></v-btn>

<v-command-palette
v-model="model"
:items="items"
max-width="500"
>
<template v-slot:item="{ item, props }">
<v-list-item v-bind="props" lines="two">
<template v-slot:append>
<v-chip :text="item.status" size="x-small" label></v-chip>
</template>
</v-list-item>
</template>
</v-command-palette>
</div>
</template>

<script setup>
import { ref, shallowRef } from 'vue'

const model = shallowRef(false)

const items = ref([
{ title: 'Fix login redirect', subtitle: 'Opened by Ana', status: 'Open', value: 101 },
{ title: 'Update dependencies', subtitle: 'Opened by Kim', status: 'Review', value: 102 },
{ title: 'Dark mode for charts', subtitle: 'Opened by Lee', status: 'Merged', value: 103 },
])
</script>
14 changes: 13 additions & 1 deletion packages/docs/src/pages/en/components/command-palettes.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ By default, selecting an actionable item closes the palette. Use **close-on-sele

The command palette provides several slots for customizing the display of items and other elements.

#### Input

Use the **#input** slot to replace the search field. Bind the slot's **props** to your input for keyboard navigation and screen reader support, and connect it to **v-model:search** so typing filters the items.

<ExamplesExample file="v-command-palette/slot-input" />

#### Item

Use the **#item** slot to render each item yourself. It receives the original **item**, its **index**, and **props** to bind to a `v-list-item`, which keep the keyboard highlight, click handling and screen reader announcements.

<ExamplesExample file="v-command-palette/slot-item" />

#### Item prepend

Use the **#item.prepend** slot to customize the prepend area of each item. This slot receives the current **item** and **index** as slot props.
Expand Down Expand Up @@ -122,4 +134,4 @@ The `v-command-palette` component follows accessibility best practices:
- Returns focus to the previously focused element on close
- Supports full keyboard navigation without mouse interaction

A custom input provided through the **#input** slot doesn't receive these attributes or the keyboard handling.
When replacing the search field through the **#input** slot, bind the slot's **props** to your input so it keeps the keyboard handling and these attributes. The same applies to the **#item** slot: bind its **props** to a `v-list-item`.
68 changes: 49 additions & 19 deletions packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import './VCommandPalette.scss'

// Components
import { VCommandPaletteSymbol } from './shared'
import { listItemKeys, VCommandPaletteSymbol } from './shared'
import { VCommandPaletteItem } from './VCommandPaletteItem'
import { VDialog } from '@/components/VDialog'
import { makeVDialogProps } from '@/components/VDialog/VDialog'
Expand All @@ -21,7 +21,17 @@ import { useProxiedModel } from '@/composables/proxiedModel'
// Utilities
import { computed, nextTick, onUnmounted, provide, ref, shallowRef, toRef, useId, watch, watchEffect } from 'vue'
import { isActionItem } from './types'
import { convertToUnit, genericComponent, getActiveElement, isFunction, omit, propsFactory, useRender } from '@/util'
import {
convertToUnit,
focusableChildren,
genericComponent,
getActiveElement,
isFunction,
omit,
pick,
propsFactory,
useRender,
} from '@/util'

// Types
import type { PropType, Ref } from 'vue'
Expand Down Expand Up @@ -74,12 +84,12 @@ export type VCommandPaletteSlots = {
default: never
prepend: never
append: never
input: never
input: { props: Record<string, unknown> }
'input.append-inner': never
'no-data': never
'list.prepend': never
'list.subheader': VListChildrenSlots<any>['subheader']
item: { item: VCommandPaletteItemType, index: number }
item: { item: VCommandPaletteItemType, index: number, props: Record<string, unknown> }
'item.prepend': { item: VCommandPaletteItemType, index: number }
'item.title': { item: VCommandPaletteItemType, index: number }
'item.append': { item: VCommandPaletteItemType, index: number }
Expand All @@ -106,6 +116,7 @@ export const VCommandPalette = genericComponent<VCommandPaletteSlots>()({
const isOpen = useProxiedModel(props, 'modelValue')
const searchQuery = useProxiedModel(props, 'search') as Ref<string>
const searchInputRef = ref<VTextField>()
const inputContainerRef = ref<HTMLElement>()
const dialogRef = ref<VDialog>()
const listRef = ref<VList>()
const listId = `v-command-palette-list-${useId()}`
Expand Down Expand Up @@ -261,6 +272,8 @@ export const VCommandPalette = genericComponent<VCommandPaletteSlots>()({
requestAnimationFrame(() => {
if (searchInputRef.value && isFunction(searchInputRef.value.focus)) {
searchInputRef.value.focus()
} else if (inputContainerRef.value) {
focusableChildren(inputContainerRef.value)[0]?.focus()
}
})
})
Expand All @@ -280,6 +293,14 @@ export const VCommandPalette = genericComponent<VCommandPaletteSlots>()({
useRender(() => {
const dialogProps = VDialog.filterProps(omit(props, ['modelValue', 'class', 'style']))
const hasResults = filteredItems.value.length > 0
const inputProps = {
role: 'combobox',
'aria-expanded': hasResults,
'aria-controls': hasResults ? listId : undefined,
'aria-autocomplete': 'list',
'aria-activedescendant': listRef.value?.activeDescendant,
onKeydown: handleSearchKeydown,
}

return (
<VDialog
Expand All @@ -301,8 +322,8 @@ export const VCommandPalette = genericComponent<VCommandPaletteSlots>()({
>
{ slots.prepend?.() }

<div class="v-command-palette__input-container">
{ slots.input?.() ?? (
<div ref={ inputContainerRef } class="v-command-palette__input-container">
{ slots.input?.({ props: inputProps }) ?? (
<VTextField
ref={ searchInputRef }
v-model={ searchQuery.value }
Expand All @@ -316,12 +337,7 @@ export const VCommandPalette = genericComponent<VCommandPaletteSlots>()({
variant="solo"
flat
bgColor="transparent"
role="combobox"
aria-expanded={ hasResults }
aria-controls={ hasResults ? listId : undefined }
aria-autocomplete="list"
aria-activedescendant={ listRef.value?.activeDescendant }
onKeydown={ handleSearchKeydown }
{ ...inputProps }
v-slots={{
'append-inner': slots['input.append-inner'],
}}
Expand Down Expand Up @@ -349,27 +365,41 @@ export const VCommandPalette = genericComponent<VCommandPaletteSlots>()({
onUpdate:navigationIndex={ navigation.setSelectedIndex }
v-slots={{
subheader: slots['list.subheader'],
item: ({ props: itemProps }: { props: any }) => (
slots.item?.({ item: itemProps, index: itemProps.index }) ?? (
item: ({ props: itemProps }: { props: any }) => {
const item = filteredItems.value[itemProps.index]

function execute (event: MouseEvent | KeyboardEvent) {
navigation.execute(itemProps.index, event)
}

return slots.item?.({
item,
index: itemProps.index,
props: {
...pick(itemProps, listItemKeys),
index: itemProps.index,
onClick: execute,
},
}) ?? (
<VCommandPaletteItem
key={ `item-${itemProps.index}` }
item={ itemProps }
index={ itemProps.index }
onExecute={ (event: MouseEvent | KeyboardEvent) => navigation.execute(itemProps.index, event) }
onExecute={ execute }
v-slots={{
prepend: slots['item.prepend']
? () => slots['item.prepend']?.({ item: itemProps, index: itemProps.index })
? () => slots['item.prepend']?.({ item, index: itemProps.index })
: undefined,
title: slots['item.title']
? () => slots['item.title']?.({ item: itemProps, index: itemProps.index })
? () => slots['item.title']?.({ item, index: itemProps.index })
: undefined,
append: slots['item.append']
? () => slots['item.append']?.({ item: itemProps, index: itemProps.index })
? () => slots['item.append']?.({ item, index: itemProps.index })
: undefined,
}}
/>
)
),
},
}}
/>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { VHotkey } from '@/components/VHotkey'
import { VListItem } from '@/components/VList'

// Utilities
import { genericComponent, propsFactory, useRender } from '@/util'
import { listItemKeys } from './shared'
import { genericComponent, pick, propsFactory, useRender } from '@/util'

// Types
import type { PropType } from 'vue'
Expand Down Expand Up @@ -35,14 +36,8 @@ export const VCommandPaletteItem = genericComponent<VCommandPaletteItemSlots>()(
setup (props, { slots }) {
useRender(() => (
<VListItem
{ ...pick(props.item, listItemKeys) }
index={ props.index }
value={ props.item.value }
title={ props.item.title }
subtitle={ props.item.subtitle }
prependIcon={ props.item.prependIcon }
prependAvatar={ props.item.prependAvatar }
appendIcon={ props.item.appendIcon }
appendAvatar={ props.item.appendAvatar }
onClick={ props.onExecute }
v-slots={{
prepend: slots.prepend,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Components
import { VCommandPalette } from '../VCommandPalette'
import { VListItem } from '@/components/VList'

// Utilities
import { render, screen, userEvent, wait } from '@test'
Expand Down Expand Up @@ -551,6 +552,92 @@ describe('VCommandPalette', () => {

expect(screen.getByTestId('no-data-slot')).toBeInTheDocument()
})

it('should focus, filter, navigate and execute from a custom input bound to the input slot props', async () => {
const model = ref(true)
const search = ref('')
const onClickItem = vi.fn()
render(() => (
<VCommandPalette
v-model={ model.value }
v-model:search={ search.value }
items={ testItems }
onClick:item={ onClickItem }
>
{{
input: ({ props }) => (
<input
{ ...props }
value={ search.value }
onInput={ (e: Event) => search.value = (e.target as HTMLInputElement).value }
/>
),
}}
</VCommandPalette>
))

await screen.findByRole('dialog')
const input = screen.getByRole('combobox')

await expect.poll(() => document.activeElement).toBe(input)
expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id)

await userEvent.click(input)
await userEvent.keyboard('file')
await expect.poll(() => screen.queryByText('Folder')).toBeNull()

await userEvent.keyboard('{ArrowDown}')
await expect.poll(() => document.getElementById(input.getAttribute('aria-activedescendant')!)).toHaveTextContent('Open File')

await userEvent.keyboard('{Enter}')
expect(onClickItem).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Open File' }),
expect.any(KeyboardEvent)
)
})

it('should execute an item rendered from the item slot props on click', async () => {
const model = ref(true)
const onClickItem = vi.fn()
render(() => (
<VCommandPalette v-model={ model.value } items={ testItems } onClick:item={ onClickItem }>
{{ item: ({ props }) => <VListItem { ...props } /> }}
</VCommandPalette>
))

await screen.findByRole('dialog')
const input = screen.getByCSS('input[role="combobox"]')

expect(document.getElementById(input.getAttribute('aria-activedescendant')!)).toHaveTextContent('File')

await userEvent.click(screen.getByText('Folder'))
expect(onClickItem).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Folder' }),
expect.any(MouseEvent)
)
await expect.poll(() => model.value).toBe(false)
})

it('should pass the original item and only list item props to the item slot', async () => {
const model = ref(true)
const onClick = vi.fn()
render(() => (
<VCommandPalette
v-model={ model.value }
items={[{ title: 'Export', value: 'export', hotkey: 'ctrl+shift+e', onClick }]}
>
{{ item: ({ item, props }) => <VListItem { ...props } subtitle={ `value: ${(item as any).value}` } /> }}
</VCommandPalette>
))

await screen.findByRole('dialog')

expect(screen.getByText('value: export')).toBeInTheDocument()
expect(screen.getByRole('option')).not.toHaveAttribute('hotkey')

await userEvent.click(screen.getByText('Export'))
expect(onClick).toHaveBeenCalledExactlyOnceWith(expect.any(MouseEvent), 'export')
})
})

describe('Accessibility', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/vuetify/src/labs/VCommandPalette/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface CommandPaletteProvide {

export const VCommandPaletteSymbol: InjectionKey<CommandPaletteProvide> = Symbol.for('vuetify:command-palette')

export const listItemKeys = ['value', 'title', 'subtitle', 'prependIcon', 'prependAvatar', 'appendIcon', 'appendAvatar'] as const

export function useCommandPalette () {
const commandPalette = inject(VCommandPaletteSymbol)

Expand Down
Loading