blob: 361ed2c2bc71faebe420a780b048e9c9e4286c27 [file] [log] [blame]
<!--
The 'autocomplete-box' is a input box with autocomplete drop-down menu.
The drop-down menu has support for multi-select, grouping, and tag name.
Example usage:
<autocomplete-box dataList={{dataList}}
placeholder="course"
multi></autocomplete-box>
The 'dataList' is a list of object with the following properties:
[
{name: 'Skydiving'},
{name: 'Rock Climbing'},
...
]
Tag property adds a label to the right of the list item name.
[
{name: 'Skydiving'},
{name: 'Rock Climbing', tag: 'beginner'},
...
]
'autocomplete-box' supports grouping which shows group name and
group member items indented. Group name can be used to select and unselect
all group member items. This expects each group header to be an item with the
property 'head' sets to true and the following group items to have
property 'group' sets to the header item name.
[
{name: 'Outdoor', head: true},
{name: 'Skydiving', tag: '1 spot left', group: 'Outdoor'},
...
]
-->
<link rel="import" href="/components/core-icon-button/core-icon-button.html">
<link rel="import" href="/components/core-item/core-item.html">
<link rel="import" href="/components/core-menu/core-menu.html">
<link rel="import" href="/components/paper-input/paper-input-decorator.html">
<link rel="import" href="/components/paper-shadow/paper-shadow.html">
<link rel="import" href="/dashboard/static/autocomplete.html">
<polymer-element name="autocomplete-box"
attributes="placeholder dataList disabled multi value">
<template>
<style>
#container * {
margin-right: 3px;
}
#dropdown-container {
position: absolute;
background-color: white;
box-sizing: border-box;
border-radius: 2px;
z-index: 9999;
}
.dropdown-scroller {
overflow-y: auto;
overflow-x: hidden;
max-height: 300px;
padding-right: 8px;
}
#dropdown > core-item {
min-height: 25px;
color: #616161;
text-indent: 10px;
}
#dropdown > core-item[head] {
color: darkblue;
text-indent: 0;
}
#size-check {
display: inline-block;
position: absolute;
visibility: hidden;
}
.tag {
color: gray;
font-size: 90%;
padding-left: 5px;
margin-left: auto;
margin-right: auto;
text-align: right;
}
</style>
<div id="container">
<paper-input-decorator label="{{placeholder}}" floatinglabel="" layout vertical
id="textbox-container">
<input is="core-input"
on-click="{{showHideDropdown}}"
on-keyup="{{onTextboxKeyup}}"
on-keydown="{{onTextboxKeydown}}"
placeholder="{{placeholder}}"
disabled?="{{disabled}}"
id="textbox"
value="{{value}}">
</paper-input-decorator>
<paper-shadow id="dropdown-container" hidden="true">
<div class="dropdown-scroller" layered>
<core-menu id="dropdown"
on-core-activate="{{onDropdownSelect}}"
multi?="{{multi}}">
<template repeat="{{dataList}}">
<core-item label="{{name}}" head?="{{head}}"
hidden?="{{hidden}}">
<div class="{{{tag: tag} | tokenList}}">{{tag}}</div>
</core-item>
</template>
</core-menu>
</div>
</paper-shadow>
</div>
<span id="size-check">{{value}}</span>
</template>
<script>
'use strict';
Polymer('autocomplete-box', {
TEXTBOX_MIN_WIDTH: 152,
ready: function() {
this.value = '';
this.$.dropdown.selected = [];
this.selectedItems = [];
this.hasVisibleItems = true;
this.initAutocomplete();
},
/**
* Initializes Autocomplete class with current dataList.
*/
initAutocomplete: function() {
if (this.dataList) {
this.autocomplete = new autocomplete.Trie(this.dataList);
} else {
this.autocomplete = new autocomplete.Trie([]);
}
},
/**
* Sets the current dataList. This will keep selected items from
* previous dataList if they also exist in the new dataList.
* @param {Array} dataList List of drop-down items.
*/
setDataList: function(dataList) {
this.dataList = dataList;
this.updateCurrentSelection();
this.initAutocomplete();
},
/**
* Handles typing text in the textbox and navigating drop-down menu with
* arrow keys.
*/
onTextboxKeyup: function(event) {
var key = event.keyCode || event.charCode;
if (key == 8 || key == 46) { // Backspace and Delete.
if (this.value.length == 0) {
this.selectedItems = [];
this.$.dropdown.selected = [];
this.fire('dropdownselect');
}
}
if (!this.$['dropdown-container'].hidden) {
switch (key) {
case 40: // Arrow down.
this.selectNext(1);
return;
case 38: // Arrow up.
this.selectNext(-1);
return;
case 13: // Enter.
this.hideDropdown();
return;
}
}
this.updateAutocomplete();
},
onTextboxKeydown: function(event) {
// Since tab key loses focus of the textbox menu, we handle the event
// on keydown instead of keyup.
var key = event.keyCode || event.charCode;
if (key == 9) { // Tab.
this.hideDropdown();
}
},
/**
* Handles item selected on drop-down menu.
*/
onDropdownSelect: function(event, detail, sender) {
this.$.textbox.focus();
var item = detail.item.templateInstance.model;
var isSelected = detail.item.classList.contains('core-selected');
if (this.multi) {
if (item.head) {
this.onHeadItemSelected(item.name, isSelected);
} else {
this.onItemSelected(item.name, isSelected);
}
}
this.setItemSelection(item, isSelected);
this.value = this.getSelectedValues().join(',');
this.fire('dropdownselect');
},
/**
* On head item selected, either selects or unselects all of the
* subsequent non-head items.
* @param {string} headName Name of the head item.
* @param {boolean} isSelected Whether the head item was checked or not.
*/
onHeadItemSelected: function(headName, isSelected) {
for (var i = 0; i < this.dataList.length; i++) {
var item = this.dataList[i];
if (!item.head && item.group == headName) {
this.setItemSelection(item, isSelected);
this.setDropdownSelection(i, isSelected);
}
}
},
/**
* On non-head item selected, either select or unselect its head item.
* @param {string} headName Name of the head item.
* @param {boolean} isSelected Whether the head item was checked or not.
*/
onItemSelected: function(headName, isSelected) {
var allSelected = true;
for (var i = 0; i < this.dataList.length; i++) {
var it = this.dataList[i];
if (it.group == headName &&
this.$.dropdown.selected.indexOf(i) == -1) {
allSelected = false;
}
}
// Find head item and set selection.
for (var i = 0; i < this.dataList.length; i++) {
var it = this.dataList[i];
if (it.head && it.name == headName) {
this.setDropdownSelection(i, isSelected && allSelected);
}
}
},
/**
* Selects the next item in direction and unselect the current selected
* item. For multi-selection mode, this scrolls through non-head items.
* @param {number} direction Either 1 to select next item or -1 to select
* the previous item.
*/
selectNext: function(direction) {
var numItems = this.dataList.length;
var start = (direction) ? -1 : numItems;
var selected = this.$.dropdown.selected;
if (selected instanceof Array) {
if (selected.length > 0) {
start = selected[selected.length - 1];
}
} else if (selected != null) {
start = selected;
}
// Select next item that is not the head item.
for (var i = start + direction; i >= 0 && i < numItems;
i += direction) {
var item = this.dataList[i];
if (!item.head) {
this.setDropdownSelection(i, true);
this.setItemSelection(item, true);
this.value = this.getSelectedValues().join(',');
this.fire('dropdownselect');
return;
}
}
},
/**
* Updates current selection from 'this.dataList'.
* This tries to keep the current selection when 'this.dataList'
* changes.
*/
updateCurrentSelection: function() {
this.$.dropdown.selected = [];
var matchedItems = [];
for (var i = 0; i < this.selectedItems.length; i++) {
for (var j = 0; j < this.dataList.length; j++) {
if (this.itemEquals(this.selectedItems[i], this.dataList[j])) {
this.$.dropdown.selected.push(j);
matchedItems.push(this.selectedItems[i]);
break;
}
}
}
this.selectedItems = matchedItems;
this.value = this.getSelectedValues().join(',');
},
itemEquals: function(itemA, itemB) {
return itemA.name == itemB.name && itemA.group == itemB.group;
},
/**
* Gets a list of name from current selected items.
*/
getSelectedValues: function() {
var values = [];
this.selectedItems.forEach(function(item) {
values.push(item.name);
});
return values;
},
getSelectedItems: function() {
return this.selectedItems;
},
/**
* Adds or remove an index from dropbox's selected indices.
*/
setDropdownSelection: function(index, isSelected) {
if (!this.multi) {
this.$.dropdown.selected = [];
}
var foundIndex = this.$.dropdown.selected.indexOf(index);
if (isSelected) {
if (foundIndex == -1) {
this.$.dropdown.selected.push(index);
}
} else if (foundIndex >= 0) {
this.$.dropdown.selected.splice(foundIndex, 1);
}
},
/**
* Adds or removes an item from 'this.selectedItems'.
*/
setItemSelection: function(item, isSelected) {
if (!this.multi) {
this.selectedItems = [];
}
var itemIndex = this.getItemIndex(item);
if (isSelected) {
if (itemIndex == -1) {
this.selectedItems.push(item);
}
} else if (itemIndex > -1) {
this.selectedItems.splice(itemIndex, 1);
}
},
getItemIndex: function(item) {
for (var i = 0; i < this.selectedItems.length; i++) {
if (this.itemEquals(this.selectedItems[i], item)) {
return i;
}
}
return -1;
},
/**
* Updates drop-down menu with autocomplete suggestion items base on
* current value in the textbox.
*/
updateAutocomplete: function() {
var currentValue = null;
if (this.multi && this.value.length) {
var currentParts = this.value.split(',');
currentValue = currentParts[currentParts.length - 1];
// Remove only the last selected item.
var lastItem = this.selectedItems[this.selectedItems.length - 1];
if (this.selectedItems.length == currentParts.length) {
this.selectedItems.pop();
}
} else {
currentValue = this.value;
}
this.$.dropdown.selected = [];
this.dataList = this.autocomplete.search(currentValue);
if (this.multi) {
this.updateDropdownWithSelectedItems();
}
this.selectFirstMatch(currentValue);
this.fire('dropdownselect');
this.hasVisibleItems = this.dataList.length > 0;
this.showHideDropdown();
},
updateDropdownWithSelectedItems: function() {
for (var i = 0; i < this.selectedItems.length; i++) {
for (var j = 0; j < this.dataList.length; j++) {
if (this.itemEquals(this.selectedItems[i], this.dataList[j])) {
this.$.dropdown.selected.push(j);
break;
}
}
}
},
/**
* Selects the first non-head item if it is an exact match.
*/
selectFirstMatch: function(value) {
value = value.toLowerCase();
for (var i = 0; i < this.dataList.length; i++) {
var item = this.dataList[i];
if (!item.head) {
if (item.name.toLowerCase() == value) {
this.setItemSelection(item, true);
this.$.dropdown.selected.push(i);
}
return;
}
}
},
/**
* Adjusts the textbox size to fit the first selected item.
*/
updateTextboxSize: function() {
var values = this.getSelectedValues();
if (values.length == 0) {
this.$.textbox.style.width = this.TEXTBOX_MIN_WIDTH;
return;
}
// A hack for auto-resizing input box.
this.$['size-check'].innerHTML = values[0];
var newWidth = this.$['size-check'].offsetWidth + 5;
this.$.textbox.style.width = ((newWidth > this.TEXTBOX_MIN_WIDTH) ?
newWidth : this.TEXTBOX_MIN_WIDTH);
},
valueChanged: function(oldValue, newValue) {
// Ignore empty changes.
if (!oldValue && !newValue) {
return;
}
this.updateTextboxSize();
},
showHideDropdown: function(event) {
if (this.dataList.length > 0 && this.hasVisibleItems) {
this.showDropdown();
} else {
this.hideDropdown();
}
},
showDropdown: function() {
var textContainer = this.$['textbox-container'];
this.$['dropdown-container'].style.top = textContainer.offsetTop +
textContainer.offsetHeight + 'px';
this.$['dropdown-container'].style.left = textContainer.offsetLeft +
'px';
this.$['dropdown-container'].hidden = false;
document.addEventListener('click', this.hideDropdown.bind(this), true);
},
hideDropdown: function() {
this.$['dropdown-container'].hidden = true;
document.removeEventListener(
'click', this.hideDropdown.bind(this), true);
}
});
</script>
</polymer-element>