| <!-- |
| 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> |