blob: 3a527d328870f908997d1ff11fe943c62a6da99a [file] [log] [blame]
<!--
`paper-autocomplete` extends `paper-input`, adding autocomplete functionality.
The current value is used to prefix-match against the labels of this element's
child nodes (either `core-item` or `paper-item`). Those that match are shown,
while those that fail to match are hidden. The suggested items can be selected
using keyboard and mouse input.
Example:
<paper-autocomplete label="Country" on-change="{{changeHandler}}">
<core-item label="United States></core-item>
<core-item label="Canada"></core-item>
</paper-autocomplete>
All attributes and events of paper-input are inherited by paper-autocomplete.
New Attributes:
maxItems
caseSensitive
delimiter
allowWhitespace
New Events:
delete-item: An event indicating that the user wants a child item removed
-->
<polymer-element name="paper-autocomplete" extends="paper-input" attributes="maxItems caseSensitive delimiter allowWhitespace">
<template>
<link href="paper-autocomplete.css" rel="stylesheet">
<div id="frame">
<div id="top-input">
<shadow></shadow>
</div>
<div id="bottom-suggest">
<core-selector id="suggest-box" class="suggest-box-style hidden"
on-core-activate="{{selectAction}}">
<content id="items" select="core-item,paper-item"></content>
</core-selector>
</div>
</div>
</template>
<script>
Polymer({
/**
* Maximum number of items to show in the suggestion box.
* A null value indicates that there is no limit.
*
* @attribute: maxItems
* @type: integer | null
*/
maxItems: null,
/**
* Flag that indicates the case-sensitivity of prefix-matching.
*
* @attribute: caseSensitive
* @type: boolean
*/
caseSensitive: false,
/**
* If null, this element acts like a standard autocomplete unit.
* If given a value, then instead of replacing the full text string,
* this element will replace the substring after the last occurrence of
* the delimiter. In effect, making this like a list selector.
*
* Example: delimiter === '' (Append-only)
* When the text box has 'abcd', selecting 'ef' makes this 'abcdef'
* This choice of delimiter prevents suggestions from being filtered.
*
* Example: delimiter === ',' (CSV-style list)
* If the text box has 'red,orange,g', and 'green' is selected,
* then the text box will have 'red,orange,green'.
*
* Some delimiters work well with dynamic child node suggestions.
*
* Example: delimiter === '/' (Directory path)
* If the text box has 'drive/google/photos/' and 'May2012' is selected,
* then the text box will have 'drive/google/photos/May2012'.
*
* @attribute: delimiter
* @type: string | null
*/
delimiter: null,
/**
* Set to true to tolerate whitespace when filtering suggestions.
* Whitespace will also be maintained when a suggestion is selected.
* Note: This attribute is only useful when there is a delimiter.
*
* Example: delimiter === ',' and allowWhitespace === true
* If the text box has 'red, orange, g' and 'green' is selected,
* then the text box will have 'red, orange, green'.
*
* @attribute: allowWhitespace
* @type: boolean
*/
allowWhitespace: false,
/**
* Whether this element is currently being edited or not. Becomes true
* when the internal paper-input gains focus or receives input.
*
* @type: boolean
*/
editing: false,
/**
* A special flag indicating that the suggestion box is going to close.
*/
closing: false,
/**
* List of indexes of children that can be shown. The children are
* filtered by the current value.
*
* @type: Array<integer>
*/
showing: [],
/**
* Tracks the highlight position in the showing array. The corresponding
* child node is highlighted with the 'highlighted' class.
*
* @type: int | null
*/
highlightIndex: null,
/**
* Tracks the highlighted item's label. Used to update the highlight index
* when the user types new characters.
*
* @type: string | null
*/
highlightLabel: null,
/**
* Called when this node is added to the DOM. Sets up event listeners
* through the shadow DOM to its internal paper-input. Additionally,
* watches for mutations in its light DOM children.
*/
ready: function() {
this.super();
// Add listener to the suggestBox to cancel the suggestion box from
// closing if the user starts to click on it.
var suggestBox = this.$['suggest-box'];
suggestBox.addEventListener(
'mousedown',
this.cancelClosing.bind(this)
);
// Listen to inputs to determine editing status.
this.addEventListener('input', this.inputAction.bind(this));
this.addEventListener('keydown', this.keydownAction.bind(this));
this.addEventListener('focus', this.inputFocusAction.bind(this));
this.addEventListener('blur', this.inputBlurAction.bind(this));
// Capture changes to children
this.watchMutation();
},
/**
* Whenever the light DOM children change, then rerender this element.
*/
watchMutation: function() {
var change = function() {
this.filterSuggestions();
// Note: onMutation unbinds after each use, so we need to rebind.
this.onMutation(this, change.bind(this));
}
this.onMutation(this, change.bind(this));
},
/**
* Set the editing flag to true upon gaining focus.
*/
inputFocusAction: function(e) {
this.super(e);
if (!this.closing) {
this.editing = true;
}
this.closing = false;
this.filterSuggestions();
},
/**
* When blurring, potentially set editing to false.
* Has a delay before the suggestion box is closed in case the user starts
* to click on the suggestion box.
*/
inputBlurAction: function(e) {
this.super(e);
this.closing = true;
this.async(function() {
if (this.closing) {
this.showSuggestions(false);
this.editing = false;
}
}, null, 100);
},
/**
* If the mouse is pressed down on the suggestion box, set the closing
* flag to false.
*/
cancelClosing: function(e) {
this.async(function() {
if (this.closing) {
this.closing = false;
}
}, null, 0);
},
/**
* On the hidden input's 'input' event, the fact that data changed means
* that the user is editing. Re-render the suggestions.
*/
inputAction: function(e) {
this.closing = false;
this.editing = true;
this.filterSuggestions();
},
/**
* Filters the potential list of suggestions drawn from child node labels.
* Use a prefixMatch to decide which child nodes to show/hide.
* Finally, show the suggestion box if the conditions are right.
*/
filterSuggestions: function() {
var showing = [];
var childNodes = this.$.items.getDistributedNodes();
for (var i = 0; i < childNodes.length; i++) {
var item = childNodes[i];
// Show nodes with a prefix match, as long
// as the maxItems limit has not been reached.
if ((this.maxItems === null || showing.length < this.maxItems) &&
this.prefixMatch(this.getItemLabel(item))) {
showing.push(i);
item.style.display = '';
} else {
item.style.display = 'none';
}
}
this.showing = showing;
this.highlightSeekIndex();
this.showSuggestions(this.shouldShowSuggestions());
},
/**
* Update the highlight index, using the highlight label and the shown
* items. If there are no matches, reset the highlight variables.
* Note: There may be false updates if the suggestion list contains
* >1 of the same label.
*/
highlightSeekIndex: function() {
var found = false;
var childNodes = this.$.items.getDistributedNodes();
for (var i = 0; i < this.showing.length; i++) {
var itemIndex = this.showing[i];
var item = childNodes[itemIndex];
if (!found && this.getItemLabel(item) === this.highlightLabel) {
// There was a match; the new highlightIndex is the showing index.
this.highlightIndex = i;
found = true;
item.classList.toggle('highlighted', true);
} else {
// This isn't a match, so reset highlighting, just in case.
item.classList.toggle('highlighted', false);
}
}
// If not found, then nothing should be highlighted.
// Note: Cannot use highlightReset since the item may not exist at the
// current highlightIndex.
if (!found) {
this.highlightIndex = null;
this.highlightLabel = null;
}
},
/**
* Depending on the caseSensitive attribute, returns whether or not the
* given word has a prefix match with the current value of this element.
*/
prefixMatch: function(word) {
var curValue = this.getInputValueSuffix();
if (!this.caseSensitive) {
return word.toLowerCase().indexOf(curValue.toLowerCase()) === 0;
}
return word.indexOf(curValue) === 0;
},
/**
* Get the suffix of the current input value. If there is no delimiter,
* then the full input value is used instead.
*/
getInputValueSuffix: function() {
var value = this.value || '';
if (this.delimiter === '') {
return '';
} else if (!this.delimiter) {
return value;
}
// Find the suffix.
var parts = value.split(this.delimiter);
var suffix = parts[parts.length - 1];
// Handle excess whitespace.
if (this.allowWhitespace) {
return suffix.trimLeft();
}
return suffix;
},
/**
* Gets the label out of the item for prefix-matching and replacement.
* Note: paper-item and core-item mismatch; label is not available on the
* former anymore.
*/
getItemLabel: function(item) {
return item.textContent || item.label;
},
/**
* Determine whether or not the suggestBox should be shown.
* Show if this node is being edited and there are valid suggestions.
*/
shouldShowSuggestions: function() {
return this.editing && this.showing.length > 0;
},
/**
* The suggestion box is shown/hidden. If hidden, then additional internal
* values are reset.
*/
showSuggestions: function(shouldShow) {
this.$['suggest-box'].classList.toggle('hidden', !shouldShow);
if (!shouldShow) {
this.highlightReset();
this.closing = false;
}
},
/**
* Process the 'core-activate' event when a suggestion was tapped.
*/
selectAction: function(e) {
this.selectItem(e, e.detail.item);
},
/**
* When an item in the suggestion list is clicked or 'enter' is pressed
* while it is being highlighted, then replace this node's value.
*
* This node is no longer being edited, so re-render will hide it.
* Note: Fires this element's 'input' and 'change' events.
* Special: Click loses the element's focus, but pressing enter does not.
*/
selectItem: function(e, item) {
this.highlightReset();
this.replaceValue(this.getItemLabel(item));
this.fireInput();
this.changeAction(e);
this.showSuggestions(false);
this.editing = false;
},
/**
* Replace the current value and inputValue of this element.
* If there is a delimiter, replace only the suffix.
*/
replaceValue: function(newValue) {
if (this.delimiter === '') {
this.value += newValue;
} else if (!this.delimiter) {
this.value = newValue;
} else {
// Find the suffix.
var parts = this.value.split(this.delimiter);
var suffix = parts[parts.length - 1];
// Compute the replacement, taking into account whitespace.
var keepLength = 0;
if (this.allowWhitespace) {
keepLength = suffix.length - suffix.trimLeft().length
}
parts[parts.length - 1] = suffix.substring(0, keepLength) + newValue;
// Replace the input value.
this.value = parts.join(this.delimiter);
}
},
/**
* Fire the input event. (The invocation matches core-input's.)
*/
fireInput: function() {
this.fire('input', null, this);
},
/**
* Fire the delete-item event with the corresponding child item.
*/
fireDeleteItem: function(item) {
this.fire('delete-item', null, item);
},
/**
* Obtain the child item that is currently highlighted.
*/
getHighlightedItem: function() {
var selected = this.showing[this.highlightIndex];
return this.$.items.getDistributedNodes()[selected];
},
/**
* Remove highlight off the current item and stop tracking it.
*/
highlightReset: function() {
this.highlightItem(false);
this.highlightIndex = null;
},
/**
* Add/remove the highlighted class off the currently highlighted item.
*/
highlightItem: function(shouldHighlight) {
if (this.highlightIndex !== null) {
var item = this.getHighlightedItem();
item.classList.toggle('highlighted', shouldHighlight);
this.highlightLabel = this.getItemLabel(item);
} else {
this.highlightLabel = null;
}
},
/**
* Pressing up and down change the currently highlighted item.
* Pressing enter will select that item.
* Pressing escape will close the suggestBox.
* Pressing any other key will reset highlight.
*/
keydownAction: function(e) {
var KEY_DELETE = 46;
var KEY_UP = 38;
var KEY_DOWN = 40;
var KEY_ENTER = 13;
var KEY_ESC = 27;
// The suggest box is not open.
if (!this.shouldShowSuggestions()) {
// If up or down was hit, potentially open the suggest box.
if (e.keyCode === KEY_UP || e.keyCode === KEY_DOWN) {
this.editing = true;
this.showSuggestions(this.shouldShowSuggestions());
}
return;
}
// The suggest box is open. Handle the keypresses accordingly.
switch(e.keyCode) {
case KEY_DELETE:
// Shift+Delete => fire a 'delete-item' event
if (e.shiftKey === true && this.highlightIndex !== null) {
this.fireDeleteItem(this.getHighlightedItem());
}
break;
case KEY_UP: // Highlight the item above the current one.
e.preventDefault();
// unhighlight, change selection, highlight
this.highlightItem(false);
if (this.highlightIndex === null || this.highlightIndex === 0) {
this.highlightIndex = this.showing.length - 1;
} else {
this.highlightIndex--;
}
this.highlightItem(true);
break;
case KEY_DOWN: // Highlight the item below the current one.
e.preventDefault();
// unhighlight, change selection, highlight
this.highlightItem(false);
if (this.highlightIndex === null ||
this.highlightIndex === this.showing.length - 1) {
this.highlightIndex = 0;
} else {
this.highlightIndex++;
}
this.highlightItem(true);
break;
case KEY_ENTER: // Activate the highlighted node, if any.
if (this.highlightIndex !== null) {
e.preventDefault(); // do not commit a change
this.selectItem(e, this.getHighlightedItem());
} else {
this.showSuggestions(false);
this.editing = false;
}
break;
case KEY_ESC: // Hide the suggestions and reset the current highlight.
this.showSuggestions(false);
this.editing = false;
break;
}
}
});
</script>
</polymer-element>