From 5d746893522b99de199ae1e9d4cc214723d8221a Mon Sep 17 00:00:00 2001 From: Matthew Ary Date: Fri, 9 Oct 2026 11:51:09 -0700 Subject: [PATCH 1/2] fix(VCommandPalette): pass keyboard and click wiring to custom slots The `input` slot now receives `props` with the combobox attributes and keyboard handler, and the `item` slot receives `props` whose click runs the command. fixes #23259 Co-Authored-By: Claude Opus 5.5 --- .../src/locale/en/VCommandPalette.json | 4 +- .../pages/en/components/command-palettes.md | 2 +- .../labs/VCommandPalette/VCommandPalette.tsx | 30 ++++++++---- .../VCommandPalette.spec.browser.tsx | 48 +++++++++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/packages/api-generator/src/locale/en/VCommandPalette.json b/packages/api-generator/src/locale/en/VCommandPalette.json index 7c036d66fba..ae3d0db0d72 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.", "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/pages/en/components/command-palettes.md b/packages/docs/src/pages/en/components/command-palettes.md index 50900aba348..e08651215d4 100644 --- a/packages/docs/src/pages/en/components/command-palettes.md +++ b/packages/docs/src/pages/en/components/command-palettes.md @@ -122,4 +122,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 e366c97407d..bfa30549d57 100644 --- a/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx +++ b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx @@ -74,12 +74,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 } @@ -280,6 +280,14 @@ 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, + onKeydown: handleSearchKeydown, + } return ( ()({ { slots.prepend?.() }
- { slots.input?.() ?? ( + { slots.input?.({ props: inputProps }) ?? ( ()({ 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'], }} @@ -350,7 +353,14 @@ export const VCommandPalette = genericComponent()({ v-slots={{ subheader: slots['list.subheader'], item: ({ props: itemProps }: { props: any }) => ( - slots.item?.({ item: itemProps, index: itemProps.index }) ?? ( + slots.item?.({ + item: itemProps, + index: itemProps.index, + props: { + ...itemProps, + onClick: (event: MouseEvent) => navigation.execute(itemProps.index, event), + }, + }) ?? ( { expect(screen.getByTestId('no-data-slot')).toBeInTheDocument() }) + + it('should navigate and execute from a custom input bound to the input slot props', async () => { + const model = ref(true) + const onClickItem = vi.fn() + render(() => ( + + {{ input: ({ props }) => }} + + )) + + await screen.findByRole('dialog') + const input = screen.getByRole('combobox') + + expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id) + + await userEvent.click(input) + await userEvent.keyboard('{ArrowDown}') + await expect.poll(() => document.getElementById(input.getAttribute('aria-activedescendant')!)).toHaveTextContent('Folder') + + await userEvent.keyboard('{Enter}') + expect(onClickItem).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Folder' }), + 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) + }) }) describe('Accessibility', () => { From 026877f44e89b9a6d4403cb7c14b16f64939a5d3 Mon Sep 17 00:00:00 2001 From: Matthew Ary Date: Fri, 9 Oct 2026 15:10:10 -0700 Subject: [PATCH 2/2] fix(VCommandPalette): address slot wiring review feedback - item slot `props` only carries list item props, `item` is the original item - focus a custom input when the palette opens - document `v-model:search` for custom inputs, add input and item slot examples Co-Authored-By: Claude Opus 5.5 --- .../src/locale/en/VCommandPalette.json | 2 +- .../examples/v-command-palette/slot-input.vue | 40 +++++++++++++++ .../examples/v-command-palette/slot-item.vue | 31 ++++++++++++ .../pages/en/components/command-palettes.md | 12 +++++ .../labs/VCommandPalette/VCommandPalette.tsx | 46 ++++++++++++----- .../VCommandPalette/VCommandPaletteItem.tsx | 11 ++--- .../VCommandPalette.spec.browser.tsx | 49 +++++++++++++++++-- .../src/labs/VCommandPalette/shared.ts | 2 + 8 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 packages/docs/src/examples/v-command-palette/slot-input.vue create mode 100644 packages/docs/src/examples/v-command-palette/slot-item.vue diff --git a/packages/api-generator/src/locale/en/VCommandPalette.json b/packages/api-generator/src/locale/en/VCommandPalette.json index ae3d0db0d72..39d9929ac4d 100644 --- a/packages/api-generator/src/locale/en/VCommandPalette.json +++ b/packages/api-generator/src/locale/en/VCommandPalette.json @@ -20,7 +20,7 @@ }, "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**. Bind the slot's **props** to the input to keep keyboard navigation and screen reader announcements.", + "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.", 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 e08651215d4..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. diff --git a/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx index bfa30549d57..64f8940b96b 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,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' @@ -106,6 +116,7 @@ export const VCommandPalette = genericComponent()({ 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()}` @@ -261,6 +272,8 @@ export const VCommandPalette = genericComponent()({ requestAnimationFrame(() => { if (searchInputRef.value && isFunction(searchInputRef.value.focus)) { searchInputRef.value.focus() + } else if (inputContainerRef.value) { + focusableChildren(inputContainerRef.value)[0]?.focus() } }) }) @@ -309,7 +322,7 @@ export const VCommandPalette = genericComponent()({ > { slots.prepend?.() } -
+
{ slots.input?.({ props: inputProps }) ?? ( ()({ onUpdate:navigationIndex={ navigation.setSelectedIndex } v-slots={{ subheader: slots['list.subheader'], - item: ({ props: itemProps }: { props: any }) => ( - slots.item?.({ - item: itemProps, + 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: { - ...itemProps, - onClick: (event: MouseEvent) => navigation.execute(itemProps.index, event), + ...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(screen.getByTestId('no-data-slot')).toBeInTheDocument() }) - it('should navigate and execute from a custom input bound to the input slot props', async () => { + 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 }) => }} + + {{ + 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('Folder') + await expect.poll(() => document.getElementById(input.getAttribute('aria-activedescendant')!)).toHaveTextContent('Open File') await userEvent.keyboard('{Enter}') expect(onClickItem).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Folder' }), + expect.objectContaining({ title: 'Open File' }), expect.any(KeyboardEvent) ) }) @@ -599,6 +617,27 @@ describe('VCommandPalette', () => { ) 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)