Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/combobox-free-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@vuetify/v0": minor
---

feat(createCombobox): commit free-text values in non-strict mode

A non-strict combobox (the default) now accepts typed text that matches no registered option. Confirming with **Enter** or **Tab** mints a ticket for the typed value, selects it, and emits it through `v-model`; `strict` keeps the constrained behaviour and discards unmatched text on confirm. Adds a `commit()` action to the combobox context. Free-text values are kept out of keyboard navigation and pruned when superseded (single-select) or cleared.
10 changes: 5 additions & 5 deletions apps/docs/src/pages/components/forms/combobox.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,16 @@ The Combobox implements the [WAI-ARIA Combobox](https://www.w3.org/WAI/ARIA/apg/
| `aria-live` | `polite` | Error |

> [!TIP]
> `aria-autocomplete="both"` is set automatically when `strict` is enabled, signaling that the input value will revert to a valid option on close.
> `aria-autocomplete="both"` is set automatically when `strict` is enabled (versus `"list"` when free-text is allowed), signaling to assistive tech that the field resolves to one of the listed options.

### Keyboard Navigation

| Key | Action |
|-----|--------|
| `ArrowDown` / `ArrowUp` | Open dropdown, or move highlight down / up |
| `Enter` | Select highlighted item |
| `Escape` | Close dropdown |
| `Tab` | Close dropdown and move focus |
| `Enter` | Select highlighted item, or commit the typed value (unless `strict`) |
| `Escape` | Close dropdown and discard the typed value |
| `Tab` | Accept the highlighted item or commit the typed value, then move focus |
| `Home` | Move highlight to first item |
| `End` | Move highlight to last item |

Expand All @@ -231,7 +231,7 @@ Items render with `v-show` (not `v-if`) against the filtered set, so they're hid

??? How do I stop users from keeping free text that doesn't match an option?

Set `strict` on `Combobox.Root`. When the dropdown closes, the input reverts to the selected option's value — or clears if nothing is selected — so an unmatched query never sticks.
Set `strict` on `Combobox.Root`. By default, confirming with **Enter** or **Tab** commits unmatched text as a free-text value through `v-model`; with `strict`, unmatched text is discarded on confirm and the input reverts to the selected option's value — or clears if nothing is selected — so only registered options can be chosen.

??? How do I submit the selected value(s) with a native form?

Expand Down
13 changes: 12 additions & 1 deletion packages/0/src/components/Combobox/ComboboxControl.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@
case 'Enter': {
e.preventDefault()
const highlighted = context.cursor.highlightedId.value
if (!isUndefined(highlighted)) {
if (isUndefined(highlighted)) {
context.commit()
} else {
context.select(highlighted)
}
break
Expand All @@ -108,6 +110,15 @@
break
}
case 'Tab': {
const highlighted = context.cursor.highlightedId.value
if (isUndefined(highlighted)) {
context.commit()
} else {
context.select(highlighted)
}
// Multiple-select commit/select keeps the menu open; close it here so the
// listbox doesn't float over the next control (useClickOutside dismisses on
// pointer events, not on focus leaving via Tab).
context.close()
break
}
Expand Down
6 changes: 5 additions & 1 deletion packages/0/src/components/Combobox/ComboboxRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@
* - true: Prevents deselecting the last selected item
*/
mandatory?: boolean
/** Strict mode: reverts query to selected value on close if no match */
/**
* Constrains accepted values to registered options.
* - false (default): confirming (Enter/Tab) typed text with no match commits it as a new value
* - true: unmatched text is discarded on confirm; only registered options can be selected
*/
strict?: boolean
/** Manual error state override — forces invalid regardless of error messages */
error?: boolean
Expand Down
209 changes: 209 additions & 0 deletions packages/0/src/components/Combobox/index.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,215 @@ describe('combobox', () => {
})
})

describe('free-text commit', () => {
it('should commit unmatched free text on Enter when not strict', async () => {
const selected = ref<string>()
const { wrapper, open, isOpen, query } = await createCombobox({
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.setValue('Durian')
await input.trigger('input')
await nextTick()
await input.trigger('keydown', { key: 'Enter' })
await nextTick()

expect(selected.value).toBe('Durian')
expect(isOpen()).toBe(false)
expect(query()).toBe('Durian')
})

it('should discard unmatched free text on Enter when strict', async () => {
const selected = ref<string>()
const { wrapper, open, isOpen, query } = await createCombobox({
'strict': true,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.setValue('Durian')
await input.trigger('input')
await nextTick()
await input.trigger('keydown', { key: 'Enter' })
await nextTick()

expect(selected.value).toBeUndefined()
expect(isOpen()).toBe(false)
expect(query()).toBe('')
})

it('should select an exact match on Enter without minting a free-text value', async () => {
const selected = ref<string>()
const { wrapper, open } = await createCombobox({
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.setValue('Banana')
await input.trigger('input')
await nextTick()
await input.trigger('keydown', { key: 'Enter' })
await nextTick()

expect(selected.value).toBe('Banana')
})

it('should commit free text on Tab when nothing is highlighted', async () => {
const selected = ref<string>()
const { wrapper, open, isOpen } = await createCombobox({
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.setValue('Fig')
await input.trigger('input')
await nextTick()

const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
input.element.dispatchEvent(event)
await nextTick()

expect(selected.value).toBe('Fig')
expect(isOpen()).toBe(false)
expect(event.defaultPrevented).toBe(false)
})

it('should select the highlighted option on Tab instead of free text', async () => {
const selected = ref<string>()
const { wrapper, open, isOpen } = await createCombobox({
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.trigger('keydown', { key: 'Home' })
await nextTick()
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()

const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
input.element.dispatchEvent(event)
await nextTick()

expect(selected.value).toBe('Banana')
expect(isOpen()).toBe(false)
})

it('should not commit free text on Escape', async () => {
const selected = ref<string>()
const { wrapper, open, isOpen, query } = await createCombobox({
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.setValue('Guava')
await input.trigger('input')
await nextTick()
await input.trigger('keydown', { key: 'Escape' })
await nextTick()

expect(selected.value).toBeUndefined()
expect(isOpen()).toBe(false)
expect(query()).toBe('')
})

it('should prefer the highlighted option over free text on Enter', async () => {
const selected = ref<string>()
const { wrapper, open } = await createCombobox({
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string
},
})

open()
await nextTick()

const input = wrapper.find('input')
// Partial query keeps Apple and Banana filtered in; highlight Banana.
await input.setValue('a')
await input.trigger('input')
await nextTick()
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
await input.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
await input.trigger('keydown', { key: 'Enter' })
await nextTick()

expect(selected.value).toBe('Banana')
})

it('should close the menu on Tab in multiple-select mode after free-text commit', async () => {
const selected = ref<string[]>([])
const { wrapper, open, isOpen } = await createCombobox({
'multiple': true,
'strict': false,
'modelValue': selected.value,
'onUpdate:modelValue': v => {
selected.value = v as string[]
},
})

open()
await nextTick()

const input = wrapper.find('input')
await input.setValue('tag')
await input.trigger('input')
await nextTick()

const event = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
input.element.dispatchEvent(event)
await nextTick()

expect(selected.value).toEqual(['tag'])
expect(isOpen()).toBe(false)
})
})

describe('accessibility', () => {
it('should give input role=combobox', async () => {
const { wrapper } = await createCombobox()
Expand Down
Loading
Loading