blob: 4fdaf01e6ac77593e4eef0168029af22eca9d661 [file] [log] [blame]
// Copyright 2018 The Chromium Authors. All rights reserved.
// 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.
*/
Polymer({
is: 'cr-searchable-drop-down',
properties: {
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,
/** @type {!Array<string>} */
items: {
type: Array,
observer: 'onItemsChanged_',
},
/** @type {string} */
value: {
type: String,
notify: true,
},
/** @type {string} */
label: {
type: String,
value: '',
},
/** @type {boolean} */
updateValueOnInput: Boolean,
/** @type {boolean} */
showLoading: {
type: Boolean,
value: false,
},
/** @private {string} */
searchTerm_: String,
/** @private {boolean} */
dropdownRefitPending_: Boolean,
/**
* Whether the dropdown is currently open. Should only be used by CSS
* privately.
* @private {boolean}
*/
opened_: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
},
listeners: {
'mousemove': 'onMouseMove_',
},
/** @override */
attached: function() {
this.mouseDownListener_ = this.onMouseDown_.bind(this);
document.addEventListener('mousedown', this.mouseDownListener_);
},
/** @override */
detached: function() {
document.removeEventListener('mousedown', this.mouseDownListener_);
},
/**
* Enqueues a task to refit the iron-dropdown if it is open.
* @private
*/
enqueueDropdownRefit_: function() {
const dropdown = this.$$('iron-dropdown');
if (!this.dropdownRefitPending_ && dropdown.opened) {
this.dropdownRefitPending_ = true;
setTimeout(() => {
dropdown.refit();
this.dropdownRefitPending_ = false;
}, 0);
}
},
/** @private */
openDropdown_: function() {
this.$$('iron-dropdown').open();
this.opened_ = true;
},
/** @private */
closeDropdown_: function() {
this.$$('iron-dropdown').close();
this.opened_ = false;
},
/**
* @param {!Array<string>} oldValue
* @param {!Array<string>} newValue
* @private
*/
onItemsChanged_: function(oldValue, newValue) {
// 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_: function() {
if (this.readonly) {
return;
}
this.openDropdown_();
},
/**
* @param {!Event} event
* @private
*/
onMouseMove_: function(event) {
const item = event.composedPath().find(
elm => elm.classList && elm.classList.contains('list-item'));
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_', '');
},
/**
* @param {!Event} event
* @private
*/
onMouseDown_: function(event) {
if (this.readonly) {
return;
}
const paths = event.composedPath();
const searchInput = this.$.search.inputElement;
const dropdown = /** @type {!Element} */ (this.$$('iron-dropdown'));
if (paths.includes(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.
this.openDropdown_();
} 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_();
}
},
/**
* @param {!Event} event
* @suppress {missingProperties} Property modelForElement never defined on
* Element
* @private
*/
onKeyDown_: function(event) {
const dropdown = this.$$('iron-dropdown');
if (!dropdown.opened) {
return;
}
event.stopPropagation();
switch (event.code) {
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.getElementsByClassName('list-item');
if (items.length == 0) {
break;
}
this.updateSelected_(items, selected, event.code == 'ArrowDown');
break;
}
case 'Enter': {
const selected = this.findSelectedItem_();
if (!selected) {
break;
}
selected.removeAttribute('selected_');
this.value =
dropdown.querySelector('dom-repeat').modelForElement(selected).item;
this.searchTerm_ = '';
this.closeDropdown_();
// Stop the default submit action.
event.preventDefault();
break;
}
}
},
/**
* Finds the currently selected dropdown item.
* @return {Element|undefined} Currently selected dropdown item, or undefined
* if no item is selected.
* @private
*/
findSelectedItem_: function() {
const dropdown = this.$$('iron-dropdown');
const items = Array.from(dropdown.getElementsByClassName('list-item'));
return items.find(item => item.hasAttribute('selected_'));
},
/**
* Finds the index of currently selected dropdown item.
* @return {number} Index of the currently selected dropdown item, or -1 if
* no item is selected.
* @private
*/
findSelectedItemIndex_: function() {
const dropdown = this.$$('iron-dropdown');
const items = Array.from(dropdown.getElementsByClassName('list-item'));
return items.findIndex(item => item.hasAttribute('selected_'));
},
/**
* Updates the currently selected element based on keyboard up/down movement.
* @param {!HTMLCollection} items
* @param {number} currentIndex
* @param {boolean} moveDown
* @private
*/
updateSelected_: function(items, currentIndex, moveDown) {
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_: function() {
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_();
},
/*
* @param {{model:Object}} event
* @private
*/
onSelect_: function(event) {
this.closeDropdown_();
this.value = event.model.item;
this.searchTerm_ = '';
const selected = this.findSelectedItem_();
if (selected) {
// Reset the selection state.
selected.removeAttribute('selected_');
}
},
/** @private */
filterItems_: function(searchTerm) {
if (!searchTerm) {
return null;
}
return function(item) {
return item.toLowerCase().includes(searchTerm.toLowerCase());
};
},
/**
* @param {string} errorMessage
* @param {boolean} errorMessageAllowed
* @return {boolean}
* @private
*/
shouldShowErrorMessage_: function(errorMessage, errorMessageAllowed) {
return !!this.getErrorMessage_(errorMessage, errorMessageAllowed);
},
/**
* @param {string} errorMessage
* @param {boolean} errorMessageAllowed
* @return {string}
* @private
*/
getErrorMessage_: function(errorMessage, errorMessageAllowed) {
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_ : function () {
if (!this.updateValueOnInput) {
this.$.search.value = this.value;
}
}
});