diff --git a/packages/combo-box/src/vaadin-combo-box-items-mixin.d.ts b/packages/combo-box/src/vaadin-combo-box-items-mixin.d.ts index 311610bef0c..10eed9d4c17 100644 --- a/packages/combo-box/src/vaadin-combo-box-items-mixin.d.ts +++ b/packages/combo-box/src/vaadin-combo-box-items-mixin.d.ts @@ -6,11 +6,33 @@ import type { Constructor } from '@open-wc/dedupe-mixin'; import type { ComboBoxBaseMixinClass } from './vaadin-combo-box-base-mixin.js'; +export type ComboBoxAutoFocusPartialMatch = 'first-match' | 'none' | 'only-match'; + export declare function ComboBoxItemsMixin>( base: T, ): Constructor & Constructor> & T; export declare class ComboBoxItemsMixinClass { + /** + * Controls whether an item whose label partially matches the typed + * filter is automatically focused. The focused item is highlighted + * in the dropdown while typing and is selected when committing the + * value, for example on Enter press: + * + * - `none` (default): do not focus partial matches. + * - `first-match`: focus the first item in the filtered results. + * - `only-match`: focus the item when filtering narrows the results to a single item. + * + * An item whose label matches the filter exactly is always focused, + * regardless of this property. Matching is case-insensitive. A partial + * match is not focused when `allowCustomValue` is enabled, or while + * the dropdown is closed. For example, with `autoOpenDisabled`, typing + * does not focus or select a match until the dropdown is opened. + * + * @attr {none|first-match|only-match} auto-focus-partial-match + */ + autoFocusPartialMatch: ComboBoxAutoFocusPartialMatch; + /** * A full set of items to filter the visible options from. * The items can be of either `String` or `Object` type. diff --git a/packages/combo-box/src/vaadin-combo-box-items-mixin.js b/packages/combo-box/src/vaadin-combo-box-items-mixin.js index b9db6bb499b..ef0607942fe 100644 --- a/packages/combo-box/src/vaadin-combo-box-items-mixin.js +++ b/packages/combo-box/src/vaadin-combo-box-items-mixin.js @@ -62,6 +62,29 @@ export const ComboBoxItemsMixin = (superClass) => sync: true, }, + /** + * Controls whether an item whose label partially matches the typed + * filter is automatically focused. The focused item is highlighted + * in the dropdown while typing and is selected when committing the + * value, for example on Enter press: + * + * - `none` (default): do not focus partial matches. + * - `first-match`: focus the first item in the filtered results. + * - `only-match`: focus the item when filtering narrows the results to a single item. + * + * An item whose label matches the filter exactly is always focused, + * regardless of this property. Matching is case-insensitive. A partial + * match is not focused when `allowCustomValue` is enabled, or while + * the dropdown is closed. For example, with `autoOpenDisabled`, typing + * does not focus or select a match until the dropdown is opened. + * + * @attr {none|first-match|only-match} auto-focus-partial-match + */ + autoFocusPartialMatch: { + type: String, + value: 'none', + }, + /** * Filtering string the user has typed into the input field. */ @@ -161,6 +184,22 @@ export const ComboBoxItemsMixin = (superClass) => this.setProperties(props); } + /** + * Override method from `ComboBoxBaseMixin` to focus the item matching + * the filter when the dropdown is opened after typing, which is possible + * when `autoOpenDisabled` is enabled. + * + * @protected + * @override + */ + _onOpened() { + super._onOpened(); + + if (this.filter && this._focusedIndex === -1) { + this._focusedIndex = this.__getItemIndexByFilter(this._dropdownItems); + } + } + /** * Override method from `ComboBoxBaseMixin` to handle item label path. * @protected @@ -282,4 +321,28 @@ export const ComboBoxItemsMixin = (superClass) => return this._getItemLabel(item).toString().toLowerCase() === label.toString().toLowerCase(); }); } + + /** @private */ + __getItemIndexByFilter(items) { + // An item whose label matches the filter exactly takes precedence. + const exactMatchIndex = this.__getItemIndexByLabel(items, this.filter); + if (exactMatchIndex > -1) { + return exactMatchIndex; + } + + if (!this.opened || !items || items.length === 0 || !this.filter || this.allowCustomValue) { + return -1; + } + + if ( + this.autoFocusPartialMatch === 'first-match' || + (this.autoFocusPartialMatch === 'only-match' && items.length === 1) + ) { + // Skip an item that is not yet loaded. Once the item is loaded, + // the focused index is updated again. + return items[0] instanceof ComboBoxPlaceholder ? -1 : 0; + } + + return -1; + } }; diff --git a/packages/combo-box/src/vaadin-combo-box-mixin.js b/packages/combo-box/src/vaadin-combo-box-mixin.js index c89a7912153..8396d071dd6 100644 --- a/packages/combo-box/src/vaadin-combo-box-mixin.js +++ b/packages/combo-box/src/vaadin-combo-box-mixin.js @@ -281,6 +281,8 @@ export const ComboBoxMixin = (superClass) => * @override */ _onOpened() { + super._onOpened(); + this.dispatchEvent(new CustomEvent('vaadin-combo-box-dropdown-opened', { bubbles: true, composed: true })); // _detectAndDispatchChange() should not consider value changes done before opening @@ -542,7 +544,7 @@ export const ComboBoxMixin = (superClass) => } else { // When the user filled in something that is different from the current value = filtering is enabled, // set the focused index to the item that matches the filter query. - this._focusedIndex = this.__getItemIndexByLabel(newItems, this.filter); + this._focusedIndex = this.__getItemIndexByFilter(newItems); } } diff --git a/packages/combo-box/src/vaadin-combo-box.d.ts b/packages/combo-box/src/vaadin-combo-box.d.ts index d3ca9443f85..078aa698238 100644 --- a/packages/combo-box/src/vaadin-combo-box.d.ts +++ b/packages/combo-box/src/vaadin-combo-box.d.ts @@ -30,6 +30,7 @@ export { ComboBoxDataProviderCallback, ComboBoxDataProviderParams, } from './vaadin-combo-box-data-provider-mixin.js'; +export { ComboBoxAutoFocusPartialMatch } from './vaadin-combo-box-items-mixin.js'; export { ComboBoxDefaultItem, ComboBoxItemModel, ComboBoxRenderer } from './vaadin-combo-box-mixin.js'; /** diff --git a/packages/combo-box/test/auto-focus-partial-match.test.js b/packages/combo-box/test/auto-focus-partial-match.test.js new file mode 100644 index 00000000000..27cdfc0ea27 --- /dev/null +++ b/packages/combo-box/test/auto-focus-partial-match.test.js @@ -0,0 +1,220 @@ +import { expect } from '@vaadin/chai-plugins'; +import { sendKeys } from '@vaadin/test-runner-commands'; +import { aTimeout, fixtureSync, nextRender, outsideClick } from '@vaadin/testing-helpers'; +import '../src/vaadin-combo-box.js'; +import { getFocusedItemIndex } from './helpers.js'; + +describe('auto-focus-partial-match', () => { + let comboBox, inputElement; + + beforeEach(async () => { + [comboBox] = fixtureSync( + `
+ + +
`, + ).children; + await nextRender(); + inputElement = comboBox.inputElement; + inputElement.focus(); + }); + + describe('none (default)', () => { + beforeEach(() => { + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should be none by default', () => { + expect(comboBox.autoFocusPartialMatch).to.equal('none'); + }); + + it('should highlight the exact match', async () => { + await sendKeys({ type: 'grape' }); + expect(getFocusedItemIndex(comboBox)).to.equal(1); + }); + + it('should not highlight partial matches', async () => { + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex(comboBox)).to.equal(-1); + }); + + describe('value commit', () => { + it('should commit the exact match on Enter', async () => { + await sendKeys({ type: 'grape' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal('grape'); + }); + + it('should commit the exact match on Tab', async () => { + await sendKeys({ type: 'grape' }); + await sendKeys({ press: 'Tab' }); + expect(comboBox.value).to.equal('grape'); + }); + + it('should commit the exact match on outside click', async () => { + await sendKeys({ type: 'grape' }); + outsideClick(); + expect(comboBox.value).to.equal('grape'); + }); + + it('should not commit the partial match on Enter', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal(''); + }); + }); + }); + + describe('first-match', () => { + beforeEach(() => { + comboBox.autoFocusPartialMatch = 'first-match'; + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should highlight the first partial match', async () => { + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex(comboBox)).to.equal(0); + }); + + it('should highlight the exact match when there is one', async () => { + await sendKeys({ type: 'grape' }); + expect(getFocusedItemIndex(comboBox)).to.equal(1); + }); + + it('should not highlight the first partial match when custom values are allowed', async () => { + comboBox.allowCustomValue = true; + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex(comboBox)).to.equal(-1); + }); + + it('should not highlight anything when the filter is empty', () => { + comboBox.open(); + expect(getFocusedItemIndex(comboBox)).to.equal(-1); + }); + + describe('value commit', () => { + it('should commit the first partial match on Enter', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal('grapefruit'); + }); + + it('should not commit on Enter when no items match', async () => { + await sendKeys({ type: 'xyz' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal(''); + }); + + it('should commit the first partial match on Tab', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Tab' }); + expect(comboBox.value).to.equal('grapefruit'); + }); + + it('should commit the first partial match on outside click', async () => { + await sendKeys({ type: 'grap' }); + outsideClick(); + expect(comboBox.value).to.equal('grapefruit'); + }); + }); + }); + + describe('only-match', () => { + beforeEach(() => { + comboBox.autoFocusPartialMatch = 'only-match'; + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should highlight the only partial match', async () => { + await sendKeys({ type: 'ban' }); + expect(getFocusedItemIndex(comboBox)).to.equal(0); + }); + + it('should not highlight anything when multiple items match', async () => { + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex(comboBox)).to.equal(-1); + }); + + it('should highlight the exact match when there is one', async () => { + await sendKeys({ type: 'grape' }); + expect(getFocusedItemIndex(comboBox)).to.equal(1); + }); + + describe('value commit', () => { + it('should commit the only partial match on Enter', async () => { + await sendKeys({ type: 'grapef' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal('grapefruit'); + }); + + it('should not commit on Enter when multiple items match', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal(''); + }); + }); + }); + + describe('autoOpenDisabled', () => { + beforeEach(() => { + comboBox.autoOpenDisabled = true; + comboBox.autoFocusPartialMatch = 'first-match'; + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should commit the exact match on Enter while closed', async () => { + await sendKeys({ type: 'grape' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal('grape'); + }); + + it('should not commit the first partial match on Enter while closed', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal(''); + }); + + it('should highlight the first partial match when opening the dropdown after typing', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'ArrowDown' }); + expect(getFocusedItemIndex(comboBox)).to.equal(0); + }); + + it('should commit the first partial match on Enter after opening the dropdown', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.value).to.equal('grapefruit'); + }); + }); + + describe('lazy loading', () => { + beforeEach(() => { + const allItems = ['apple', 'banana', 'grapefruit', 'grape']; + comboBox.dataProvider = (params, callback) => { + setTimeout(() => { + const filteredItems = allItems.filter((item) => item.includes(params.filter)); + callback(filteredItems, filteredItems.length); + }); + }; + }); + + it('should highlight the first partial match after the page is loaded', async () => { + comboBox.autoFocusPartialMatch = 'first-match'; + + await sendKeys({ type: 'gra' }); + await aTimeout(0); + + expect(getFocusedItemIndex(comboBox)).to.equal(0); + }); + + it('should highlight the only partial match after the page is loaded', async () => { + comboBox.autoFocusPartialMatch = 'only-match'; + + await sendKeys({ type: 'ban' }); + await aTimeout(0); + + expect(getFocusedItemIndex(comboBox)).to.equal(0); + }); + }); +}); diff --git a/packages/combo-box/test/typings/combo-box.types.ts b/packages/combo-box/test/typings/combo-box.types.ts index eda20a33df8..091c3cc09bf 100644 --- a/packages/combo-box/test/typings/combo-box.types.ts +++ b/packages/combo-box/test/typings/combo-box.types.ts @@ -93,6 +93,7 @@ assertType<() => void>(narrowedComboBox.open); assertType<() => void>(narrowedComboBox.requestContentUpdate); assertType(narrowedComboBox.allowCustomValue); assertType(narrowedComboBox.autofocus); +assertType<'first-match' | 'none' | 'only-match'>(narrowedComboBox.autoFocusPartialMatch); assertType(narrowedComboBox.autoselect); assertType(narrowedComboBox.autoOpenDisabled); assertType(narrowedComboBox.opened); diff --git a/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box-mixin.js b/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box-mixin.js index 49750fbe940..4d270de06e3 100644 --- a/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box-mixin.js +++ b/packages/multi-select-combo-box/src/vaadin-multi-select-combo-box-mixin.js @@ -537,6 +537,16 @@ export const MultiSelectComboBoxMixin = (superClass) => __commitUserInput() { if (this._focusedIndex > -1) { const focusedItem = this._dropdownItems[this._focusedIndex]; + // Do not unselect an already selected item when it was focused by + // filtering, in which case the input value still equals the filter. + if ( + this._lastFilter && + this._lastFilter === this._inputElementValue && + this._findIndex(focusedItem, this.selectedItems, this.itemIdPath) !== -1 + ) { + this.__clearInternalValue(); + return; + } this.__selectItem(focusedItem); } else if (this._inputElementValue) { // Detect if input value doesn't match an existing item @@ -754,7 +764,7 @@ export const MultiSelectComboBoxMixin = (superClass) => } else { // When the user filled in something that is different from the current value = filtering is enabled, // set the focused index to the item that matches the filter query. - this._focusedIndex = this.__getItemIndexByLabel(newItems, this.filter); + this._focusedIndex = this.__getItemIndexByFilter(newItems); } } diff --git a/packages/multi-select-combo-box/test/auto-focus-partial-match.test.js b/packages/multi-select-combo-box/test/auto-focus-partial-match.test.js new file mode 100644 index 00000000000..1a4fdfbebd2 --- /dev/null +++ b/packages/multi-select-combo-box/test/auto-focus-partial-match.test.js @@ -0,0 +1,210 @@ +import { expect } from '@vaadin/chai-plugins'; +import { sendKeys } from '@vaadin/test-runner-commands'; +import { fixtureSync, nextRender, outsideClick } from '@vaadin/testing-helpers'; +import '../src/vaadin-multi-select-combo-box.js'; +import { getAllItems } from './helpers.js'; + +describe('auto-focus-partial-match', () => { + let comboBox, inputElement; + + function getFocusedItemIndex() { + return getAllItems(comboBox).findIndex((item) => item.hasAttribute('focused')); + } + + beforeEach(async () => { + comboBox = fixtureSync(''); + await nextRender(); + inputElement = comboBox.inputElement; + inputElement.focus(); + }); + + describe('none (default)', () => { + beforeEach(() => { + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should be none by default', () => { + expect(comboBox.autoFocusPartialMatch).to.equal('none'); + }); + + it('should highlight the exact match', async () => { + await sendKeys({ type: 'grape' }); + expect(getFocusedItemIndex()).to.equal(1); + }); + + it('should not highlight partial matches', async () => { + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex()).to.equal(-1); + }); + + describe('value commit', () => { + it('should select the exact match on Enter', async () => { + await sendKeys({ type: 'grape' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grape']); + }); + + it('should not select the exact match on outside click', async () => { + await sendKeys({ type: 'grape' }); + outsideClick(); + expect(comboBox.selectedItems).to.deep.equal([]); + }); + + it('should not select the partial match on Enter', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal([]); + }); + }); + }); + + describe('first-match', () => { + beforeEach(() => { + comboBox.autoFocusPartialMatch = 'first-match'; + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should highlight the first partial match', async () => { + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex()).to.equal(0); + }); + + it('should highlight the exact match when there is one', async () => { + await sendKeys({ type: 'grape' }); + expect(getFocusedItemIndex()).to.equal(1); + }); + + it('should not highlight the first partial match when custom values are allowed', async () => { + comboBox.allowCustomValue = true; + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex()).to.equal(-1); + }); + + it('should not highlight anything when the filter is empty', () => { + comboBox.open(); + expect(getFocusedItemIndex()).to.equal(-1); + }); + + describe('value commit', () => { + it('should select the first partial match on Enter', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grapefruit']); + }); + + it('should unselect the already selected item on Enter after highlighting it with arrow keys', async () => { + comboBox.selectedItems = ['grape']; + await sendKeys({ type: 'grap' }); + // Move the highlight from `grapefruit` to `grape`. + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal([]); + }); + + it('should not change the selection on Enter when no items match', async () => { + await sendKeys({ type: 'xyz' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal([]); + }); + + it('should not select the first partial match on outside click', async () => { + await sendKeys({ type: 'grap' }); + outsideClick(); + expect(comboBox.selectedItems).to.deep.equal([]); + }); + + describe('committing the same partial match again', () => { + beforeEach(async () => { + comboBox.selectedItems = ['grapefruit']; + await sendKeys({ type: 'grap' }); + }); + + it('should not unselect the item on Enter', async () => { + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grapefruit']); + }); + + it('should clear the input value on Enter', async () => { + await sendKeys({ press: 'Enter' }); + expect(inputElement.value).to.equal(''); + expect(comboBox.filter).to.equal(''); + }); + + it('should not unselect the item on outside click', () => { + outsideClick(); + expect(comboBox.selectedItems).to.deep.equal(['grapefruit']); + }); + }); + }); + }); + + describe('only-match', () => { + beforeEach(() => { + comboBox.autoFocusPartialMatch = 'only-match'; + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should highlight the only partial match', async () => { + await sendKeys({ type: 'ban' }); + expect(getFocusedItemIndex()).to.equal(0); + }); + + it('should not highlight anything when multiple items match', async () => { + await sendKeys({ type: 'gra' }); + expect(getFocusedItemIndex()).to.equal(-1); + }); + + it('should highlight the exact match when there is one', async () => { + await sendKeys({ type: 'grape' }); + expect(getFocusedItemIndex()).to.equal(1); + }); + + describe('value commit', () => { + it('should select the only partial match on Enter', async () => { + await sendKeys({ type: 'grapef' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grapefruit']); + }); + + it('should not change the selection on Enter when multiple items match', async () => { + comboBox.selectedItems = ['grapefruit']; + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grapefruit']); + }); + }); + }); + + describe('autoOpenDisabled', () => { + beforeEach(() => { + comboBox.autoOpenDisabled = true; + comboBox.autoFocusPartialMatch = 'first-match'; + comboBox.items = ['apple', 'banana', 'grapefruit', 'grape']; + }); + + it('should select the exact match on Enter while closed', async () => { + await sendKeys({ type: 'grape' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grape']); + }); + + it('should not select the first partial match on Enter while closed', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal([]); + }); + + it('should highlight the first partial match when opening the dropdown after typing', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'ArrowDown' }); + expect(getFocusedItemIndex()).to.equal(0); + }); + + it('should select the first partial match on Enter after opening the dropdown', async () => { + await sendKeys({ type: 'grap' }); + await sendKeys({ press: 'ArrowDown' }); + await sendKeys({ press: 'Enter' }); + expect(comboBox.selectedItems).to.deep.equal(['grapefruit']); + }); + }); +}); diff --git a/packages/multi-select-combo-box/test/typings/multi-select-combo-box.types.ts b/packages/multi-select-combo-box/test/typings/multi-select-combo-box.types.ts index 8764ee616b8..fcc741c9b53 100644 --- a/packages/multi-select-combo-box/test/typings/multi-select-combo-box.types.ts +++ b/packages/multi-select-combo-box/test/typings/multi-select-combo-box.types.ts @@ -89,6 +89,7 @@ assertType<() => boolean>(narrowedComboBox.checkValidity); assertType<() => boolean>(narrowedComboBox.validate); assertType(narrowedComboBox.allowCustomValue); assertType(narrowedComboBox.autoOpenDisabled); +assertType<'first-match' | 'none' | 'only-match'>(narrowedComboBox.autoFocusPartialMatch); assertType(narrowedComboBox.filter); assertType(narrowedComboBox.filteredItems); assertType(narrowedComboBox.items);