diff --git a/packages/api-generator/src/locale/en/VCommandPalette.json b/packages/api-generator/src/locale/en/VCommandPalette.json index 7c036d66fba..39d9929ac4d 100644 --- a/packages/api-generator/src/locale/en/VCommandPalette.json +++ b/packages/api-generator/src/locale/en/VCommandPalette.json @@ -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)." diff --git a/packages/docs/src/examples/v-command-palette/slot-input.vue b/packages/docs/src/examples/v-command-palette/slot-input.vue new file mode 100644 index 00000000000..a5ac3ddfb44 --- /dev/null +++ b/packages/docs/src/examples/v-command-palette/slot-input.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/docs/src/examples/v-command-palette/slot-item.vue b/packages/docs/src/examples/v-command-palette/slot-item.vue new file mode 100644 index 00000000000..a5581cc25f5 --- /dev/null +++ b/packages/docs/src/examples/v-command-palette/slot-item.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/docs/src/pages/en/components/command-palettes.md b/packages/docs/src/pages/en/components/command-palettes.md index 50900aba348..fba98a3234d 100644 --- a/packages/docs/src/pages/en/components/command-palettes.md +++ b/packages/docs/src/pages/en/components/command-palettes.md @@ -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. + + + +#### 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. + + + #### 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. @@ -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`. diff --git a/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx index 8005d79fa53..4651f8aa351 100644 --- a/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx +++ b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx @@ -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' @@ -21,7 +21,15 @@ 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, + genericComponent, + getActiveElement, + omit, + pick, + propsFactory, + useRender, +} from '@/util' // Types import type { PropType, Ref } from 'vue' @@ -74,12 +82,12 @@ export type VCommandPaletteSlots = { default: never prepend: never append: never - input: never + input: { props: Record } 'input.append-inner': never 'no-data': never 'list.prepend': never 'list.subheader': VListChildrenSlots['subheader'] - item: { item: VCommandPaletteItemType, index: number } + item: { item: VCommandPaletteItemType, index: number, props: Record } 'item.prepend': { item: VCommandPaletteItemType, index: number } 'item.title': { item: VCommandPaletteItemType, index: number } 'item.append': { item: VCommandPaletteItemType, index: number } @@ -105,7 +113,7 @@ export const VCommandPalette = genericComponent()({ const { t } = useLocale() const isOpen = useProxiedModel(props, 'modelValue') const searchQuery = useProxiedModel(props, 'search') as Ref - const searchInputRef = ref() + const inputContainerRef = ref() const dialogRef = ref() const listRef = ref() const listId = `v-command-palette-list-${useId()}` @@ -259,9 +267,7 @@ export const VCommandPalette = genericComponent()({ // Use requestAnimationFrame to ensure DOM is fully rendered nextTick(() => { requestAnimationFrame(() => { - if (searchInputRef.value && isFunction(searchInputRef.value.focus)) { - searchInputRef.value.focus() - } + inputContainerRef.value?.querySelector('[autofocus]')?.focus() }) }) } else if (!newValue && oldValue) { @@ -280,6 +286,15 @@ export const VCommandPalette = genericComponent()({ 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, + autofocus: true, + onKeydown: handleSearchKeydown, + } return ( ()({ > { slots.prepend?.() } -
- { slots.input?.() ?? ( +
+ { slots.input?.({ props: inputProps }) ?? ( ()({ 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, + }, + }) ?? ( 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, }} /> ) - ), + }, }} /> ) : ( diff --git a/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx b/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx index 31d2bce70f9..a0e21139dd7 100644 --- a/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx +++ b/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx @@ -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' @@ -35,14 +36,8 @@ export const VCommandPaletteItem = genericComponent()( setup (props, { slots }) { useRender(() => ( { expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id) }) + it('should focus the search input when an eager palette reopens', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + const input = screen.getByCSS('input[role="combobox"]') + await expect.poll(() => document.activeElement).toBe(input) + + model.value = false + await expect.poll(() => document.activeElement).not.toBe(input) + + model.value = true + await expect.poll(() => document.activeElement).toBe(input) + }) + it('should keep focus in search input while navigating', async () => { const model = ref(true) render(() => ( @@ -550,6 +568,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(() => ( + + {{ + input: ({ props }) => ( + search.value = (e.target as HTMLInputElement).value } + /> + ), + }} + + )) + + 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(() => ( + + {{ item: ({ props }) => }} + + )) + + 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(() => ( + + {{ item: ({ item, props }) => }} + + )) + + 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', () => { diff --git a/packages/vuetify/src/labs/VCommandPalette/shared.ts b/packages/vuetify/src/labs/VCommandPalette/shared.ts index 32d86af8010..df642537e55 100644 --- a/packages/vuetify/src/labs/VCommandPalette/shared.ts +++ b/packages/vuetify/src/labs/VCommandPalette/shared.ts @@ -15,6 +15,8 @@ export interface CommandPaletteProvide { export const VCommandPaletteSymbol: InjectionKey = Symbol.for('vuetify:command-palette') +export const listItemKeys = ['value', 'title', 'subtitle', 'prependIcon', 'prependAvatar', 'appendIcon', 'appendAvatar'] as const + export function useCommandPalette () { const commandPalette = inject(VCommandPaletteSymbol)