Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
42f33b2
feat: add autoSelectMode to combo-box and multi-select-combo-box
vursen Aug 14, 2026
6153891
test: focus input in auto-select no-match test to fix WebKit
vursen Aug 14, 2026
53e6eb1
test: assert item highlight only in auto-select-mode suite
vursen Aug 14, 2026
5ddc674
test: assert item highlight only in MSCB auto-select-mode suite
vursen Aug 14, 2026
0cb5164
test: drop Escape and arrow navigation tests from auto-select-mode
vursen Aug 14, 2026
65aee75
test: clarify auto-select-mode test names
vursen Aug 14, 2026
06c82d1
test: cover full-match highlight in auto-select-mode suite
vursen Aug 14, 2026
f0c70fe
test: align auto-select-mode test names on matching-item wording
vursen Aug 14, 2026
6454b55
test: name auto-select-mode tests after mode terms
vursen Aug 14, 2026
49e5e85
refactor: rename full-match value of autoSelectMode to exact-match
vursen Aug 14, 2026
99bdcfd
refactor: rename only-match value of autoSelectMode to single-match
vursen Aug 14, 2026
0e884af
refactor: rename autoSelectMode to autoFocusPartialMatch
vursen Aug 17, 2026
9890206
docs: describe exact match focusing outside autoFocusPartialMatch values
vursen Aug 17, 2026
2fe7b56
chore: remove auto-focus-partial-match dev page
vursen Aug 17, 2026
5977686
docs: trim jsdoc on __getItemIndexByFilter to @private only
vursen Aug 17, 2026
06f8a00
test: align MSCB auto-focus-partial-match suite with combo-box
vursen Aug 17, 2026
832f4ca
fix: do not unselect item auto-focused by partial match on Enter
vursen Aug 17, 2026
0d3dd8f
test: cover combo-box auto-focus-partial-match commit triggers
vursen Aug 17, 2026
28da939
test: group auto-focus-partial-match commit tests into a suite
vursen Aug 17, 2026
f54a633
test: use mode terminology in auto-focus-partial-match test names
vursen Aug 17, 2026
33d91ed
test: rename match phrases to partial-match terminology
vursen Aug 17, 2026
3daee41
docs: drop blur and outside click from partial match commit examples
vursen Aug 18, 2026
fed4471
fix: focus partial match only while the dropdown is open
vursen Aug 18, 2026
88ac2c7
test: simplify auto-focus-partial-match test names
vursen Aug 18, 2026
eef5264
refactor: drop redundant comment in __getItemIndexByFilter
vursen Aug 18, 2026
02901a4
refactor: shorten comment in __commitUserInput
vursen Aug 18, 2026
97487ca
test: cover Tab as commit trigger for auto-focused partial match
vursen Aug 18, 2026
f32c884
test: add articles to auto-focus-partial-match test names
vursen Aug 18, 2026
7572c9f
test: restructure auto-focus-partial-match commit coverage
vursen Aug 18, 2026
911c507
test: add Tab navigation target to fix Firefox failures
vursen Aug 18, 2026
b973c44
Apply suggestion from @vursen
vursen Aug 18, 2026
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
22 changes: 22 additions & 0 deletions packages/combo-box/src/vaadin-combo-box-items-mixin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TItem, T extends Constructor<HTMLElement>>(
base: T,
): Constructor<ComboBoxBaseMixinClass> & Constructor<ComboBoxItemsMixinClass<TItem>> & T;

export declare class ComboBoxItemsMixinClass<TItem> {
/**
* 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.
Expand Down
63 changes: 63 additions & 0 deletions packages/combo-box/src/vaadin-combo-box-items-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Comment thread
vursen marked this conversation as resolved.
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;
}
};
4 changes: 3 additions & 1 deletion packages/combo-box/src/vaadin-combo-box-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/combo-box/src/vaadin-combo-box.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down
220 changes: 220 additions & 0 deletions packages/combo-box/test/auto-focus-partial-match.test.js
Original file line number Diff line number Diff line change
@@ -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(
`<div>
<vaadin-combo-box></vaadin-combo-box>
<input id="last-global-focusable" />
</div>`,
).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', () => {
Comment thread
web-padawan marked this conversation as resolved.
beforeEach(() => {
comboBox.autoFocusPartialMatch = 'first-match';
comboBox.items = ['apple', 'banana', 'grapefruit', 'grape'];
Comment thread
web-padawan marked this conversation as resolved.
});

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);
});
});
});
1 change: 1 addition & 0 deletions packages/combo-box/test/typings/combo-box.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ assertType<() => void>(narrowedComboBox.open);
assertType<() => void>(narrowedComboBox.requestContentUpdate);
assertType<boolean>(narrowedComboBox.allowCustomValue);
assertType<boolean>(narrowedComboBox.autofocus);
assertType<'first-match' | 'none' | 'only-match'>(narrowedComboBox.autoFocusPartialMatch);
assertType<boolean>(narrowedComboBox.autoselect);
assertType<boolean | null | undefined>(narrowedComboBox.autoOpenDisabled);
assertType<boolean>(narrowedComboBox.opened);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment thread
vursen marked this conversation as resolved.
}
}

Expand Down
Loading