// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview 'cr-searchable-drop-down' implements a search box with a
* suggestions drop down.
*
* If the update-value-on-input flag is set, value will be set to whatever is
* in the input box. Otherwise, value will only be set when an element in items
* is clicked.
*
* The |invalid| property tracks whether the user's current text input in the
* dropdown matches the previously saved dropdown value. This property can be
* used to disable certain user actions when the dropdown is invalid.
*
* Forked from
* ui/webui/resources/cr_elements/cr_searchable_drop_down/
* cr_searchable_drop_down.ts
*/
import '../cr_input/cr_input.js';
import '../cr_hidden_style.css.js';
import '../icons.html.js';
import '../cr_shared_style.css.js';
import '../cr_shared_vars.css.js';
import '//resources/polymer/v3_0/iron-dropdown/iron-dropdown.js';
import '//resources/polymer/v3_0/iron-icon/iron-icon.js';
import '//resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import {IronDropdownElement} from '//resources/polymer/v3_0/iron-dropdown/iron-dropdown.js';
import {DomRepeatEvent, PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {CrInputElement} from '../cr_input/cr_input.js';
import {getTemplate} from './cr_searchable_drop_down.html.js';
export interface CrSearchableDropDownElement {
$: {
search: CrInputElement,
dropdown: IronDropdownElement,
};
}
export class CrSearchableDropDownElement extends PolymerElement {
static get is() {
return 'cr-searchable-drop-down';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
autofocus: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
readonly: {
type: Boolean,
reflectToAttribute: true,
},
/**
* Whether space should be left below the text field to display an error
* message. Must be true for |errorMessage| to be displayed.
*/
errorMessageAllowed: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
/**
* When |errorMessage| is set, the text field is highlighted red and
* |errorMessage| is displayed beneath it.
*/
errorMessage: String,
/**
* Message to display next to the loading spinner.
*/
loadingMessage: String,
placeholder: String,
/**
* Used to track in real time if the |value| in cr-searchable-drop-down
* matches the value in the underlying cr-input. These values will differ
* after a user types in input that does not match a valid dropdown
* option. |invalid| is always false when |updateValueOnInput| is set to
* true. This is because when |updateValueOnInput| is set to true, we are
* not setting a restrictive set of valid options.
*/
invalid: {
type: Boolean,
value: false,
notify: true,
},
items: {
type: Array,
observer: 'onItemsChanged_',
},
value: {
type: String,
notify: true,
observer: 'updateInvalid_',
},
label: {
type: String,
value: '',
},
updateValueOnInput: Boolean,
showLoading: {
type: Boolean,
value: false,
},
searchTerm_: String,
dropdownRefitPending_: Boolean,
/**
* Whether the dropdown is currently open. Should only be used by CSS
* privately.
*/
opened_: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
};
}
override autofocus: boolean;
readonly: boolean;
errorMessageAllowed: boolean;
errorMessage: string;
loadingMessage: string;
placeholder: string;
invalid: boolean;
items: string[];
value: string;
label: string;
updateValueOnInput: boolean;
showLoading: boolean;
private searchTerm_: string;
private dropdownRefitPending_: boolean;
private opened_: boolean;
private openDropdownTimeoutId_: number = 0;
private resizeObserver_: ResizeObserver|null = null;
private pointerDownListener_: (e: Event) => void;
override connectedCallback() {
super.connectedCallback();
this.pointerDownListener_ = this.onPointerDown_.bind(this);
document.addEventListener('pointerdown', this.pointerDownListener_);
this.resizeObserver_ = new ResizeObserver(() => {
this.resizeDropdown_();
});
this.resizeObserver_.observe(this.$.search);
}
override ready() {
super.ready();
this.addEventListener('mousemove', this.onMouseMove_.bind(this));
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('pointerdown', this.pointerDownListener_);
this.resizeObserver_!.unobserve(this.$.search);
}
/**
* Enqueues a task to refit the iron-dropdown if it is open.
*/
private enqueueDropdownRefit_() {
const dropdown = this.$.dropdown;
if (!this.dropdownRefitPending_ && dropdown.opened) {
this.dropdownRefitPending_ = true;
setTimeout(() => {
dropdown.refit();
this.dropdownRefitPending_ = false;
}, 0);
}
}
/**
* Keeps the dropdown from expanding beyond the width of the search input when
* its width is specified as a percentage.
*/
private resizeDropdown_() {
const dropdown = this.$.dropdown.containedElement;
const dropdownWidth =
Math.max(dropdown.offsetWidth, this.$.search.offsetWidth);
dropdown.style.width = `${dropdownWidth}px`;
this.enqueueDropdownRefit_();
}
private openDropdown_() {
this.$.dropdown.open();
this.opened_ = true;
}
private closeDropdown_() {
if (this.openDropdownTimeoutId_) {
clearTimeout(this.openDropdownTimeoutId_);
}
this.$.dropdown.close();
this.opened_ = false;
}
/**
* Enqueues a task to open the iron-dropdown. Any pending task is canceled and
* a new task is enqueued.
*/
private enqueueOpenDropdown_() {
if (this.opened_) {
return;
}
if (this.openDropdownTimeoutId_) {
clearTimeout(this.openDropdownTimeoutId_);
}
this.openDropdownTimeoutId_ = setTimeout(this.openDropdown_.bind(this));
}
private onItemsChanged_() {
// Refit the iron-dropdown so that it can expand as neccessary to
// accommodate new items. Refitting is done on a new task because the change
// notification might not yet have propagated to the iron-dropdown.
this.enqueueDropdownRefit_();
}
private onFocus_() {
if (this.readonly) {
return;
}
this.openDropdown_();
}
private onMouseMove_(event: Event) {
const item = event.composedPath().find(elm => {
const element = elm as HTMLElement;
return element.classList && element.classList.contains('list-item');
}) as HTMLElement |
undefined;
if (!item) {
return;
}
// Select the item the mouse is hovering over. If the user uses the
// keyboard, the selection will shift. But once the user moves the mouse,
// selection should be updated based on the location of the mouse cursor.
const selectedItem = this.findSelectedItem_();
if (item === selectedItem) {
return;
}
if (selectedItem) {
selectedItem.removeAttribute('selected_');
}
item.setAttribute('selected_', '');
}
private onPointerDown_(event: Event) {
if (this.readonly) {
return;
}
const paths = event.composedPath();
const searchInput = this.$.search.inputElement;
if (paths.includes(this.$.dropdown)) {
// At this point, the search input field has lost focus. Since the user
// is still interacting with this element, give the search field focus.
searchInput.focus();
// Prevent any other field from gaining focus due to this event.
event.preventDefault();
} else if (paths.includes(searchInput)) {
// A click on the search input should open the dropdown. Opening the
// dropdown is done on a new task because when the IronDropdown element is
// opened, it may capture and cancel the touch event, preventing the
// searchInput field from receiving focus. Replacing iron-dropdown
// (crbug.com/1013408) will eliminate the need for this work around.
this.enqueueOpenDropdown_();
} else {
// A click outside either the search input or dropdown should close the
// dropdown. Implicitly, the search input has lost focus at this point.
this.closeDropdown_();
}
}
private onKeyDown_(event: KeyboardEvent) {
const dropdown = this.$.dropdown;
if (!dropdown.opened) {
if (this.readonly) {
return;
}
if (event.key === 'Enter') {
this.openDropdown_();
// Stop the default submit action.
event.preventDefault();
}
return;
}
event.stopPropagation();
switch (event.key) {
case 'Tab':
// Pressing tab will cause the input field to lose focus. Since the
// dropdown visibility is tied to focus, close the dropdown.
this.closeDropdown_();
break;
case 'ArrowUp':
case 'ArrowDown': {
const selected = this.findSelectedItemIndex_();
const items = dropdown.querySelectorAll<HTMLElement>('.list-item');
if (items.length === 0) {
break;
}
this.updateSelected_(items, selected, event.key === 'ArrowDown');
break;
}
case 'Enter': {
const selected = this.findSelectedItem_();
if (!selected) {
break;
}
selected.removeAttribute('selected_');
this.value = (dropdown.querySelector('dom-repeat')!.modelForElement(
selected) as unknown as {
item: string,
}).item;
this.searchTerm_ = '';
this.closeDropdown_();
// Stop the default submit action.
event.preventDefault();
break;
}
}
}
/**
* Finds the currently selected dropdown item.
* @return Currently selected dropdown item, or undefined if no item is
* selected.
*/
private findSelectedItem_(): HTMLElement|undefined {
const items =
Array.from(this.$.dropdown.querySelectorAll<HTMLElement>('.list-item'));
return items.find(item => item.hasAttribute('selected_'));
}
/**
* Finds the index of currently selected dropdown item.
* @return Index of the currently selected dropdown item, or -1 if no item is
* selected.
*/
private findSelectedItemIndex_(): number {
const items =
Array.from(this.$.dropdown.querySelectorAll<HTMLElement>('.list-item'));
return items.findIndex(item => item.hasAttribute('selected_'));
}
/**
* Updates the currently selected element based on keyboard up/down movement.
*/
private updateSelected_(
items: NodeListOf<HTMLElement>, currentIndex: number, moveDown: boolean) {
const numItems = items.length;
let nextIndex = 0;
if (currentIndex === -1) {
nextIndex = moveDown ? 0 : numItems - 1;
} else {
const delta = moveDown ? 1 : -1;
nextIndex = (numItems + currentIndex + delta) % numItems;
items[currentIndex]!.removeAttribute('selected_');
}
items[nextIndex]!.setAttribute('selected_', '');
// The newly selected item might not be visible because the dropdown needs
// to be scrolled. So scroll the dropdown if necessary.
items[nextIndex]!.scrollIntoViewIfNeeded();
}
private onInput_() {
this.searchTerm_ = this.$.search.value;
if (this.updateValueOnInput) {
this.value = this.$.search.value;
}
// If the user makes a change, ensure the dropdown is open. The dropdown is
// closed when the user makes a selection using the mouse or keyboard.
// However, focus remains on the input field. If the user makes a further
// change, then the dropdown should be shown.
this.openDropdown_();
// iron-dropdown sets its max-height when it is opened. If the current value
// results in no filtered items in the drop down list, the iron-dropdown
// will have a max-height for 0 items. If the user then clears the input
// field, a non-zero number of items might be displayed in the drop-down,
// but the height is still limited based on 0 items. This results in a tiny,
// but scollable dropdown. Refitting the dropdown allows it to expand to
// accommodate the new items.
this.enqueueDropdownRefit_();
// Need check to if the input is valid when the user types.
this.updateInvalid_();
}
private onSelect_(event: DomRepeatEvent<string>) {
this.closeDropdown_();
this.value = event.model.item;
this.searchTerm_ = '';
const selected = this.findSelectedItem_();
if (selected) {
// Reset the selection state.
selected.removeAttribute('selected_');
}
}
private filterItems_(searchTerm: string): ((s: string) => boolean)|null {
if (!searchTerm) {
return null;
}
return function(item) {
return item.toLowerCase().includes(searchTerm.toLowerCase());
};
}
private shouldShowErrorMessage_(
errorMessage: string, errorMessageAllowed: boolean): boolean {
return !!this.getErrorMessage_(errorMessage, errorMessageAllowed);
}
private getErrorMessage_(errorMessage: string, errorMessageAllowed: boolean):
string {
if (!errorMessageAllowed) {
return '';
}
return errorMessage;
}
/**
* This makes sure to reset the text displayed in the dropdown to the actual
* value in the cr-input for the use case where a user types in an invalid
* option then changes focus from the dropdown. This behavior is only for when
* updateValueOnInput is false. When updateValueOnInput is true, it is ok to
* leave the user's text in the dropdown search bar when focus is changed.
*/
private onBlur_() {
if (!this.updateValueOnInput) {
this.$.search.value = this.value;
}
// Need check to if the input is valid when the dropdown loses focus.
this.updateInvalid_();
}
/**
* If |updateValueOnInput| is true then any value is allowable so always set
* |invalid| to false.
*/
private updateInvalid_() {
this.invalid =
!this.updateValueOnInput && (this.value !== this.$.search.value);
}
}
declare global {
interface HTMLElementTagNameMap {
'cr-searchable-drop-down': CrSearchableDropDownElement;
}
}
customElements.define(
CrSearchableDropDownElement.is, CrSearchableDropDownElement);