diff --git a/packages/api-generator/src/locale/en/VCommandPalette.json b/packages/api-generator/src/locale/en/VCommandPalette.json new file mode 100644 index 00000000000..db6c6f03a82 --- /dev/null +++ b/packages/api-generator/src/locale/en/VCommandPalette.json @@ -0,0 +1,25 @@ +{ + "props": { + "modelValue": "Controls the visibility of the command palette dialog. Use `v-model` for two-way binding.", + "search": "The current search query. Use `v-model:search` to control or monitor the search input value.", + "items": "Array of command palette items. Objects should have **title** and optionally **subtitle**, **prependIcon**, **appendIcon**, **prependAvatar**, **appendAvatar**, **hotkey**, **onClick**, **to**, **href**, and **value** properties. Use `type: 'subheader'` with a **title** for section headers, or `type: 'divider'` for visual separators between groups.", + "hotkey": "Global keyboard shortcut to toggle the palette. Accepts hotkey strings like `'ctrl+shift+p'` or `'meta+j'`. The shortcut is automatically registered on mount and cleaned up on unmount.", + "placeholder": "Placeholder text displayed in the search input. Defaults to the `$vuetify.command.search` locale string.", + "inputIcon": "Icon to display at the start of the search input field. Defaults to `'mdi-magnify'`.", + "noDataText": "Text displayed when no items match the current search query. Defaults to `$vuetify.noDataText`.", + "location": "Controls the position of the dialog. Supports values like `'top'`, `'bottom'`, `'center'` and combinations. Passed to the underlying **v-dialog**.", + "activator": "Element to use as the activator. Set to `'parent'` to use the parent element, or provide a CSS selector or element reference. Passed to the underlying **v-dialog**.", + "dialogProps": "Additional props to pass through to the underlying **v-dialog** component. Useful for customizing behavior like `persistent`, `fullscreen`, `width`, `maxWidth`, etc." + }, + "events": { + "update:modelValue": "Emitted when the dialog visibility changes.", + "update:search": "Emitted when the search query changes.", + "click:item": "Emitted when an item is clicked or activated via Enter key. The payload includes the selected item object and the triggering event (MouseEvent or KeyboardEvent). The palette automatically closes after this event." + }, + "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.", + "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/api-generator/src/locale/en/VList.json b/packages/api-generator/src/locale/en/VList.json index 6d0822e5a21..8ef3b2684cf 100644 --- a/packages/api-generator/src/locale/en/VList.json +++ b/packages/api-generator/src/locale/en/VList.json @@ -30,9 +30,10 @@ "update:selected": "Emitted when the list item is selected." }, "slots": { - "divider": "Slot for the divider.", - "header": "Slot for the header.", - "subheader": "Removes the top padding from `v-list-subheader` components. When used as a **String**, renders a subheader for you.", + "item": "Slot for rendering custom list items. Receives `{ props }` where `props` contains item data (`title`, `subtitle`, `value`, etc.) plus `index` (the item's position in the list). Use this to completely customize item rendering while still using VList's navigation and selection features.", + "divider": "Slot for rendering custom dividers. Receives `{ props }` containing divider item data including `value`. Note: dividers do not receive `index` since they are not navigable items.", + "subheader": "Slot for rendering custom subheaders. Receives `{ props }` containing subheader item data including `title` and `value`. Note: subheaders do not receive `index` since they are not navigable items.", + "header": "Slot for rendering custom group headers when using nested items. Receives `{ props }` containing the group's header item data plus activator props for expand/collapse functionality.", "children": "Slot for the children.", "focus": "Slot for the focus.", "open": "Slot for the open.", diff --git a/packages/docs/src/data/nav.json b/packages/docs/src/data/nav.json index 97570e96313..d408f0f6d0d 100644 --- a/packages/docs/src/data/nav.json +++ b/packages/docs/src/data/nav.json @@ -254,6 +254,10 @@ "title": "color-inputs", "subfolder": "components" }, + { + "title": "command-palettes", + "subfolder": "components" + }, { "title": "date-inputs", "subfolder": "components" diff --git a/packages/docs/src/examples/v-command-palette/prop-dialog.vue b/packages/docs/src/examples/v-command-palette/prop-dialog.vue new file mode 100644 index 00000000000..1f178522bb9 --- /dev/null +++ b/packages/docs/src/examples/v-command-palette/prop-dialog.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/packages/docs/src/examples/v-command-palette/prop-hotkey.vue b/packages/docs/src/examples/v-command-palette/prop-hotkey.vue new file mode 100644 index 00000000000..21abd65dbb2 --- /dev/null +++ b/packages/docs/src/examples/v-command-palette/prop-hotkey.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/packages/docs/src/examples/v-command-palette/prop-items.vue b/packages/docs/src/examples/v-command-palette/prop-items.vue new file mode 100644 index 00000000000..23d43be4b29 --- /dev/null +++ b/packages/docs/src/examples/v-command-palette/prop-items.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/packages/docs/src/examples/v-command-palette/usage.vue b/packages/docs/src/examples/v-command-palette/usage.vue new file mode 100644 index 00000000000..1d3fb6a722c --- /dev/null +++ b/packages/docs/src/examples/v-command-palette/usage.vue @@ -0,0 +1,123 @@ + + + diff --git a/packages/docs/src/pages/en/components/command-palettes.md b/packages/docs/src/pages/en/components/command-palettes.md new file mode 100644 index 00000000000..071edc6d6a6 --- /dev/null +++ b/packages/docs/src/pages/en/components/command-palettes.md @@ -0,0 +1,94 @@ +--- +emphasized: true +meta: + nav: Command Palettes + title: Command Palette component + description: A keyboard-driven command palette component that provides a searchable dialog interface for executing commands and actions. + keywords: command palette, keyboard shortcuts, vuetify command palette component, vue command palette component +related: + - /components/dialogs/ + - /components/hotkeys/ + - /components/lists/ +features: + github: /labs/VCommandPalette/ + label: 'C: VCommandPalette' + report: true +--- + +# Command Palettes + +The `v-command-palette` component provides a keyboard-driven command interface that allows users to quickly search and execute commands. It's commonly used for quick navigation, command execution, and power-user workflows. + + + +::: success +This feature was introduced as a labs component and is available for testing and feedback. +::: + +## Usage + +The command palette displays a searchable list of commands in a dialog. Users can type to filter items and press Enter or click to execute commands. + + + + + +## API + +| Component | Description | +| - | - | +| [v-command-palette](/api/v-command-palette/) | Primary Component | +| [v-dialog](/api/v-dialog/) | Base Component | + + + +## Examples + +Below is a collection of simple to complex examples. + +### Props + +#### Items + +The **items** prop accepts an array of command palette items. Items support action items (interactive commands), subheaders (section labels), and dividers (visual separators). + + + +#### Hotkey + +Use the **hotkey** prop to register a global keyboard shortcut that toggles the command palette. Individual items can also have their own **hotkey** property for quick access. + + + +#### Dialog configuration + +The command palette is built on `v-dialog` and supports dialog-related props. Use **location** to control positioning, **activator** for activation patterns, and **dialog-props** to pass additional props. + + + +### Filtering + +The search input automatically filters items based on their **title** and **subtitle** properties. Use **v-model:search** to control or monitor the search query. The **filter-keys** prop can customize which item properties are searched. + +The **placeholder** prop customizes the search input's placeholder text, while **no-data-text** customizes the message shown when no items match the search query. + +### Keyboard navigation + +The command palette supports full keyboard navigation: + +- **Arrow Up/Down**: Navigate through commands +- **Enter**: Execute the selected command +- **Escape**: Close the palette +- **Typing**: Filters commands by title and subtitle +- **Per-item hotkeys**: Execute specific commands directly (when palette is open) + +## Accessibility + +The `v-command-palette` component follows accessibility best practices: + +- Uses semantic ARIA roles (`listbox` for the list, `option` for items) +- Provides descriptive labels for screen readers +- Implements `aria-activedescendant` for proper focus announcement +- Maintains focus within the dialog while open +- Returns focus to the previously focused element on close +- Supports full keyboard navigation without mouse interaction diff --git a/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.scss b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.scss new file mode 100644 index 00000000000..764302499ff --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.scss @@ -0,0 +1,36 @@ +@use '../../styles/tools'; + +@include tools.layer('components') { + .v-command-palette { + > .v-overlay__content > .v-sheet { + display: flex; + flex: 1 1 100%; + flex-direction: column; + } + + &__input-container { + padding: 8px 16px; + } + + &__content { + overflow-y: auto; + } + + &__no-data { + padding: 16px; + text-align: center; + opacity: 0.6; + } + + &__list { + .v-list-subheader { + opacity: 0.7; + user-select: none; + } + + .v-divider { + margin-block: 4px; + } + } + } +} diff --git a/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx new file mode 100644 index 00000000000..df1d6407577 --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/VCommandPalette.tsx @@ -0,0 +1,324 @@ +// Styles +import './VCommandPalette.scss' + +// Components +import { VDialog } from '@/components/VDialog' +import { VList } from '@/components/VList' +import { VSheet } from '@/components/VSheet' +import { VTextField } from '@/components/VTextField' + +// Composables +import { provideCommandPaletteContext } from './composables/useCommandPaletteContext' +import { useCommandPaletteNavigation } from './composables/useCommandPaletteNavigation' +import { makeDensityProps, useDensity } from '@/composables/density' +import { makeFilterProps, useFilter } 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, onUnmounted, ref, shallowRef, watch, watchEffect } from 'vue' +import { genericComponent, propsFactory, useRender } from '@/util' + +// Types +import type { PropType, Ref, VNode } from 'vue' +import type { VCommandPaletteItem as VCommandPaletteItemType } from './types' +import { isActionItem } from './types' +import { VCommandPaletteItemComponent } from './VCommandPaletteItem' + +export const makeVCommandPaletteProps = propsFactory({ + modelValue: Boolean, + search: String, + items: { + type: Array as PropType, + default: () => [], + }, + placeholder: String, + inputIcon: { + type: String, + default: 'mdi-magnify', + }, + hotkey: String, + noDataText: String, + location: String, + activator: [String, Object], + dialogProps: Object as PropType>, + + ...makeFilterProps({ filterKeys: ['title', 'subtitle'] }), + ...makeThemeProps(), + ...makeDensityProps(), + ...makeTransitionProps(), +}, 'VCommandPalette') + +export type VCommandPaletteSlots = { + default: never + prepend: never + append: never + input: never + 'no-data': never +} + +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() + const isOpen = useProxiedModel(props, 'modelValue') + const searchQuery = useProxiedModel(props, 'search') as Ref + const { themeClasses } = provideTheme(props) + const { densityClasses } = useDensity(props) + const searchInputRef = ref() + const dialogRef = ref() + const previouslyFocusedElement = shallowRef(null) + + const internalItems = computed(() => + props.items.map((item, index) => ({ + value: index, + raw: item, + ...('title' in item && { title: item.title }), + ...('subtitle' in item && { subtitle: item.subtitle }), + })) + ) + + const { filteredItems: filterResult } = useFilter(props, internalItems, searchQuery) + + const filteredItems = computed(() => filterResult.value.map(item => item.raw)) + + const itemsForList = computed(() => { + return filteredItems.value.map((item, idx) => ({ + ...item, + value: idx, + })) + }) + + 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 + }, + }) + + provideCommandPaletteContext({ + items: computed(() => props.items), + filteredItems, + selectedIndex: navigation.selectedIndex, + search: searchQuery, + setSelectedIndex: navigation.setSelectedIndex, + }) + + // Register main hotkey with cleanup + let hotkeyUnsubscribe: (() => void) | undefined + if (props.hotkey) { + hotkeyUnsubscribe = useHotkey(props.hotkey, () => { + isOpen.value = !isOpen.value + }) + } + + 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?.()) + }) + }) + + function findNextSelectableIndex (startIndex: number, direction: 1 | -1): number { + const items = filteredItems.value + if (items.length === 0) return -1 + + let index = startIndex + const maxIterations = items.length + + for (let i = 0; i < maxIterations; i++) { + index += direction + if (index >= items.length) index = 0 + if (index < 0) index = items.length - 1 + + if (isActionItem(items[index])) { + return index + } + } + + return -1 + } + + function handleSearchKeydown (e: KeyboardEvent) { + switch (e.key) { + case 'ArrowDown': { + e.preventDefault() + const nextIndex = findNextSelectableIndex(navigation.selectedIndex.value, 1) + if (nextIndex !== -1) { + navigation.setSelectedIndex(nextIndex) + } + break + } + case 'ArrowUp': { + e.preventDefault() + const prevIndex = findNextSelectableIndex(navigation.selectedIndex.value, -1) + if (prevIndex !== -1) { + navigation.setSelectedIndex(prevIndex) + } + break + } + case 'Enter': + e.preventDefault() + navigation.executeSelected(e) + break + case 'Escape': + e.preventDefault() + isOpen.value = false + break + } + } + + watch(isOpen, (newValue, oldValue) => { + if (newValue && !oldValue) { + previouslyFocusedElement.value = document.activeElement as HTMLElement | null + searchQuery.value = '' + navigation.reset() + + nextTick(() => { + searchInputRef.value?.controlRef?.focus() + }) + } else if (!newValue && oldValue) { + nextTick(() => { + previouslyFocusedElement.value?.focus({ preventScroll: true }) + previouslyFocusedElement.value = null + }) + } + }) + + const computedDialogProps = computed(() => { + const baseProps: Record = { + 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 + }) + + onUnmounted(() => { + hotkeyUnsubscribe?.() + previouslyFocusedElement.value = null + }) + + useRender((): VNode => ( + + {{ + default: () => ( + + { slots.prepend?.() } + +
+ { slots.input?.() ?? ( + + )} +
+ +
+ { filteredItems.value.length > 0 ? ( + ( + navigation.execute(props.index, event) } + /> + ), + }} + /> + ) : ( +
+ { slots['no-data']?.() || (props.noDataText || t('$vuetify.noDataText')) } +
+ )} +
+ + { slots.append?.() } +
+ ), + }} +
+ )) + }, +}) + +export type VCommandPalette = InstanceType diff --git a/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx b/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx new file mode 100644 index 00000000000..cfdcf2bdbfb --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/VCommandPaletteItem.tsx @@ -0,0 +1,48 @@ +// Components +import { VHotkey } from '@/components/VHotkey' +import { VListItem } from '@/components/VList' + +// Utilities +import { genericComponent, propsFactory, useRender } from '@/util' + +// Types +import type { PropType } from 'vue' +import type { VCommandPaletteActionItem } from './types' + +export const makeVCommandPaletteItemProps = propsFactory({ + item: { + type: Object as PropType, + required: true, + }, + index: { + type: Number, + required: true, + }, + onExecute: Function as PropType<(event: MouseEvent | KeyboardEvent) => void>, +}, 'VCommandPaletteItem') + +export const VCommandPaletteItemComponent = genericComponent()({ + name: 'VCommandPaletteItem', + + props: makeVCommandPaletteItemProps(), + + setup (props) { + useRender(() => ( + : undefined, + }} + /> + )) + }, +}) + +export type VCommandPaletteItemComponent = InstanceType diff --git a/packages/vuetify/src/labs/VCommandPalette/__tests__/VCommandPalette.spec.browser.tsx b/packages/vuetify/src/labs/VCommandPalette/__tests__/VCommandPalette.spec.browser.tsx new file mode 100644 index 00000000000..742018d8cf0 --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/__tests__/VCommandPalette.spec.browser.tsx @@ -0,0 +1,387 @@ +// Components +import { VCommandPalette } from '../VCommandPalette' + +// Utilities +import { render, screen, userEvent, wait } from '@test' +import { ref } from 'vue' + +// Test data +const testItems = [ + { + title: 'File', + subtitle: 'Create new file', + value: 'file', + }, + { + title: 'Folder', + subtitle: 'Create new folder', + value: 'folder', + }, + { + title: 'Project', + subtitle: 'Create new project', + value: 'project', + }, + { + type: 'divider' as const, + }, + { + type: 'subheader' as const, + title: 'Recent', + }, + { + title: 'Open File', + subtitle: 'Recently opened file', + value: 'open-file', + }, +] + +describe('VCommandPalette', () => { + afterEach(() => { + const overlays = document.querySelectorAll('.v-overlay') + overlays.forEach(overlay => overlay.remove()) + }) + + describe('Rendering', () => { + it('should render dialog closed by default', () => { + render(() => ) + expect(document.querySelector('[role="dialog"]')).not.toBeInTheDocument() + }) + + it('should render dialog when modelValue is true', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should render items with titles, subtitles, subheaders, and dividers', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + + // Action items + expect(screen.getByText('File')).toBeInTheDocument() + expect(screen.getByText('Create new file')).toBeInTheDocument() + + // Subheader + expect(screen.getByText('Recent')).toBeInTheDocument() + + // Divider + expect(document.querySelectorAll('.v-divider').length).toBeGreaterThan(0) + }) + + it('should show no-data message when no items match search', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + const input = screen.getByRole('textbox') + + await userEvent.type(input, 'nonexistent') + await wait(50) + + expect(screen.getByText('No data available')).toBeInTheDocument() + }) + }) + + describe('Search & Filtering', () => { + it('should filter items by title (case insensitive)', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + const input = screen.getByRole('textbox') + + await userEvent.type(input, 'FILE') + await wait(50) + + expect(screen.getByText('File')).toBeInTheDocument() + expect(screen.getByText('Open File')).toBeInTheDocument() + expect(screen.queryByText('Folder')).not.toBeInTheDocument() + }) + + it('should clear search when dialog closes and reopens', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + const input = screen.getByRole('textbox') as HTMLInputElement + + await userEvent.type(input, 'test') + await wait(50) + expect(input.value).toBe('test') + + // Close and reopen + model.value = false + await wait(50) + model.value = true + await screen.findByRole('dialog') + + const newInput = screen.getByRole('textbox') as HTMLInputElement + expect(newInput.value).toBe('') + }) + }) + + describe('Keyboard Navigation', () => { + it('should navigate with arrow keys and skip dividers/subheaders', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + const listbox = screen.getByRole('listbox') + + // Items: File(0), Folder(1), Project(2), Divider(3), Subheader(4), Open File(5) + // Selectable: 0, 1, 2, 5 + + // Start at first item + expect(listbox.getAttribute('aria-activedescendant')).toMatch(/^v-list-item-.*-0$/) + + // Navigate through items + await userEvent.keyboard('{ArrowDown}') + await wait(50) + expect(listbox.getAttribute('aria-activedescendant')).toMatch(/^v-list-item-.*-1$/) + + await userEvent.keyboard('{ArrowDown}') + await wait(50) + expect(listbox.getAttribute('aria-activedescendant')).toMatch(/^v-list-item-.*-2$/) + + // Should skip divider(3) and subheader(4), land on Open File(5) + await userEvent.keyboard('{ArrowDown}') + await wait(50) + expect(listbox.getAttribute('aria-activedescendant')).toMatch(/^v-list-item-.*-5$/) + + // Should wrap to first item + await userEvent.keyboard('{ArrowDown}') + await wait(50) + expect(listbox.getAttribute('aria-activedescendant')).toMatch(/^v-list-item-.*-0$/) + + // Arrow up should go back to last selectable + await userEvent.keyboard('{ArrowUp}') + await wait(50) + expect(listbox.getAttribute('aria-activedescendant')).toMatch(/^v-list-item-.*-5$/) + }) + + it('should keep focus in search input while navigating', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + const input = screen.getByRole('textbox') + + await userEvent.click(input) + expect(input).toHaveFocus() + + await userEvent.keyboard('{ArrowDown}') + await wait(50) + + expect(input).toHaveFocus() + }) + }) + + describe('Item Execution', () => { + it('should execute item with Enter key and close dialog', async () => { + const model = ref(true) + const onClickItem = vi.fn() + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + + // Navigate to second item and execute + await userEvent.keyboard('{ArrowDown}') + await wait(50) + await userEvent.keyboard('{Enter}') + await wait(100) + + expect(onClickItem).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Folder', value: 'folder' }), + expect.any(KeyboardEvent) + ) + expect(model.value).toBe(false) + }) + + it('should execute item with mouse click', async () => { + const model = ref(true) + const onClickItem = vi.fn() + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + + const folderItem = screen.getByText('Folder') + await userEvent.click(folderItem) + await wait(100) + + expect(onClickItem).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Folder', value: 'folder' }), + expect.any(MouseEvent) + ) + }) + + it('should call item onClick handler when executed', async () => { + const handleClick = vi.fn() + const model = ref(true) + const itemsWithHandler = [ + { + title: 'Action Item', + value: 'action', + onClick: handleClick, + }, + ] + + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + + await userEvent.keyboard('{Enter}') + await wait(100) + + expect(handleClick).toHaveBeenCalled() + }) + }) + + describe('Hotkeys', () => { + it('should open palette with global hotkey', async () => { + const model = ref(false) + render(() => ( + + )) + + expect(document.querySelector('[role="dialog"]')).not.toBeInTheDocument() + + await userEvent.keyboard('{Control>}{Shift>}p{/Shift}{/Control}') + await wait(100) + + expect(document.querySelector('[role="dialog"]')).toBeInTheDocument() + }) + + it('should trigger item hotkey when palette is open', async () => { + const model = ref(true) + const handleClick = vi.fn() + const itemsWithHotkeys = [ + { + title: 'Save File', + value: 'save', + hotkey: 'ctrl+s', + onClick: handleClick, + }, + ] + + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + + await userEvent.keyboard('{Control>}s{/Control}') + await wait(100) + + expect(handleClick).toHaveBeenCalledWith(expect.any(KeyboardEvent), 'save') + expect(document.querySelector('[role="dialog"]')).not.toBeInTheDocument() + }) + }) + + describe('Slots', () => { + it('should render prepend, append, and no-data slots', async () => { + const model = ref(true) + const search = ref('nonexistent') + render(() => ( +
Prepend
, + append: () =>
Append
, + 'no-data': () =>
No results
, + } as any} + /> + )) + + await screen.findByRole('dialog') + + expect(screen.getByTestId('prepend-slot')).toBeInTheDocument() + expect(screen.getByTestId('append-slot')).toBeInTheDocument() + expect(screen.getByTestId('no-data-slot')).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('should have proper ARIA attributes on listbox and options', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + + const listbox = screen.getByRole('listbox') + expect(listbox).toHaveAttribute('aria-activedescendant') + + const options = screen.getAllByRole('option') + expect(options).toHaveLength(4) // 4 action items, not divider/subheader + options.forEach(option => { + expect(option).toHaveAttribute('aria-selected') + }) + }) + + it('should update aria-selected when navigating', async () => { + const model = ref(true) + render(() => ( + + )) + + await screen.findByRole('dialog') + await wait(100) + + const options = screen.getAllByRole('option') + expect(options[0].getAttribute('aria-selected')).toBe('true') + expect(options[1].getAttribute('aria-selected')).toBe('false') + + await userEvent.keyboard('{ArrowDown}') + await wait(100) + + expect(options[0].getAttribute('aria-selected')).toBe('false') + expect(options[1].getAttribute('aria-selected')).toBe('true') + }) + }) +}) diff --git a/packages/vuetify/src/labs/VCommandPalette/composables/useCommandPaletteContext.ts b/packages/vuetify/src/labs/VCommandPalette/composables/useCommandPaletteContext.ts new file mode 100644 index 00000000000..63ec76ce2fa --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/composables/useCommandPaletteContext.ts @@ -0,0 +1,31 @@ +// Utilities +import { inject, provide } from 'vue' + +// Types +import type { InjectionKey, Ref } from 'vue' +import type { VCommandPaletteItem } from '../types' + +export interface VCommandPaletteContextType { + items: Ref + filteredItems: Ref + selectedIndex: Ref + search: Ref + setSelectedIndex: (index: number) => void +} + +export const VCommandPaletteContextKey: InjectionKey = Symbol.for('vuetify:command-palette-context') + +export function provideCommandPaletteContext (context: VCommandPaletteContextType) { + provide(VCommandPaletteContextKey, context) + return context +} + +export function useCommandPaletteContext (): VCommandPaletteContextType { + const context = inject(VCommandPaletteContextKey) + + if (!context) { + throw new Error('useCommandPaletteContext must be used within a VCommandPalette component') + } + + return context +} diff --git a/packages/vuetify/src/labs/VCommandPalette/composables/useCommandPaletteNavigation.ts b/packages/vuetify/src/labs/VCommandPalette/composables/useCommandPaletteNavigation.ts new file mode 100644 index 00000000000..e36a366e325 --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/composables/useCommandPaletteNavigation.ts @@ -0,0 +1,122 @@ +// Utilities +import { readonly, ref, shallowRef, watch } from 'vue' + +// Types +import type { ComputedRef, Ref } from 'vue' +import type { VCommandPaletteItem } from '../types' +import { isActionItem } from '../types' + +export interface UseCommandPaletteNavigationOptions { + filteredItems: ComputedRef + onItemClick: (item: VCommandPaletteItem, event: KeyboardEvent | MouseEvent) => void +} + +export interface UseCommandPaletteNavigationReturn { + selectedIndex: Readonly> + getSelectedItem: () => VCommandPaletteItem | undefined + execute: (index: number, event: KeyboardEvent | MouseEvent) => void + executeSelected: (event: KeyboardEvent | MouseEvent) => void + reset: () => void + setSelectedIndex: (index: number) => void +} + +function getItemKey (item: VCommandPaletteItem): string | undefined { + if (!isActionItem(item)) return undefined + return item.value !== undefined ? String(item.value) : item.title +} + +function findFirstSelectableIndex (items: VCommandPaletteItem[]): number { + return items.findIndex(item => isActionItem(item)) +} + +export function useCommandPaletteNavigation ( + options: UseCommandPaletteNavigationOptions +): UseCommandPaletteNavigationReturn { + const selectedIndex = ref(0) + const selectedItemKey = shallowRef(undefined) + + watch(() => options.filteredItems.value, (newItems, oldItems) => { + if (newItems.length === 0) { + selectedIndex.value = -1 + selectedItemKey.value = undefined + return + } + + if (selectedItemKey.value !== undefined) { + const newIndex = newItems.findIndex(item => + isActionItem(item) && getItemKey(item) === selectedItemKey.value + ) + if (newIndex !== -1) { + selectedIndex.value = newIndex + return + } + } + + const firstSelectableIndex = findFirstSelectableIndex(newItems) + if (firstSelectableIndex !== -1) { + selectedIndex.value = firstSelectableIndex + selectedItemKey.value = getItemKey(newItems[firstSelectableIndex]) + return + } + + selectedIndex.value = 0 + selectedItemKey.value = undefined + }, { immediate: true }) + + function getSelectedItem (): VCommandPaletteItem | undefined { + return options.filteredItems.value[selectedIndex.value] + } + + function execute (index: number, event: KeyboardEvent | MouseEvent) { + const item = options.filteredItems.value[index] + if (item) { + options.onItemClick(item, event) + } + } + + function executeSelected (event: KeyboardEvent | MouseEvent) { + const item = getSelectedItem() + if (item) { + options.onItemClick(item, event) + } + } + + function reset () { + const items = options.filteredItems.value + + if (items.length === 0) { + selectedIndex.value = -1 + selectedItemKey.value = undefined + return + } + + const firstSelectableIndex = findFirstSelectableIndex(items) + if (firstSelectableIndex !== -1) { + selectedIndex.value = firstSelectableIndex + selectedItemKey.value = getItemKey(items[firstSelectableIndex]) + return + } + + selectedIndex.value = 0 + selectedItemKey.value = undefined + } + + function setSelectedIndex (index: number) { + // Ignore VList's reset to -1 when we have items - we manage selection on filter changes + if (index === -1 && options.filteredItems.value.length > 0) { + return + } + selectedIndex.value = index + const item = options.filteredItems.value[index] + selectedItemKey.value = item ? getItemKey(item) : undefined + } + + return { + selectedIndex: readonly(selectedIndex), + getSelectedItem, + execute, + executeSelected, + reset, + setSelectedIndex, + } +} diff --git a/packages/vuetify/src/labs/VCommandPalette/index.ts b/packages/vuetify/src/labs/VCommandPalette/index.ts new file mode 100644 index 00000000000..62471a2845d --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/index.ts @@ -0,0 +1,2 @@ +export { VCommandPalette } from './VCommandPalette' +export { VCommandPaletteItemComponent } from './VCommandPaletteItem' diff --git a/packages/vuetify/src/labs/VCommandPalette/types.ts b/packages/vuetify/src/labs/VCommandPalette/types.ts new file mode 100644 index 00000000000..f740afa7815 --- /dev/null +++ b/packages/vuetify/src/labs/VCommandPalette/types.ts @@ -0,0 +1,43 @@ +// Types +import type { RouteLocationRaw } from 'vue-router' + +export interface BaseVListItem { + title?: string + subtitle?: string + prependIcon?: string + appendIcon?: string + prependAvatar?: string + appendAvatar?: string +} + +interface NavigableItemProps { + to?: RouteLocationRaw + href?: string +} + +export interface VCommandPaletteActionItem extends BaseVListItem, NavigableItemProps { + type?: 'item' + onClick?: (event: MouseEvent | KeyboardEvent, value?: any) => void + value?: any + hotkey?: string +} + +export interface VCommandPaletteSubheader { + type: 'subheader' + title: string + inset?: boolean +} + +export interface VCommandPaletteDivider { + type: 'divider' + inset?: boolean +} + +export type VCommandPaletteItem = + | VCommandPaletteActionItem + | VCommandPaletteSubheader + | VCommandPaletteDivider + +export function isActionItem (item: any): item is VCommandPaletteActionItem { + return !item.type || item.type === 'item' +} diff --git a/packages/vuetify/src/labs/components.ts b/packages/vuetify/src/labs/components.ts index e4eea9266b8..b656583083e 100644 --- a/packages/vuetify/src/labs/components.ts +++ b/packages/vuetify/src/labs/components.ts @@ -1,4 +1,5 @@ export * from './VColorInput' +export * from './VCommandPalette' export * from './VDateInput' export * from './VFileUpload' export * from './VIconBtn' diff --git a/packages/vuetify/src/locale/af.ts b/packages/vuetify/src/locale/af.ts index d6b1778a2e1..fabd888704e 100644 --- a/packages/vuetify/src/locale/af.ts +++ b/packages/vuetify/src/locale/af.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Kies asseblief ten minste een waarde', pattern: 'Ongeldige formaat', }, + command: { + search: 'Tik \'n opdrag of soek...', + }, hotkey: { then: 'dan', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ar.ts b/packages/vuetify/src/locale/ar.ts index 40e6864fa45..e82ca3ba53c 100644 --- a/packages/vuetify/src/locale/ar.ts +++ b/packages/vuetify/src/locale/ar.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'يرجى اختيار قيمة واحدة على الأقل', pattern: 'تنسيق غير صالح', }, + command: { + search: 'اكتب أمراً أو ابحث...', + }, hotkey: { then: 'ثم', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/az.ts b/packages/vuetify/src/locale/az.ts index 0325ae0a344..5104f074b29 100644 --- a/packages/vuetify/src/locale/az.ts +++ b/packages/vuetify/src/locale/az.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Zəhmət olmasa ən azı bir dəyər seçin', pattern: 'Yanlış format', }, + command: { + search: 'Əmr yazın və ya axtarış edin...', + }, hotkey: { then: 'sonra', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/bg.ts b/packages/vuetify/src/locale/bg.ts index f43cfb8938e..73f3903bd05 100644 --- a/packages/vuetify/src/locale/bg.ts +++ b/packages/vuetify/src/locale/bg.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Моля, изберете поне една стойност', pattern: 'Невалиден формат', }, + command: { + search: 'Въведете команда или търсете...', + }, hotkey: { then: 'след това', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ca.ts b/packages/vuetify/src/locale/ca.ts index 703d43ce03d..3a63dd3d10a 100644 --- a/packages/vuetify/src/locale/ca.ts +++ b/packages/vuetify/src/locale/ca.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Si us plau, tria almenys un valor', pattern: 'Format no vàlid', }, + command: { + search: 'Escriu una ordre o cerca...', + }, hotkey: { then: 'després', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ckb.ts b/packages/vuetify/src/locale/ckb.ts index d74d39d191d..9edb3340bd1 100644 --- a/packages/vuetify/src/locale/ckb.ts +++ b/packages/vuetify/src/locale/ckb.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'تکایە بەلایەنی کەم یەک هەڵبژێرە', pattern: 'فۆرماتەکە نادروستە', }, + command: { + search: 'فرمان بنووسە یان بگەڕە...', + }, hotkey: { then: 'پاشان', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/cs.ts b/packages/vuetify/src/locale/cs.ts index 75bb6a4f251..fa246cf22e3 100644 --- a/packages/vuetify/src/locale/cs.ts +++ b/packages/vuetify/src/locale/cs.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Vyberte alespoň jednu hodnotu', pattern: 'Neplatný formát', }, + command: { + search: 'Zadejte příkaz nebo hledejte...', + }, hotkey: { then: 'poté', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/da.ts b/packages/vuetify/src/locale/da.ts index 855602e36f6..64ae62414c8 100644 --- a/packages/vuetify/src/locale/da.ts +++ b/packages/vuetify/src/locale/da.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Vælg venligst mindst én værdi', pattern: 'Ugyldigt format', }, + command: { + search: 'Skriv en kommando eller søg...', + }, hotkey: { then: 'derefter', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/de.ts b/packages/vuetify/src/locale/de.ts index 6b4e2c9d216..31aacec40ce 100644 --- a/packages/vuetify/src/locale/de.ts +++ b/packages/vuetify/src/locale/de.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Bitte wählen Sie mindestens einen Wert aus', pattern: 'Ungültiges Format', }, + command: { + search: 'Geben Sie einen Befehl ein oder suchen Sie...', + }, hotkey: { then: 'dann', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/el.ts b/packages/vuetify/src/locale/el.ts index 9eb7f58ff86..445ecca7a61 100755 --- a/packages/vuetify/src/locale/el.ts +++ b/packages/vuetify/src/locale/el.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Παρακαλώ επιλέξτε τουλάχιστον μία τιμή', pattern: 'Μη έγκυρη μορφή', }, + command: { + search: 'Πληκτρολογήστε μια εντολή ή αναζητήστε...', + }, hotkey: { then: 'στη συνέχεια', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/en.ts b/packages/vuetify/src/locale/en.ts index 2873e8ea28f..5725776d6dc 100644 --- a/packages/vuetify/src/locale/en.ts +++ b/packages/vuetify/src/locale/en.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Please choose at least one value', pattern: 'Invalid format', }, + command: { + search: 'Type a command or search...', + }, hotkey: { then: 'then', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/es.ts b/packages/vuetify/src/locale/es.ts index 4134a351eb0..d92f2d12007 100644 --- a/packages/vuetify/src/locale/es.ts +++ b/packages/vuetify/src/locale/es.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Por favor, elige al menos un valor', pattern: 'Formato inválido', }, + command: { + search: 'Escribe un comando o busca...', + }, hotkey: { then: 'luego', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/et.ts b/packages/vuetify/src/locale/et.ts index cc766748a3a..948d4d61617 100644 --- a/packages/vuetify/src/locale/et.ts +++ b/packages/vuetify/src/locale/et.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Palun vali vähemalt üks väärtus', pattern: 'Vale vorming', }, + command: { + search: 'Sisestage käsk või otsige...', + }, hotkey: { then: 'siis', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/fa.ts b/packages/vuetify/src/locale/fa.ts index f7258c769ae..ca3b4ff802e 100644 --- a/packages/vuetify/src/locale/fa.ts +++ b/packages/vuetify/src/locale/fa.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'لطفاً حداقل یک مقدار انتخاب کنید', pattern: 'فرمت نامعتبر', }, + command: { + search: 'دستور را تایپ کنید یا جستجو کنید...', + }, hotkey: { then: 'سپس', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/fi.ts b/packages/vuetify/src/locale/fi.ts index 35847a9ce22..92a098b537d 100644 --- a/packages/vuetify/src/locale/fi.ts +++ b/packages/vuetify/src/locale/fi.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Valitse ainakin yksi arvo', pattern: 'Virheellinen muoto', }, + command: { + search: 'Kirjoita komento tai hae...', + }, hotkey: { then: 'sitten', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/fr.ts b/packages/vuetify/src/locale/fr.ts index 3beee263fd4..490ce2e60b3 100644 --- a/packages/vuetify/src/locale/fr.ts +++ b/packages/vuetify/src/locale/fr.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Veuillez choisir au moins une valeur', pattern: 'Format invalide', }, + command: { + search: 'Tapez une commande ou recherchez...', + }, hotkey: { then: 'puis', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/he.ts b/packages/vuetify/src/locale/he.ts index 1f33419731a..dd065716392 100644 --- a/packages/vuetify/src/locale/he.ts +++ b/packages/vuetify/src/locale/he.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'נא לבחור לפחות ערך אחד', pattern: 'פורמט לא תקף', }, + command: { + search: 'הקלד פקודה או חפש...', + }, hotkey: { then: 'אז', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/hr.ts b/packages/vuetify/src/locale/hr.ts index 8b2a2020927..e890ac1e669 100644 --- a/packages/vuetify/src/locale/hr.ts +++ b/packages/vuetify/src/locale/hr.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Odaberite barem jednu vrijednost', pattern: 'Nevaljan format', }, + command: { + search: 'Unesite naredbu ili pretražite...', + }, hotkey: { then: 'zatim', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/hu.ts b/packages/vuetify/src/locale/hu.ts index d9a263c8ec2..f1c3eecd85e 100644 --- a/packages/vuetify/src/locale/hu.ts +++ b/packages/vuetify/src/locale/hu.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Kérlek, válassz legalább egy értéket', pattern: 'Érvénytelen formátum', }, + command: { + search: 'Írjon be parancsot vagy keressen...', + }, hotkey: { then: 'majd', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/id.ts b/packages/vuetify/src/locale/id.ts index f356797533b..3ad58018456 100644 --- a/packages/vuetify/src/locale/id.ts +++ b/packages/vuetify/src/locale/id.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Pilih setidaknya satu nilai', pattern: 'Format tidak valid', }, + command: { + search: 'Ketik perintah atau cari...', + }, hotkey: { then: 'kemudian', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/it.ts b/packages/vuetify/src/locale/it.ts index 36dfa11468f..a6d7c097cf5 100644 --- a/packages/vuetify/src/locale/it.ts +++ b/packages/vuetify/src/locale/it.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Seleziona almeno un valore', pattern: 'Formato non valido', }, + command: { + search: 'Digita un comando o cerca...', + }, hotkey: { then: 'poi', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ja.ts b/packages/vuetify/src/locale/ja.ts index 85f9978a23d..ccbe6a24ed4 100644 --- a/packages/vuetify/src/locale/ja.ts +++ b/packages/vuetify/src/locale/ja.ts @@ -130,6 +130,9 @@ export default { notEmpty: '少なくとも1つの値を選んでください', pattern: '無効な形式です', }, + command: { + search: 'コマンドを入力するか検索...', + }, hotkey: { then: '次に', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/km.ts b/packages/vuetify/src/locale/km.ts index 9a30c44a995..38b3965036a 100644 --- a/packages/vuetify/src/locale/km.ts +++ b/packages/vuetify/src/locale/km.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'សូមជ្រើសរើសយ៉ាងហោចណាស់តម្លៃមួយ', pattern: 'ទម្រង់មិនត្រឹមត្រូវ', }, + command: { + search: 'វាយបញ្ចូលពាក្យបញ្ជា ឬស្វាគមន៍...', + }, hotkey: { then: 'បន្ទាប់មក', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ko.ts b/packages/vuetify/src/locale/ko.ts index a65f88b3006..ed5f56dfccc 100644 --- a/packages/vuetify/src/locale/ko.ts +++ b/packages/vuetify/src/locale/ko.ts @@ -130,6 +130,9 @@ export default { notEmpty: '최소 하나의 값을 선택해주세요', pattern: '형식이 유효하지 않습니다', }, + command: { + search: '명령을 입력하거나 검색하세요...', + }, hotkey: { then: '그 다음', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/lt.ts b/packages/vuetify/src/locale/lt.ts index 30fcb1aa300..6062f8482f6 100644 --- a/packages/vuetify/src/locale/lt.ts +++ b/packages/vuetify/src/locale/lt.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Prašome pasirinkti bent vieną reikšmę', pattern: 'Neteisingas formatas', }, + command: { + search: 'Įveskite komandą arba ieškokite...', + }, hotkey: { then: 'tada', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/lv.ts b/packages/vuetify/src/locale/lv.ts index 9565e182c22..984c9a8be7c 100644 --- a/packages/vuetify/src/locale/lv.ts +++ b/packages/vuetify/src/locale/lv.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Lūdzu, izvēlieties vismaz vienu vērtību', pattern: 'Nederīgs formāts', }, + command: { + search: 'Ierakstiet komandu vai meklējiet...', + }, hotkey: { then: 'tad', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/nl.ts b/packages/vuetify/src/locale/nl.ts index d8f1836b2cd..3d81cb4c0dd 100644 --- a/packages/vuetify/src/locale/nl.ts +++ b/packages/vuetify/src/locale/nl.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Kies ten minste één waarde', pattern: 'Ongeldig formaat', }, + command: { + search: 'Typ een opdracht of zoek...', + }, hotkey: { then: 'dan', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/no.ts b/packages/vuetify/src/locale/no.ts index 78ca3d0d3de..e75955fa9a0 100644 --- a/packages/vuetify/src/locale/no.ts +++ b/packages/vuetify/src/locale/no.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Vennligst velg minst én verdi', pattern: 'Ugyldig format', }, + command: { + search: 'Skriv en kommando eller søk...', + }, hotkey: { then: 'deretter', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/pl.ts b/packages/vuetify/src/locale/pl.ts index 9979bae0171..e0d7ee242ac 100644 --- a/packages/vuetify/src/locale/pl.ts +++ b/packages/vuetify/src/locale/pl.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Proszę wybrać co najmniej jedną wartość', pattern: 'Nieprawidłowy format', }, + command: { + search: 'Wpisz polecenie lub szukaj...', + }, hotkey: { then: 'następnie', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/pt.ts b/packages/vuetify/src/locale/pt.ts index 316f41b8f1d..a5b483533b4 100644 --- a/packages/vuetify/src/locale/pt.ts +++ b/packages/vuetify/src/locale/pt.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Por favor, escolha pelo menos um valor', pattern: 'Formato inválido', }, + command: { + search: 'Digite um comando ou pesquise...', + }, hotkey: { then: 'então', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ro.ts b/packages/vuetify/src/locale/ro.ts index 97df755835b..9e87acbe768 100644 --- a/packages/vuetify/src/locale/ro.ts +++ b/packages/vuetify/src/locale/ro.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Vă rugăm să alegeți cel puțin o valoare', pattern: 'Format invalid', }, + command: { + search: 'Tastați o comandă sau căutați...', + }, hotkey: { then: 'apoi', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/ru.ts b/packages/vuetify/src/locale/ru.ts index 0b0219bd8f1..3d1de3c880f 100644 --- a/packages/vuetify/src/locale/ru.ts +++ b/packages/vuetify/src/locale/ru.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Пожалуйста, выберите хотя бы одно значение', pattern: 'Недопустимый формат', }, + command: { + search: 'Введите команду или введите...', + }, hotkey: { then: 'затем', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/sk.ts b/packages/vuetify/src/locale/sk.ts index 5984b833475..d05a679a9d0 100644 --- a/packages/vuetify/src/locale/sk.ts +++ b/packages/vuetify/src/locale/sk.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Vyberte aspoň jednu hodnotu', pattern: 'Neplatný formát', }, + command: { + search: 'Zadajte príkaz alebo hľadajte...', + }, hotkey: { then: 'potom', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/sl.ts b/packages/vuetify/src/locale/sl.ts index f8eca19e25f..750b335cd93 100644 --- a/packages/vuetify/src/locale/sl.ts +++ b/packages/vuetify/src/locale/sl.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Izberite vsaj eno vrednost', pattern: 'Neveljaven format', }, + command: { + search: 'Vnesite ukaz ali iščite...', + }, hotkey: { then: 'nato', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/sr-Cyrl.ts b/packages/vuetify/src/locale/sr-Cyrl.ts index b291ab42930..9bf8b77c129 100644 --- a/packages/vuetify/src/locale/sr-Cyrl.ts +++ b/packages/vuetify/src/locale/sr-Cyrl.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Изаберите бар једну вредност', pattern: 'Неважећи формат', }, + command: { + search: 'Унесите команду или претражите...', + }, hotkey: { then: 'затим', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/sr-Latn.ts b/packages/vuetify/src/locale/sr-Latn.ts index 3e3fa40be35..42a1da612ae 100644 --- a/packages/vuetify/src/locale/sr-Latn.ts +++ b/packages/vuetify/src/locale/sr-Latn.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Izaberite bar jednu vrednost', pattern: 'Nevažeći format', }, + command: { + search: 'Unesite naredbu ili pretražite...', + }, hotkey: { then: 'zatim', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/sv.ts b/packages/vuetify/src/locale/sv.ts index 60d1f0083dd..a138b54fc30 100644 --- a/packages/vuetify/src/locale/sv.ts +++ b/packages/vuetify/src/locale/sv.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Välj minst ett värde', pattern: 'Ogiltigt format', }, + command: { + search: 'Skriv ett kommando eller sök...', + }, hotkey: { then: 'sedan', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/th.ts b/packages/vuetify/src/locale/th.ts index 6078ecf749c..57880adba1a 100644 --- a/packages/vuetify/src/locale/th.ts +++ b/packages/vuetify/src/locale/th.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'กรุณาเลือกอย่างน้อยหนึ่งค่า', pattern: 'รูปแบบไม่ถูกต้อง', }, + command: { + search: 'พิมพ์คำสั่งหรือค้นหา...', + }, hotkey: { then: 'จากนั้น', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/tr.ts b/packages/vuetify/src/locale/tr.ts index ba20826547e..bf2552028dc 100644 --- a/packages/vuetify/src/locale/tr.ts +++ b/packages/vuetify/src/locale/tr.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Lütfen en az bir değer seçin', pattern: 'Geçersiz biçim', }, + command: { + search: 'Komut yazın veya arayın...', + }, hotkey: { then: 'sonra', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/uk.ts b/packages/vuetify/src/locale/uk.ts index e086d3db57e..01e3a0c4467 100644 --- a/packages/vuetify/src/locale/uk.ts +++ b/packages/vuetify/src/locale/uk.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Будь ласка, виберіть принаймні одне значення', pattern: 'Недійсний формат', }, + command: { + search: 'Введіть команду або виконайте пошук...', + }, hotkey: { then: 'потім', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/vi.ts b/packages/vuetify/src/locale/vi.ts index 8ab27026cf4..e1ca997ed97 100644 --- a/packages/vuetify/src/locale/vi.ts +++ b/packages/vuetify/src/locale/vi.ts @@ -130,6 +130,9 @@ export default { notEmpty: 'Vui lòng chọn ít nhất một giá trị', pattern: 'Định dạng không hợp lệ', }, + command: { + search: 'Nhập lệnh hoặc tìm kiếm...', + }, hotkey: { then: 'sau đó', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/zh-Hans.ts b/packages/vuetify/src/locale/zh-Hans.ts index c3d72d9aa76..a894ac1a54c 100644 --- a/packages/vuetify/src/locale/zh-Hans.ts +++ b/packages/vuetify/src/locale/zh-Hans.ts @@ -130,6 +130,9 @@ export default { notEmpty: '请至少选择一个值', pattern: '格式无效', }, + command: { + search: '输入命令或搜索...', + }, hotkey: { then: '然后', ctrl: 'Ctrl', diff --git a/packages/vuetify/src/locale/zh-Hant.ts b/packages/vuetify/src/locale/zh-Hant.ts index c75cc5c10d1..f0c41532c80 100644 --- a/packages/vuetify/src/locale/zh-Hant.ts +++ b/packages/vuetify/src/locale/zh-Hant.ts @@ -130,6 +130,9 @@ export default { notEmpty: '請至少選擇一個值', pattern: '格式無效', }, + command: { + search: '輸入指令或搜尋...', + }, hotkey: { then: '然後', ctrl: 'Ctrl',