@vaadin/combo-box
Version:
Web Component for displaying a list of items with filtering
796 lines (694 loc) • 21.8 kB
JavaScript
/**
* @license
* Copyright (c) 2015 - 2026 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js';
import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js';
import { isElementFocused, isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
import { KeyboardMixin } from '@vaadin/a11y-base/src/keyboard-mixin.js';
import { isTouch } from '@vaadin/component-base/src/browser-utils.js';
import { InputMixin } from '@vaadin/field-base/src/input-mixin.js';
import { VirtualKeyboardController } from '@vaadin/field-base/src/virtual-keyboard-controller.js';
import { ComboBoxPlaceholder } from './vaadin-combo-box-placeholder.js';
/**
* @polymerMixin
* @mixes DisabledMixin
* @mixes FocusMixin
* @mixes InputMixin
* @mixes KeyboardMixin
* @param {function(new:HTMLElement)} superClass
*/
export const ComboBoxBaseMixin = (superClass) =>
class ComboBoxMixinBaseClass extends KeyboardMixin(InputMixin(DisabledMixin(FocusMixin(superClass)))) {
static get properties() {
return {
/**
* True if the dropdown is open, false otherwise.
*/
opened: {
type: Boolean,
notify: true,
value: false,
reflectToAttribute: true,
sync: true,
observer: '_openedChanged',
},
/**
* Set true to prevent the overlay from opening automatically.
* @attr {boolean} auto-open-disabled
*/
autoOpenDisabled: {
type: Boolean,
sync: true,
},
/**
* When present, it specifies that the field is read-only.
*/
readonly: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
/**
* @protected
*/
_focusedIndex: {
type: Number,
observer: '_focusedIndexChanged',
value: -1,
sync: true,
},
/**
* @type {!HTMLElement | undefined}
* @protected
*/
_toggleElement: {
type: Object,
observer: '_toggleElementChanged',
},
/**
* Set of items to be rendered in the dropdown.
* @protected
*/
_dropdownItems: {
type: Array,
sync: true,
},
/**
* Whether the overlay should be opened.
* @protected
*/
_overlayOpened: {
type: Boolean,
sync: true,
observer: '_overlayOpenedChanged',
},
};
}
constructor() {
super();
/**
* Reference to the `vaadin-combo-box-scroller` element instance.
* Do not define in `properties` to avoid triggering updates.
* @type {HTMLElement}
* @protected
*/
this._scroller;
/**
* Used to detect if focusout should be ignored due to touch.
* Do not define in `properties` to avoid triggering updates.
* @type {boolean}
* @protected
*/
this._closeOnBlurIsPrevented;
this._boundOverlaySelectedItemChanged = this._overlaySelectedItemChanged.bind(this);
this._boundOnClearButtonMouseDown = this.__onClearButtonMouseDown.bind(this);
this._boundOnClick = this._onClick.bind(this);
this._boundOnOverlayTouchAction = this._onOverlayTouchAction.bind(this);
this._boundOnTouchend = this._onTouchend.bind(this);
}
/**
* Tag name prefix used by scroller and items.
* @protected
* @return {string}
*/
get _tagNamePrefix() {
return 'vaadin-combo-box';
}
/**
* Override method inherited from `InputMixin`
* to customize the input element.
* @protected
* @override
*/
_inputElementChanged(input) {
super._inputElementChanged(input);
if (input) {
input.autocomplete = 'off';
input.autocapitalize = 'off';
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-expanded', !!this.opened);
// Disable the macOS Safari spell check auto corrections.
input.setAttribute('spellcheck', 'false');
// Disable iOS autocorrect suggestions.
input.setAttribute('autocorrect', 'off');
}
}
/** @protected */
firstUpdated() {
super.firstUpdated();
// Init scroller in `firstUpdated()` to ensure the `_scroller` reference
// is available by the time property observer runs. Also, do not store it
// in a reactive property to avoid triggering another unnecessary update.
this._initScroller();
}
/** @protected */
ready() {
super.ready();
this._initOverlay();
this.addEventListener('click', this._boundOnClick);
this.addEventListener('touchend', this._boundOnTouchend);
if (this.clearElement) {
this.clearElement.addEventListener('mousedown', this._boundOnClearButtonMouseDown);
}
this.addController(new VirtualKeyboardController(this));
}
/** @protected */
disconnectedCallback() {
super.disconnectedCallback();
// Close the overlay on detach
this.close();
}
/**
* Opens the dropdown list.
*/
open() {
// Prevent _open() being called when input is disabled or read-only
if (!this.disabled && !this.readonly) {
this.opened = true;
}
}
/**
* Closes the dropdown list.
*/
close() {
this.opened = false;
}
/** @private */
_initOverlay() {
const overlay = this.$.overlay;
overlay.addEventListener('touchend', this._boundOnOverlayTouchAction);
overlay.addEventListener('touchmove', this._boundOnOverlayTouchAction);
// Prevent blurring the input when clicking inside the overlay
overlay.addEventListener('mousedown', (e) => e.preventDefault());
// Manual two-way binding for the overlay "opened" property
overlay.addEventListener('opened-changed', (e) => {
this._overlayOpened = e.detail.value;
});
this._overlayElement = overlay;
}
/**
* Create and initialize the scroller element.
*
* @private
*/
_initScroller() {
const scroller = document.createElement(`${this._tagNamePrefix}-scroller`);
scroller.owner = this;
scroller.getItemLabel = this._getItemLabel.bind(this);
scroller.addEventListener('selection-changed', this._boundOverlaySelectedItemChanged);
this._renderScroller(scroller);
this._scroller = scroller;
}
/**
* Render the scroller element to the overlay.
*
* @private
*/
_renderScroller(scroller) {
scroller.setAttribute('slot', 'overlay');
// Prevent focusing scroller on input Tab
scroller.setAttribute('tabindex', '-1');
this.appendChild(scroller);
}
/**
* @type {boolean}
* @protected
*/
get _hasDropdownItems() {
return !!(this._dropdownItems && this._dropdownItems.length);
}
/** @private */
_overlayOpenedChanged(opened, wasOpened) {
if (opened) {
this._onOpened();
} else if (wasOpened && this._hasDropdownItems) {
this.close();
this._onOverlayClosed();
}
}
/** @private */
_focusedIndexChanged(index, oldIndex) {
if (oldIndex === undefined) {
return;
}
this._updateActiveDescendant(index);
}
/** @protected */
_isInputFocused() {
return this.inputElement && isElementFocused(this.inputElement);
}
/** @private */
_updateActiveDescendant(index) {
const input = this.inputElement;
if (!input) {
return;
}
const item = this._getItemElements().find((el) => el.index === index);
if (item) {
input.setAttribute('aria-activedescendant', item.id);
} else {
input.removeAttribute('aria-activedescendant');
}
}
/** @private */
_openedChanged(opened, wasOpened) {
// Prevent _close() being called when opened is set to its default value (false).
if (wasOpened === undefined) {
return;
}
if (opened) {
// For touch devices, we don't want to popup virtual keyboard
// unless input element is explicitly focused by the user.
if (!this._isInputFocused() && !isTouch) {
if (this.inputElement) {
this.inputElement.focus();
}
}
} else {
if (this.autoselect) {
// When the dropdown closes, the input remains focused. Mark that the next
// host click should re-trigger autoselect, since the normal focus event
// won't fire again.
this.__autoselectPending = true;
}
this._onClosed();
}
const input = this.inputElement;
if (input) {
input.setAttribute('aria-expanded', !!opened);
if (opened) {
input.setAttribute('aria-controls', this._scroller.id);
} else {
input.removeAttribute('aria-controls');
}
}
}
/** @private */
_onOverlayTouchAction() {
// On touch devices, blur the input on touch start inside the overlay, in order to hide
// the virtual keyboard. But don't close the overlay on this blur.
this._closeOnBlurIsPrevented = true;
this.inputElement.blur();
this._closeOnBlurIsPrevented = false;
}
/** @protected */
_isClearButton(event) {
return event.composedPath()[0] === this.clearElement;
}
/** @private */
__onClearButtonMouseDown(event) {
event.preventDefault(); // Prevent native focusout event
this.inputElement.focus();
}
/**
* @param {Event} event
* @protected
*/
_onClearButtonClick(event) {
event.preventDefault();
this._onClearAction();
}
/**
* @param {Event} event
* @private
*/
_onToggleButtonClick(event) {
// Prevent parent components such as `vaadin-grid`
// from handling the click event after it bubbles.
event.preventDefault();
if (this.opened) {
this.close();
} else {
this.open();
}
}
/**
* @param {Event} event
* @protected
*/
_onHostClick(event) {
if (!this.autoOpenDisabled) {
event.preventDefault();
this.open();
}
}
/** @private */
_onClick(event) {
// Select the input text on click when autoselect is enabled and a pending
// autoselect was set by a dropdown close. This handles the case where the
// overlay mousedown listener prevents blur on outside click, so the input
// never loses focus and subsequent clicks don't trigger the focus event
// where autoselect is normally handled.
if (this.autoselect && this.inputElement && this.__autoselectPending) {
// Skip if the user has already made a partial text selection
// (e.g. by click-dragging).
const isTextManuallySelected = this.inputElement.selectionStart !== this.inputElement.selectionEnd;
if (!isTextManuallySelected) {
this.inputElement.select();
}
}
this.__autoselectPending = false;
if (this._isClearButton(event)) {
this._onClearButtonClick(event);
} else if (event.composedPath().includes(this._toggleElement)) {
this._onToggleButtonClick(event);
} else {
this._onHostClick(event);
}
}
/** @private */
_onTouchend(event) {
if (!this.clearElement || event.composedPath()[0] !== this.clearElement) {
return;
}
event.preventDefault();
this._onClearAction();
}
/**
* Override an event listener from `KeyboardMixin`.
*
* @param {KeyboardEvent} e
* @protected
* @override
*/
_onKeyDown(e) {
super._onKeyDown(e);
if (e.key === 'ArrowDown') {
this._onArrowDown();
// Prevent caret from moving
e.preventDefault();
} else if (e.key === 'ArrowUp') {
this._onArrowUp();
// Prevent caret from moving
e.preventDefault();
}
}
/**
* Override to provide logic for item label path.
* @protected
*/
_getItemLabel(item) {
return item ? item.toString() : '';
}
/** @private */
_onArrowDown() {
if (this.opened) {
const items = this._dropdownItems;
if (items) {
this._focusedIndex = Math.min(items.length - 1, this._focusedIndex + 1);
this._prefillFocusedItemLabel();
}
} else {
this.open();
}
}
/** @private */
_onArrowUp() {
if (this.opened) {
if (this._focusedIndex > -1) {
this._focusedIndex = Math.max(0, this._focusedIndex - 1);
} else {
const items = this._dropdownItems;
if (items) {
this._focusedIndex = items.length - 1;
}
}
this._prefillFocusedItemLabel();
} else {
this.open();
}
}
/** @private */
_prefillFocusedItemLabel() {
if (this._focusedIndex > -1) {
const focusedItem = this._dropdownItems[this._focusedIndex];
this._inputElementValue = this._getItemLabel(focusedItem);
this._markAllSelectionRange();
}
}
/** @private */
_setSelectionRange(start, end) {
// Setting selection range focuses and/or moves the caret in some browsers,
// and there's no need to modify the selection range if the input isn't focused anyway.
// This affects Safari. When the overlay is open, and then hitting tab, browser should focus
// the next focusable element instead of the combo-box itself.
if (this._isInputFocused() && this.inputElement.setSelectionRange) {
this.inputElement.setSelectionRange(start, end);
}
}
/** @private */
_markAllSelectionRange() {
if (this._inputElementValue !== undefined) {
this._setSelectionRange(0, this._inputElementValue.length);
}
}
/** @private */
_clearSelectionRange() {
if (this._inputElementValue !== undefined) {
const pos = this._inputElementValue ? this._inputElementValue.length : 0;
this._setSelectionRange(pos, pos);
}
}
/**
* @protected
*/
_closeOrCommit() {
if (!this.opened) {
this._commitValue();
} else {
this.close();
}
}
/**
* Override an event listener from `KeyboardMixin`.
*
* @param {KeyboardEvent} e
* @protected
* @override
*/
_onEnter(e) {
// Do not commit value when custom values are disallowed and input value is not a valid option
// also stop propagation of the event, otherwise the user could submit a form while the input
// still contains an invalid value
if (!this._hasValidInputValue()) {
// Do not submit the surrounding form.
e.preventDefault();
// Do not trigger global listeners
e.stopPropagation();
return;
}
// Stop propagation of the enter event only if the dropdown is opened, this
// "consumes" the enter event for the action of closing the dropdown
if (this.opened) {
// Do not submit the surrounding form.
e.preventDefault();
// Do not trigger global listeners
e.stopPropagation();
}
this._closeOrCommit();
}
/**
* Override this method to detect whether valid value is provided.
* @protected
*/
_hasValidInputValue() {
return true;
}
/**
* Override an event listener from `KeyboardMixin`.
* Do not call `super` in order to override clear
* button logic defined in `InputControlMixin`.
*
* @param {!KeyboardEvent} e
* @protected
* @override
*/
_onEscape(e) {
if (
this.autoOpenDisabled &&
(this.opened || (this.value !== this._inputElementValue && this._inputElementValue.length > 0))
) {
// Auto-open is disabled
// The overlay is open or
// The input value has changed but the change hasn't been committed, so cancel it.
e.stopPropagation();
this._focusedIndex = -1;
this._onEscapeCancel();
} else if (this.opened) {
// Auto-open is enabled
// The overlay is open
e.stopPropagation();
if (this._focusedIndex > -1) {
// An item is focused, revert the input to the filtered value
this._focusedIndex = -1;
this._revertInputValue();
} else {
// No item is focused, cancel the change and close the overlay
this._onEscapeCancel();
}
} else if (this.clearButtonVisible && !!this.value && !this.readonly) {
e.stopPropagation();
// The clear button is visible and the overlay is closed, so clear the value.
this._onClearAction();
}
}
/**
* Override to handle canceling and closing overlay on Escape.
* @protected
*/
_onEscapeCancel() {
// To be implemented
}
/** @private */
_toggleElementChanged(toggleElement) {
if (toggleElement) {
// Don't blur the input on toggle mousedown
toggleElement.addEventListener('mousedown', (e) => e.preventDefault());
// Unfocus previously focused element if focus is not inside combo box (on touch devices)
toggleElement.addEventListener('click', () => {
if (isTouch && !this._isInputFocused()) {
document.activeElement.blur();
}
});
}
}
/**
* Override to implement logic for clearing value.
* @protected
*/
_onClearAction() {
// To be implemented
}
/**
* Override to implement logic for overlay opening.
* @protected
*/
_onOpened() {
// To be implemented
}
/**
* Override to implement logic for changing opened to false.
* @protected
*/
_onClosed() {
// To be implemented
}
/**
* Override to implement logic for overlay closing.
* @protected
*/
_onOverlayClosed() {
// To be implemented
}
/**
* Override to implement logic for committing value.
* @protected
*/
_commitValue() {
// To be implemented
}
/**
* Override to implement logic for value reverting.
* @protected
*/
_revertInputValue() {
this._inputElementValue = this.value;
this._clearSelectionRange();
}
/**
* Override an event listener from `InputMixin`.
* @param {!Event} event
* @protected
* @override
*/
_onInput(event) {
if (!this.opened && !this._isClearButton(event) && !this.autoOpenDisabled) {
this.opened = true;
}
}
/** @private */
_getItemElements() {
return Array.from(this._scroller.querySelectorAll(`${this._tagNamePrefix}-item`));
}
/** @protected */
_scrollIntoView(index) {
if (!this._scroller) {
return;
}
this._scroller.scrollIntoView(index);
}
/** @private */
_overlaySelectedItemChanged(e) {
// Stop this private event from leaking outside.
e.stopPropagation();
if (e.detail.item instanceof ComboBoxPlaceholder) {
// Placeholder items should not be selectable.
return;
}
if (this.opened) {
this._focusedIndex = this._dropdownItems.indexOf(e.detail.item);
this.close();
}
}
/**
* Override method inherited from `FocusMixin`
* to close the overlay on blur and commit the value.
*
* @param {boolean} focused
* @protected
* @override
*/
_setFocused(focused) {
super._setFocused(focused);
if (!focused) {
this.__autoselectPending = false;
}
if (!focused && !this.readonly && !this._closeOnBlurIsPrevented) {
this._handleFocusOut();
}
}
/**
* Override this method to provide custom logic for focusout.
* @protected
*/
_handleFocusOut() {
if (isKeyboardActive()) {
// Close on Tab key causing blur. With mouse, close on outside click instead.
this._closeOrCommit();
return;
}
if (!this.opened) {
this._commitValue();
} else if (!this._overlayOpened) {
// Combo-box is opened, but overlay is not visible -> custom value was entered.
// Make sure we close here as there won't be an "outside click" in this case.
this.close();
}
}
/**
* Override method inherited from `FocusMixin` to not remove focused
* state when focus moves to the overlay.
*
* @param {FocusEvent} event
* @return {boolean}
* @protected
* @override
*/
_shouldRemoveFocus(event) {
// VoiceOver on iOS fires `focusout` event when moving focus to the item in the dropdown.
// Do not focus the input in this case, because it would break announcement for the item.
if (event.relatedTarget && event.relatedTarget.localName === `${this._tagNamePrefix}-item`) {
return false;
}
// Do not blur when focus moves to the overlay
// Also, fixes the problem with `focusout` happening when clicking on the scroll bar on Edge
if (event.relatedTarget === this._overlayElement) {
event.composedPath()[0].focus();
return false;
}
return true;
}
};