coral-spectrum/coral-component-autocomplete/src/scripts/Autocomplete.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {BaseComponent} from '../../../coral-base-component';
import {BaseFormField} from '../../../coral-base-formfield';
import {Tag} from '../../../coral-component-taglist';
import {SelectableCollection} from '../../../coral-collection';
import AutocompleteItem from './AutocompleteItem';
import {Textfield} from '../../../coral-component-textfield';
import {Icon} from '../../../coral-component-icon';
import '../../../coral-component-button';
import '../../../coral-component-list';
import '../../../coral-component-popover';
import '../../../coral-component-wait';
import base from '../templates/base';
import loadIndicator from '../templates/loadIndicator';
import {transform, validate, commons, i18n} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';
const CLASSNAME = '_coral-Autocomplete';
/**
The distance, in pixels, from the bottom of the List at which we assume the user has scrolled
to the bottom of the list.
@type {Number}
@ignore
*/
const SCROLL_BOTTOM_THRESHOLD = 50;
/**
The number of milliseconds for which scroll events should be debounced.
@type {Number}
@ignore
*/
const SCROLL_DEBOUNCE = 100;
/**
Enumeration for {@link Autocomplete} variants.
@typedef {Object} AutocompleteVariantEnum
@property {String} DEFAULT
A default, gray Autocomplete.
@property {String} QUIET
An Autocomplete with no border or background.
*/
const variant = {
DEFAULT: 'default',
QUIET: 'quiet'
};
/**
Enumeration for {@link Autocomplete} match options.
@typedef {Object} AutocompleteMatchEnum
@property {String} STARTSWITH
Include only matches that start with the user provided value.
@property {String} CONTAINS
Include only matches that contain the user provided value.
*/
const match = {
STARTSWITH: 'startswith',
CONTAINS: 'contains'
};
/**
@class Coral.Autocomplete
@classdesc An Autocomplete component that allows users to search and select from a list of options.
@htmltag coral-autocomplete
@extends {HTMLElement}
@extends {BaseComponent}
@extends {BaseFormField}
*/
const Autocomplete = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
/** @ignore */
constructor() {
super();
// Template
this._elements = {};
base.call(this._elements, {Icon, commons, i18n});
this._elements.tagList.reset = () => {
// Kill inner tagList reset so it doesn't interfer with the autocomplete reset
};
// Pre-define labellable element
this._labellableElement = this._elements.input;
const overlayId = this._elements.overlay.id;
const events = {
// ARIA Autocomplete role keyboard interaction
// http://www.w3.org/TR/wai-aria-practices/#autocomplete
'key:up [handle="input"]': '_handleInputUpKeypress',
'key:alt+up [handle="input"]': '_handleInputUpKeypress',
'key:down [handle="input"]': '_handleInputDownKeypress',
'key:alt+down [handle="input"]': '_handleInputDownKeypress',
'key:tab [handle="input"]': '_handleInputTabKeypress',
'key:shift+tab [handle="input"]': '_handleListFocusShift',
'capture:change [handle="input"]': '_handleInput',
'input [handle="input"]': '_handleInputEvent',
// Manually listen to keydown event due to CUI-3973
'keydown': '_handleInputKeypressEnter',
// Interaction
'click [handle="trigger"]': '_handleTriggerClick',
'mousedown [handle="trigger"]': '_handleTriggerMousedown',
// Focus
'capture:blur': '_handleFocusOut',
'global:click': '_onGlobalClick',
'global:touchstart': '_onGlobalClick',
// Taglist
'coral-collection:add [handle="tagList"]': '_handleTagAdded',
'coral-collection:remove [handle="tagList"]': '_handleTagRemoved',
'change [handle="tagList"]': '_preventTagListChangeEvent',
// Items
'coral-autocomplete-item:_valuechanged': '_handleItemValueChange',
'coral-autocomplete-item:_selectedchanged': '_handleItemSelectedChange',
'coral-autocomplete-item:_contentchanged': '_handleItemContentChange'
};
// Interaction
events[`global:key:shift+tab #${overlayId} [is="coral-buttonlist-item"]`] = '_handleListFocusShift';
events[`global:key:esc`] = '_handleListFocusShift';
// Overlay
events[`global:capture:coral-overlay:positioned #${overlayId}`] = '_onOverlayPositioned';
events[`global:capture:coral-overlay:open #${overlayId}`] = '_onOverlayOpenOrClose';
events[`global:capture:coral-overlay:close #${overlayId}`] = '_onOverlayOpenOrClose';
// SelectList
events[`global:key:enter #${overlayId} button[is="coral-buttonlist-item"]`] = '_handleSelect';
events[`global:capture:mousedown #${overlayId} button[is="coral-buttonlist-item"]`] = '_handleSelect';
events[`global:capture:scroll #${overlayId} [handle="selectList"]`] = '_onScroll';
events[`global:capture:mousewheel #${overlayId} [handle="selectList"]`] = '_onMouseWheel';
events[`global:capture:mousedown #${overlayId} [handle="selectList"]`] = '_onMouseDown';
// Events
this._delegateEvents(events);
// A map of values to tags
this._tagMap = {};
// A list of selected values
this._values = [];
// A list of options objects
this._options = [];
// A map of option values to their content
this._optionsMap = {};
// Used for reset
this._initialSelectedValues = [];
// Bind the debounced scroll method
this._handleScrollBottom = this._handleScrollBottom.bind(this);
// Listen for mutations
this._observer = new MutationObserver(this._handleMutation.bind(this));
this._startObserving();
}
/**
Returns the inner overlay to allow customization.
@type {Popover}
@readonly
*/
get overlay() {
return this._elements.overlay;
}
/**
The item collection.
@type {SelectableCollection}
@readonly
*/
get items() {
// Construct the collection on first request:
if (!this._items) {
this._items = new SelectableCollection({
itemTagName: 'coral-autocomplete-item',
host: this
});
}
return this._items;
}
/**
Indicates if the autocomplete is a single or multiple mode. In multiple mode, the user can select multiple
values.
@type {Boolean}
@default false
@htmlattribute multiple
@htmlattributereflected
*/
get multiple() {
return this._multiple || false;
}
set multiple(value) {
this._multiple = transform.booleanAttr(value);
this._reflectAttribute('multiple', this._multiple);
this._setName(this.name);
if (this._multiple) {
this._elements.tagList.hidden = false;
} else {
this._elements.tagList.hidden = true;
this._elements.tagList.items.clear();
}
this.labelledBy = this.labelledBy;
}
/**
Amount of time, in milliseconds, to wait after typing a character before the suggestion is shown.
@type {Number}
@default 200
@htmlattribute delay
*/
get delay() {
return typeof this._delay === 'number' ? this._delay : 200;
}
set delay(value) {
value = transform.number(value);
if (typeof value === 'number' && value >= 0) {
this._delay = transform.number(value);
}
}
/**
Set to <code>true</code> to restrict the selected value to one of the given options from the suggestions.
When set to <code>false</code>, users can enter anything.
<strong>NOTE:</strong> This API is under review and may be removed or changed in a subsequent release.
@ignore
@type {Boolean}
@default false
@htmlattribute forceselection
@htmlattributereflected
*/
get forceSelection() {
return this._forceSelection || false;
}
set forceSelection(value) {
this._forceSelection = transform.booleanAttr(value);
this._reflectAttribute('forceselection', this._forceSelection);
}
/**
A hint to the user of what can be entered.
@type {String}
@default ""
@htmlattribute placeholder
@htmlattributereflected
*/
get placeholder() {
return this._elements.input.placeholder;
}
set placeholder(value) {
this._elements.input.placeholder = value;
this._reflectAttribute('placeholder', this.placeholder);
}
/**
Max length for the Input field
@type {Number}
@htmlattribute maxlength
@htmlattributereflected
*/
get maxLength() {
return this._elements.input.maxLength;
}
set maxLength(value) {
this._elements.input.maxLength = value;
this._reflectAttribute('maxlength', this._elements.input.maxLength);
}
/**
The Autocomplete's variant. See {@link AutocompleteVariantEnum}.
@type {AutocompleteVariantEnum}
@default AutocompleteVariantEnum.DEFAULT
@htmlattribute variant
@htmlattributereflected
*/
get variant() {
return this._variant || variant.DEFAULT;
}
set variant(value) {
value = transform.string(value).toLowerCase();
this._variant = validate.enumeration(variant)(value) && value || variant.DEFAULT;
this._reflectAttribute('variant', this._variant);
if (this._variant === variant.QUIET) {
this._elements.inputGroup.classList.add('_coral-InputGroup--quiet');
this._elements.input.variant = Textfield.variant.QUIET;
this._elements.trigger.classList.add('_coral-FieldButton--quiet');
} else {
this._elements.inputGroup.classList.remove('_coral-InputGroup--quiet');
this._elements.input.variant = Textfield.variant.DEFAULT;
this._elements.trigger.classList.remove('_coral-FieldButton--quiet');
}
}
/**
The match mode. See {@link AutocompleteMatchEnum}.
@type {String}
@default AutocompleteMatchEnum.CONTAINS
@htmlattribute match
*/
get match() {
return this._match || match.CONTAINS;
}
set match(value) {
if (typeof value === 'function') {
this._match = value;
this._matchFunction = value;
} else {
value = transform.string(value).toLowerCase();
this._match = validate.enumeration(match)(value) && value || match.CONTAINS;
if (this._match === match.STARTSWITH) {
this._matchFunction = this._optionStartsWithValue;
} else if (this._match === match.CONTAINS) {
this._matchFunction = this._optionContainsValue;
}
}
}
/**
Indicates that the component is currently loading remote data. This will set the wait indicator inside the list.
@type {Boolean}
@default false
@htmlattribute loading
*/
get loading() {
return this._loading || false;
}
set loading(value) {
this._loading = transform.booleanAttr(value);
if (this._loading) {
const overlay = this._elements.overlay;
// we decide first if we need to scroll to the bottom since adding the load will change the dimensions
const scrollToBottom = overlay.scrollTop >= overlay.scrollHeight - overlay.clientHeight;
// if it does not exist we create it
if (!this._elements.loadIndicator) {
loadIndicator.call(this._elements);
}
// inserts the item at the end
this._elements.selectList.appendChild(this._elements.loadIndicator);
// we make the load indicator visible
if (scrollToBottom) {
overlay.scrollTop = overlay.scrollHeight;
}
} else if (this._elements.loadIndicator) {
this._elements.loadIndicator.remove();
}
}
/**
Returns an Array containing the set selected items.
@type {Array.<HTMLElement>}
@readonly
*/
get selectedItems() {
return this.items._getAllSelected();
}
/**
Returns the first selected item in the Autocomplete. The value <code>null</code> is returned if no element is
selected.
@type {?HTMLElement}
@readonly
*/
get selectedItem() {
return this.items._getAllSelected()[0] || null;
}
/**
The current value, as submitted during form submission.
When {@link Coral.Autocomplete#multiple} is <code>true</code>, the first selected value will be returned.
@type {String}
@default ""
@htmlattribute value
*/
get value() {
// Get the first value (or empty string)
const values = this.values;
return values && values.length > 0 ? values[0] : '';
}
set value(value) {
this.values = [transform.string(value)];
}
/**
The current values, as submitted during form submission.
When {@link Coral.Autocomplete#multiple} is <code>false</code>, this will be an array of length 1.
@type {Array.<String>}
*/
get values() {
return this._values;
}
set values(values) {
if (values === undefined || values === null) {
values = [];
}
if (Array.isArray(values)) {
// if value was set to empty string
if (values.length === 1 && values[0] === '') {
values = [];
}
let i;
let value;
const selectedValues = [];
// Valid values only
if (this.forceSelection) {
// Add each valid value
for (i = 0 ; i < values.length ; i++) {
value = values[i];
if (this._optionsMap[value] !== undefined) {
selectedValues.push(value);
}
}
}
// Any value goes
else {
for (i = 0 ; i < values.length ; i++) {
value = values[i];
selectedValues.push(value);
}
}
if (this.multiple) {
// Remove existing tags, DOM selection, etc
// This is a full override
this._clearValues();
// Add each tag
for (i = 0 ; i < selectedValues.length ; i++) {
value = selectedValues[i];
// Ensure the item is selected if it's present in the DOM
// This keeps the DOM in sync with the JS API and prevents bugs like CUI-5681
this._selectItem(value);
// Add the value to the tag list
this._addValue(value, null, true);
}
} else {
// Set value
this._values = selectedValues.length > 0 ? [selectedValues[0]] : [];
this._reflectCurrentValue();
}
}
}
/**
Name used to submit the data in a form.
@type {String}
@default ""
@htmlattribute name
@htmlattributereflected
*/
get name() {
return this._getName();
}
set name(value) {
this._reflectAttribute('name', value);
this._setName(value);
}
/**
Inherited from {@link BaseFormField#invalid}.
*/
get invalid() {
return super.invalid;
}
set invalid(value) {
super.invalid = value;
// Add to outer component
this._elements.inputGroup.classList.toggle('is-invalid', this.invalid);
this._elements.trigger.classList.toggle('is-invalid', this.invalid);
this._elements.input.invalid = this.invalid;
}
/**
Whether this field is disabled or not.
@type {Boolean}
@default false
@htmlattribute disabled
@htmlattributereflected
*/
get disabled() {
return this._disabled || false;
}
set disabled(value) {
this._disabled = transform.booleanAttr(value);
this._reflectAttribute('disabled', this._disabled);
this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
this._elements.inputGroup.classList.toggle('is-disabled', this._disabled);
this._elements.input.disabled = this._disabled;
const disabledOrReadOnly = this._disabled || this.readOnly;
this._elements.trigger.disabled = disabledOrReadOnly;
this._elements.tagList.disabled = disabledOrReadOnly;
// Prevents the overlay to be shown
this._elements.inputGroup.disabled = disabledOrReadOnly;
}
/**
Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
@type {Boolean}
@default false
@htmlattribute readonly
@htmlattributereflected
*/
get readOnly() {
return this._readOnly || false;
}
set readOnly(value) {
this._readOnly = transform.booleanAttr(value);
this._reflectAttribute('readonly', this._readOnly);
this._elements.input.readOnly = this._readOnly;
const readOnlyOrDisabled = this._readOnly || this.disabled;
this._elements.trigger.readOnly = readOnlyOrDisabled;
this._elements.tagList.readOnly = readOnlyOrDisabled;
// Prevents the overlay to be shown
this._elements.inputGroup.disabled = readOnlyOrDisabled;
}
/**
Whether this field is required or not.
@type {Boolean}
@default false
@htmlattribute required
@htmlattributereflected
*/
get required() {
return this._required || false;
}
set required(value) {
this._required = transform.booleanAttr(value);
this._reflectAttribute('required', this._required);
this._elements.input.required = this._required;
}
/**
Inherited from {@link BaseFormField#labelled}.
*/
get labelled() {
return super.labelled;
}
set labelled(value) {
super.labelled = value;
this[this.labelled ? 'setAttribute' : 'removeAttribute']('aria-label', this.labelled);
this._elements.selectList[this.labelled ? 'setAttribute' : 'removeAttribute']('aria-label', this.labelled);
if (this.labelled && this.multiple) {
this._elements.tagList.setAttribute('aria-label', this.labelled);
} else {
this._elements.tagList.removeAttribute('aria-label');
}
}
/**
Inherited from {@link BaseFormField#labelledBy}.
*/
get labelledBy() {
return super.labelledBy;
}
set labelledBy(value) {
super.labelledBy = value;
this[this.labelledBy ? 'setAttribute' : 'removeAttribute']('aria-labelledby', this.labelledBy);
this._elements.selectList[this.labelledBy ? 'setAttribute' : 'removeAttribute']('aria-labelledby', this.labelledBy);
if (this.labelledBy && this.multiple) {
this._elements.tagList.setAttribute('aria-labelledby', this.labelledBy);
} else {
this._elements.tagList.removeAttribute('aria-labelledby');
}
}
/**
@ignore
Not supported anymore.
*/
get icon() {
return this._icon || '';
}
set icon(value) {
this._icon = transform.string(value);
this._reflectAttribute('icon', this._icon);
}
/** @private */
_getName() {
if (this.multiple) {
return this._elements.tagList.name;
}
return this._elements.field.name;
}
/**
Set the name accordingly for multiple/single mode so the form submits contain only the right fields.
@private
*/
_setName(value) {
if (this.multiple) {
this._elements.tagList.name = value;
this._elements.field.name = '';
} else {
this._elements.field.name = value;
this._elements.tagList.name = '';
}
}
/** @private */
_startObserving() {
this._observer.observe(this, {
// Only watch the childList
// Items will tell us if selected/value/content changes
childList: true
});
}
/**
Stop watching for mutations. This should be done before manually updating observed properties.
@protected
*/
_stopObserving() {
this._observer.disconnect();
}
// Override to do nothing
_onInputChange(event) {
// stops the current event
event.stopPropagation();
if (!this.multiple) {
const inputText = this._elements.input.value.toLowerCase();
if (this.forceSelection || inputText === '') {
// We need a way to deselect item in single selection mode
// 1) by using an empty string if this.forceSelection === false
// 2) by using an invalid string if this.forceSelection === true
const items = this.items.getAll();
for (let i = 0 ; i < items.length ; i++) {
if (items[i].value.toLowerCase() !== inputText) {
items[i].selected = false;
}
}
}
}
}
/**
Handle mutations to children and childList. This is used to keep the options in sync with DOM changes.
@private
*/
_handleMutation(mutations) {
for (let i = 0 ; i < mutations.length ; i++) {
const mutation = mutations[i];
const target = mutation.target;
if (mutation.type === 'childList' && target === this) {
this._setStateFromDOM();
return;
}
}
}
/**
Update the option set and selected options from the DOM.
@private
*/
_setStateFromDOM() {
this._createOptionsFromDOM();
this._setSelectedFromDOM();
}
/**
Create the set of options from nodes in the DOM.
@private
*/
_createOptionsFromDOM() {
// Reset options array and value to content map
this._options.length = 0;
this._optionsMap = {};
this.items.getAll().forEach((item) => {
// Don't use properties as children may not be initialized yet
const itemObj = {
value: item.getAttribute('value'),
icon: item.getAttribute('icon'),
disabled: item.hasAttribute('disabled'),
content: item.innerHTML.replace(/\s{2,}/g, ' ').trim(),
text: item.innerText
};
this._options.push(itemObj);
this._optionsMap[itemObj.value] = itemObj;
});
// @todo update value in hidden field if changed value = old value?
}
/** @private */
_setInputValues(value, content) {
this._elements.field.value = value;
// Set text into input if in "multiple selection mode" or in "single selection mode and content is not empty"
// otherwise keep the current text for us (should be marked red)
if (this.multiple || content !== '') {
this._elements.input.value = content.trim();
}
}
/** @private */
_reflectCurrentValue() {
// Use empty string if no values
const value = this._values.length > 0 ? this._values[0] : '';
// Reflect the value in the field for form submit
this._elements.field.value = value;
let content = '';
if (value !== '') {
// Find the object with the corresponding content
const itemObj = this._optionsMap[value];
if (itemObj) {
// Reflect the content in the input
/*
prefence would be first given to innerText instead of innerHtml
as special characters like '&' transformed as ;amp in case of innerHtml.
*/
content = itemObj.text && itemObj.text !== '' ? itemObj.text : itemObj.content;
} else {
// Just use the provided value
content = value;
}
}
this._setInputValues(value, content);
}
/**
Update the option set and selected options from the DOM
@ignore
*/
_setSelectedFromDOM() {
const selectedItems = this.selectedItems;
if (selectedItems.length) {
// Use this.hasAttribute('multiple') instead of this.multiple here, as this method is called from _render and element might not be ready
if (this.hasAttribute('multiple')) {
// Remove current tags
this._resetValues();
// Add new ones
for (let i = 0 ; i < selectedItems.length ; i++) {
const value = selectedItems[i].getAttribute('value');
const content = selectedItems[i].innerHTML;
this._addValue(value, content, true);
}
} else {
// Select last
const last = selectedItems[selectedItems.length - 1];
// Deselect others
this._deselectExcept(last, selectedItems);
// Set value from the attribute
// We don't want to use the property as the sub-component may not have been upgraded yet
this.value = last.getAttribute('value');
}
} else if (!this.hasAttribute('value')) {
if (this.hasAttribute('multiple')) {
this._resetValues();
} else {
this.value = '';
}
}
}
/**
De-select every item except the provided item.
@param {HTMLElement} exceptItem
The item not to select
@param {Array.<HTMLElement>} [items]
The set of items to consider when deselecting. If not provided, the current set of selected items is used.
@private
*/
_deselectExcept(exceptItem, items) {
const selectedItems = items || this.selectedItems;
// Deselect others
this._stopObserving();
for (let i = 0 ; i < selectedItems.length ; i++) {
if (selectedItems[i] !== exceptItem) {
selectedItems[i].removeAttribute('selected');
}
}
this._startObserving();
}
/**
Add a tag to the taglist.
@private
*/
_addValue(value, content, asHTML) {
if (!content) {
// Find the content
const itemObj = this._optionsMap[value];
if (itemObj) {
content = itemObj.content;
} else {
// Just use the value
content = value;
}
}
// Add to selected values
const index = this._values.indexOf(value);
if (index === -1) {
this._values.push(value);
}
const labelContent = {};
if (asHTML) {
labelContent.innerHTML = content;
} else {
labelContent.textContent = content;
}
// Create a new tag
const tag = new Tag().set({
label: labelContent,
value: value
});
// Add to map
this._tagMap[value] = tag;
// Add to taglist
this._elements.tagList.items.add(tag);
// make sure to remove text from input box (to easily choose next item)
this._setInputValues('', '');
}
/**
Remove a tag from the taglist.
@private
*/
_removeValue(value) {
// Remove from selected values
const index = this._values.indexOf(value);
if (index === -1) {
// Get out if we don't have the value
return;
}
this._values.splice(index, 1);
// Select autocomplete item
const item = this.querySelector(`coral-autocomplete-item[value=${JSON.stringify(value)}]`);
if (item) {
if (item.hasAttribute('selected')) {
this._stopObserving();
item.removeAttribute('selected');
this._startObserving();
}
}
// Look up the tag by value
const tag = this._tagMap[value];
if (tag) {
// Remove from map
this._tagMap[value] = null;
// Remove from taglist
this._elements.tagList.items.remove(tag);
}
if (index !== -1) {
// Emit the change event when a value is removed but only after a user interaction
this.trigger('change');
}
}
/**
Remove all tags from the taglist.
@private
*/
_clearValues() {
this._resetValues();
// Deselect items
this._stopObserving();
const items = this.querySelectorAll('coral-autocomplete-item[selected]');
for (let i = 0 ; i < items.length ; i++) {
items[i].removeAttribute('selected');
}
this._startObserving();
}
/**
Reset values without affecting the DOM.
@private
*/
_resetValues() {
// Reset values
this._values = [];
// Drop references to tags
this._tagMap = {};
// Clear taglist
this._elements.tagList.items.clear();
}
/** @private */
_focusNextItem() {
// Display focus on next item in the selectList
const selectList = this._elements.selectList;
const currentItem = selectList.querySelector('.is-focused');
const input = this._elements.input;
const items = selectList._getSelectableItems();
let index;
let item;
if (currentItem) {
index = items.indexOf(currentItem);
if (index < items.length - 1) {
item = items[index + 1];
}
} else if (items && items.length > 0) {
item = items[0];
}
window.requestAnimationFrame(() => {
if (item) {
if (currentItem) {
currentItem.classList.remove('is-focused');
}
this._scrollItemIntoView(item);
item.classList.add('is-focused');
input.setAttribute('aria-activedescendant', item.id);
}
if (!selectList.querySelector('.is-focused')) {
input.removeAttribute('aria-activedescendant');
}
});
}
/** @private */
_focusPreviousItem() {
// Display focus on previous item in the selectList
const selectList = this._elements.selectList;
const currentItem = selectList.querySelector('.is-focused');
const input = this._elements.input;
const items = selectList._getSelectableItems();
let index;
let item;
if (currentItem) {
index = items.indexOf(currentItem);
if (index > 0) {
item = items[index - 1];
}
currentItem.classList.remove('is-focused');
} else if (items && items.length > 0) {
item = items[items.length - 1];
}
window.requestAnimationFrame(() => {
if (item) {
this._scrollItemIntoView(item);
item.classList.add('is-focused');
input.setAttribute('aria-activedescendant', item.id);
}
if (!selectList.querySelector('.is-focused')) {
input.removeAttribute('aria-activedescendant');
}
});
}
/** @private */
_showSuggestions() {
// Get value from the input
const inputValue = this._elements.input.value.toLowerCase().trim();
// Since we're showing fresh suggestions, clear the existing suggestions
this.clearSuggestions();
// Trigger an event
const event = this.trigger('coral-autocomplete:showsuggestions', {
// Pass user input
value: inputValue,
// Started at zero here, always
start: 0
});
// Flag to indicate that the private method is called before public showSuggestions method
this._showSuggestionsCalled = true;
if (event.defaultPrevented) {
// Set loading mode
this.loading = true;
// Show the menu
this.showSuggestions();
} else {
// Show suggestions that match in the DOM
this.addSuggestions(this._getMatches(inputValue, this._optionContainsValue));
this.showSuggestions();
}
}
_onOverlayPositioned(event) {
// stops propagation cause the event is internal to the component
event.stopImmediatePropagation();
if (this._elements.overlay.open) {
this._elements.overlay.style.width = `${this.offsetWidth}px`;
}
}
_onGlobalClick(event) {
if (!this._elements.overlay.open) {
return;
}
const eventTargetWithinOverlayTarget = this._elements.inputGroup.contains(event.target);
const eventTargetWithinItself = this._elements.overlay.contains(event.target);
if (!eventTargetWithinOverlayTarget && !eventTargetWithinItself) {
this.hideSuggestions();
}
}
/** @private */
_onScroll() {
this._isOverlayScrolling = true;
window.clearTimeout(this._scrollTimeout);
this._scrollTimeout = window.setTimeout(this._handleScrollBottom, SCROLL_DEBOUNCE);
}
/** @private */
_onMouseWheel(event) {
const selectList = this._elements.selectList;
// If scrolling with mouse wheel and if it has hit the top or bottom boundary
// `SCROLL_BOTTOM_THRESHOLD` is ignored when hitting scroll bottom to allow debounced loading
if (event.deltaY < 0 && selectList.scrollTop === 0 || event.deltaY > 0 && selectList.scrollTop >= selectList.scrollHeight - selectList.clientHeight) {
event.preventDefault();
}
}
_onMouseDown(event) {
this._isOverlayScrollBarClicked = event.matchedTarget.clientWidth <= event.offsetX;
}
/** @private */
_handleScrollBottom() {
const selectList = this._elements.selectList;
if (selectList.scrollTop >= selectList.scrollHeight - selectList.clientHeight - SCROLL_BOTTOM_THRESHOLD) {
const inputValue = this._elements.input.value;
// Do not clear the suggestions here, instead we'll expect them to append
// Trigger an event
const event = this.trigger('coral-autocomplete:showsuggestions', {
// Pass user input
value: inputValue,
start: selectList.items.length
});
if (event.defaultPrevented) {
// Set loading mode
this.loading = true;
}
}
}
/** @private */
_handleFocusOut(event) {
const selectList = this._elements.selectList;
const target = event.target;
const inputBlur = target === this._elements.input;
if (this._blurTimeout) {
clearTimeout(this._blurTimeout);
}
// This is to hack around the fact that you cannot determine which element gets focus in a blur event
// Firefox doesn't support focusout/focusin, so we're left doing awful things
this._blurTimeout = window.setTimeout(() => {
const relatedTarget = document.activeElement;
const focusOutside = !this.contains(relatedTarget) && !this._elements.overlay.contains(relatedTarget);
// If focus has moved out of the autocomplete, it's an input event
if (inputBlur && focusOutside && !this.multiple) {
this._handleInput(event);
}
// Nothing was focused
else if (!relatedTarget || ((inputBlur || relatedTarget !== document.body) &&
// Focus is now outside of the autocomplete component
focusOutside ||
// Focus has shifted from the selectList to another element inside of the autocomplete component
selectList.contains(target) && !selectList.contains(relatedTarget))) {
this.hideSuggestions();
}
}, 0);
}
/** @private */
_handleListFocusShift(event) {
if (this._elements.overlay.open) {
// Stop focus shift
event.preventDefault();
event.stopImmediatePropagation();
this._hideSuggestionsAndFocus();
}
}
/** @private */
_hideSuggestionsAndFocus() {
// Hide the menu and focus on the input
this.hideSuggestions();
this._elements.input.focus();
}
/** @private */
_handleTriggerClick() {
if (this._elements.overlay.classList.contains('is-open')) {
this._hideSuggestionsAndFocus();
} else {
// Focus on the input so down arrow works as expected
// Per @mijordan
this._showSuggestions();
this._elements.input.focus();
}
}
/** @private */
_handleTriggerMousedown() {
this._elements.trigger.focus();
}
/** @private */
_handleListItemFocus(event) {
const item = event.matchedTarget;
const selectList = this._elements.selectList;
const currentItem = selectList.querySelector('.is-focused');
const input = this._elements.input;
if (currentItem) {
currentItem.classList.remove('is-focused');
input.removeAttribute('aria-activedescendant');
}
if (!item.disabled) {
this._scrollItemIntoView(item);
item.classList.add('is-focused');
input.setAttribute('aria-activedescendant', item.id);
}
}
/** @private */
_scrollItemIntoView(item) {
const itemRect = item.getBoundingClientRect();
const selectListRect = this._elements.selectList.getBoundingClientRect();
if (itemRect.top < selectListRect.top) {
item.scrollIntoView();
} else if (itemRect.bottom > selectListRect.bottom) {
item.scrollIntoView(false);
}
}
/** @private */
_getMatches(value, optionMatchesValue) {
optionMatchesValue = optionMatchesValue || this._matchFunction;
const matches = [];
for (let i = 0 ; i < this._options.length ; i++) {
if (optionMatchesValue(this._options[i], value)) {
matches.push(this._options[i]);
}
}
if (!matches.length) {
// If there are no matches in _options,
// Check for matches in list, which could have been added after mounting the element
const buttons = this._elements.selectList.items.getAll();
for (let i = 0 ; i < buttons.length ; i++) {
const option = {
value: buttons[i].value,
content: buttons[i].textContent.trim()
};
if (optionMatchesValue(option, value)) {
matches.push(option);
}
}
}
return matches;
}
/** @private */
_handleInputKeypressEnter(event) {
// Sigh, CUI-3973 Hitting enter quickly after typing causes form to submit
if (event.which === 13) {
this._handleInput(event);
}
}
/** @private */
_handleInputEvent() {
// Any input makes this valid again
this.invalid = false;
if (this.delay) {
// Wait until the use has stopped typing for delay milliseconds before getting suggestions
window.clearTimeout(this._timeout);
this._timeout = window.setTimeout(this._showSuggestions.bind(this), this.delay);
} else {
// Immediately get suggestions
this._showSuggestions();
}
}
/** @private */
_handleInput(event) {
// Don't set value and hide suggestions while scrolling overlay
if (this._isOverlayScrolling || this._isOverlayScrollBarClicked) {
this._isOverlayScrolling = false;
this._isOverlayScrollBarClicked = false;
return;
}
// Stop the event
event.preventDefault();
let focusedItemValue;
// If a selectList item has focus, set the input value to the value of the selected item.
if (this._elements.overlay.open && this._elements.input.getAttribute('aria-activedescendant')) {
const focusedItem = this._elements.selectList.querySelector('.is-focused');
if (focusedItem) {
// Use the text content value of the item for comparison
focusedItemValue = focusedItem.textContent.trim();
}
}
const value = focusedItemValue || this._elements.input.value;
let isChange = false;
// Get all exact matches
const exactMatches = this._getMatches(value, this._optionEqualsValue);
if (exactMatches.length) {
// Find perfect case sensitive match else defaults to first one
const exactMatch = exactMatches.filter((option) => option.content === value)[0] || exactMatches[0];
isChange = this.value !== exactMatch.value;
// Select the matched item
this._selectItem(exactMatch.value, exactMatch.content, false);
if (this.multiple) {
if (value.trim()) {
// Add tag for non-empty values
this._addValue(exactMatch.value, exactMatch.content, false);
}
} else {
// Set value
this.value = exactMatch.value;
}
// value can't be invalid as an exact match is selected
if (this.forceSelection) {
this.invalid = false;
}
// Hide the suggestions so the result can be seen
this.hideSuggestions();
// Emit the change event when a selection is made from an exact match
if (isChange === true) {
this.trigger('change');
}
} else if (this.forceSelection) {
// Invalid
if (this.multiple) {
this.invalid = value !== '' || (this.values.length === 1 && this.values[0] === '' || this.values.length === 0);
} else {
this.invalid = true;
}
// Leave suggestions open if nothing matches
} else {
// DO NOT select the corresponding item, as this would add an item
// This would result in adding items that match what the user typed, resulting in selections
// this._selectItem(value);
isChange = this.value !== value;
if (this.multiple) {
if (value.trim()) {
// Add tag for non-empty values
this._addValue(value, null, false);
}
} else {
// Set value
this.value = value;
}
// Hide the suggestions so the result can be seen
this.hideSuggestions();
// Emit the change event when arbitrary data is entered
if (isChange === true) {
this.trigger('change');
}
}
this._updateButtonAccessibilityLabel();
}
/**
This ensures the collection API is up to date with selected items, even if they come from suggestions.
@private
*/
_selectItem(value, content, asHTML) {
// Don't get caught up with internal changes
this._stopObserving();
// Select autocomplete item if it's there
const item = this.querySelector(`coral-autocomplete-item[value=${JSON.stringify(value)}]`);
if (item) {
// Select the existing item
item.setAttribute('selected', '');
} else {
const labelContent = {};
content = typeof content === 'undefined' ? value : content;
if (asHTML) {
labelContent.innerHTML = content;
} else {
labelContent.textContent = content;
}
// Add a new, selected item
this.items.add(new AutocompleteItem().set({
value: value,
content: labelContent,
selected: true
}));
}
// Resume watching for changes
this._startObserving();
}
/** @private */
_handleInputUpKeypress(event) {
// Stop any consequences of pressing the key
event.preventDefault();
if (this._elements.overlay.open) {
if (event.altKey) {
this.hideSuggestions();
} else {
this._focusPreviousItem();
}
} else {
// Show the menu and do not focus on the first item
// Implements behavior of http://www.w3.org/TR/wai-aria-practices/#autocomplete
this._showSuggestions();
}
}
/** @private */
_handleInputDownKeypress(event) {
// Stop any consequences of pressing the key
event.preventDefault();
if (this._elements.overlay.open) {
this._focusNextItem();
} else {
// Show the menu and do not focus on the first item
// Implements behavior of http://www.w3.org/TR/wai-aria-practices/#autocomplete
this._showSuggestions();
}
}
/** @private */
_handleInputTabKeypress(event) {
// if the select list is open and a list item has focus, prevent default to trap focus.
if (this._elements.overlay.open && this._elements.input.getAttribute('aria-activedescendant')) {
event.preventDefault();
}
}
/**
Handle selections in the selectList.
@ignore
*/
_handleSelect(event) {
const selectListItem = event.matchedTarget;
if (!selectListItem || selectListItem.disabled) {
// @todo it doesn't seem like this should ever happen, but it does
return;
}
// Select the corresponding item, or add one if it doesn't exist
this._selectItem(selectListItem.value, selectListItem.content.innerHTML, true);
if (!this.multiple) {
this.value = selectListItem.value;
// Make sure the value is changed
// The setter won't run if we set the same value again
// This forces the DOM to update
this._setInputValues(this.value, selectListItem.content.textContent, false);
} else {
// Add to values
this._addValue(selectListItem.value, selectListItem.content.innerHTML, true);
}
// Focus on the input element
// We have to wait a frame here because the item steals focus when selected
window.requestAnimationFrame(() => {
this._elements.input.focus();
});
// Hide the options when option is selected in all cases
this.hideSuggestions();
// Emit the change event when a selection is made
this.trigger('change');
}
/**
Don't let the internal change event bubble and confuse users
@ignore
*/
_preventTagListChangeEvent(event) {
event.stopImmediatePropagation();
}
_handleTagAdded() {
// Forces tags to wrap
this._elements.tagList.style.width = `${this.offsetWidth}px`;
}
/**
Handle tags that are removed by the user.
@ignore
*/
_handleTagRemoved(event) {
// Get the tag from the event
const tagValue = event.detail.item.value;
// Remove from values only if there is no other tags with the same value are attached (as this component constantly adds and removes tags)
// this._elements.tagList.values does not seem to work so iterate over the tags to check values
let removeValue = true;
const tags = this._elements.tagList.items.getAll();
for (let i = 0 ; i < tags.length ; i++) {
if (tags[i].value === tagValue) {
removeValue = false;
break;
}
}
if (removeValue) {
this._removeValue(tagValue);
}
// If all tags were removed, return focus to the input
if (this.selectedItems.length === 0) {
this._elements.input.focus();
}
this._updateButtonAccessibilityLabel();
}
/**
Handles value changes on a child item.
@private
*/
_handleItemValueChange(event) {
// stop event propogation
event.stopImmediatePropagation();
// Update option map from scratch
// @todo use attributeOldValue mutationobserver option and update map instead of re-creating
this._createOptionsFromDOM();
}
/**
Handles content changes on a child item.
@private
*/
_handleItemContentChange(event) {
// stop event propogation
event.stopImmediatePropagation();
// Update option map from scratch with new content
this._createOptionsFromDOM();
}
/**
Handles selected changes on a child item.
@private
*/
_handleItemSelectedChange(event) {
// stop event propogation
event.stopImmediatePropagation();
const target = event.target;
const selected = target.hasAttribute('selected');
if (this.multiple) {
this[selected ? '_addValue' : '_removeValue'](target.value, target.content.innerHTML, true);
} else if (selected) {
// Set the input text accordingly
this._elements.input.value = target.content.textContent.replace(/\s{2,}/g, ' ').trim();
// Set the value accordingly
this.value = target.value;
// value can't be invalid as an item is selected
this.invalid = false;
// Deselect the other elements if selected programatically changed
this._deselectExcept(target);
}
// Remove values if deselected
// Only do this if we're the current value
// If the selected item was changed, this.value will be different
else if (this.value === target.value) {
this.value = '';
// CUI-5533 Since checks inside of _handleInput will assume the value hasn't change,
// We need to trigger here
this.trigger('change');
}
}
/**
Check if the given option partially matches the given value.
@param {HTMLElement} option
The option to test
@param {String} value
The value to test
@returns {Boolean} true if the value matches, false if not.
@protected
*/
_optionContainsValue(option, value) {
value = (typeof value === 'string' ? value : '').toLowerCase();
return (option.text || option.content).toLowerCase().indexOf(value) !== -1;
}
/**
Check if the given option starts with the given value.
@param {HTMLElement} option
The option to test
@param {String} value
The value to test
@returns {Boolean} true if the value matches, false if not.
@protected
*/
_optionStartsWithValue(option, value) {
value = (typeof value === 'string' ? value : '').toLowerCase();
return option.content.toLowerCase().trim().indexOf(value) === 0;
}
/**
Check if the given option exactly matches the given value.
@param {HTMLElement} option
The option to test
@param {String} value
The value to test
@returns {Boolean} true if the value matches, false if not.
@protected
*/
_optionEqualsValue(option, value) {
value = (typeof value === 'string' ? value : '').toLowerCase();
return option.content.toLowerCase().trim() === value;
}
/**
Updates label on toggle button to communicate number of suggestions in list.
@param {Number} num
The number of suggestions available
@private
*/
_updateButtonAccessibilityLabel(num) {
let str = i18n.get('Show suggestions');
if (num === 1) {
str = i18n.get('Show suggestion');
} else if (num > 1) {
str = i18n.get('Show {0} suggestions', num);
}
this._elements.trigger.setAttribute('aria-label', str);
this._elements.trigger.setAttribute('title', str);
}
/**
Clears the current selected value or items.
*/
clear() {
this.value = '';
this._elements.input.clear();
if (this.multiple) {
this._clearValues();
}
}
/**
Clear the list of suggestions.
*/
clearSuggestions() {
this._elements.selectList.items.clear();
this._updateButtonAccessibilityLabel();
}
/**
A suggestion object.
@typedef {Object} AutocompleteSuggestion
@property {String} value
The form submission value to use when this suggestion is selected.
@property {String} [content=value]
The content to disable in the suggestion dropdown.
*/
/**
Add the provided list of suggestions and clear loading status.
@param {Array.<AutocompleteSuggestion>} suggestions
The list of suggestions to show.
@param {Boolean} clear
If true, existing suggestions will be cleared.
*/
addSuggestions(suggestions, clear) {
// Disable loading mode
this.loading = false;
if (clear) {
// Remove existing selectList items
this.clearSuggestions();
}
// Add items to the selectlist
for (let i = 0 ; i < suggestions.length ; i++) {
const value = suggestions[i].value;
const content = suggestions[i].content;
const icon = suggestions[i].icon;
const disabled = !!suggestions[i].disabled;
// Only add the item if it's not a selected value or we're in single mode
if (!this.multiple || this.values.indexOf(value) === -1) {
this._elements.selectList.items.add({
value: value,
type: 'button',
icon: icon,
disabled: disabled,
id: commons.getUID(),
tabIndex: -1,
content: {
innerHTML: content
}
});
this._elements.selectList.items.last().setAttribute('role', 'option');
}
}
if (!suggestions.length && !this._elements.selectList.items.length) {
// Show "no results" when no suggestions are found at all
this._elements.selectList.items.add({
type: 'button',
content: {
innerHTML: `<em>${i18n.get('No matching results.')}</em>`
},
disabled: true
});
this._elements.selectList.items.last().setAttribute('role', 'status');
this._elements.selectList.items.last().setAttribute('aria-live', 'polite');
this._elements.input.removeAttribute('aria-activedescendant');
this._updateButtonAccessibilityLabel();
} else {
this._updateButtonAccessibilityLabel(this._elements.selectList.items.length);
}
}
/**
Shows the suggestion UI.
*/
showSuggestions() {
if (!this._showSuggestionsCalled) {
this._showSuggestions();
} else {
this._showSuggestionsCalled = false;
}
// Just show
this._elements.overlay.open = true;
// Force overlay repositioning (e.g because of remote loading)
requestAnimationFrame(() => {
this._elements.overlay._onAnimate();
this._elements.overlay.reposition();
});
this._elements.input.setAttribute('aria-expanded', 'true');
this._elements.trigger.setAttribute('aria-expanded', 'true');
}
/**
Hides the suggestion UI.
*/
hideSuggestions() {
this._elements.overlay.open = false;
this._elements.input.setAttribute('aria-expanded', 'false');
this._elements.trigger.setAttribute('aria-expanded', 'false');
this._elements.input.removeAttribute('aria-activedescendant');
// Don't let the suggestions show
window.clearTimeout(this._timeout);
// Trigger an event
this.trigger('coral-autocomplete:hidesuggestions');
}
/**
Matches the accessibility to the state of the popover.
@ignore
*/
_onOverlayOpenOrClose(event) {
if (this._elements.overlay.open) {
this._elements.input.setAttribute('aria-expanded', 'true');
this._elements.trigger.setAttribute('aria-expanded', 'true');
} else {
this._elements.input.setAttribute('aria-expanded', 'false');
this._elements.trigger.setAttribute('aria-expanded', 'false');
this._elements.input.removeAttribute('aria-activedescendant');
}
}
/**
Inherited from {@link BaseFormField#reset}.
*/
reset() {
// reset the values to the initial values
this.values = this._initialSelectedValues;
}
/**
Returns {@link Autocomplete} match options.
@return {AutocompleteMatchEnum}
*/
static get match() {
return match;
}
/**
Returns {@link Autocomplete} variants.
@return {AutocompleteVariantEnum}
*/
static get variant() {
return variant;
}
static get _attributePropertyMap() {
return commons.extend(super._attributePropertyMap, {
forceselection: 'forceSelection',
maxlength: 'maxLength'
});
}
/** @ignore */
static get observedAttributes() {
return super.observedAttributes.concat([
'multiple',
'delay',
'forceselection',
'placeholder',
'maxlength',
'icon',
'match',
'loading',
'variant'
]);
}
/** @ignore */
connectedCallback() {
super.connectedCallback();
const overlay = this._elements.overlay;
// Cannot be open by default when rendered
overlay.removeAttribute('open');
// Restore in DOM
if (overlay._parent) {
overlay._parent.appendChild(overlay);
}
}
render() {
super.render();
this.classList.add(CLASSNAME);
// Container role per ARIA Autocomplete
this.setAttribute('role', 'group');
// Input attributes per ARIA Autocomplete
this._elements.input.setAttribute('role', 'combobox');
this._elements.input.setAttribute('aria-autocomplete', 'list');
this._elements.input.setAttribute('aria-haspopup', 'listbox');
this._elements.input.setAttribute('aria-expanded', 'false');
this._elements.input.setAttribute('aria-controls', this._elements.selectList.id);
// Trigger button attributes per ARIA Autocomplete
this._elements.trigger.setAttribute('aria-haspopup', 'listbox');
this._elements.trigger.setAttribute('aria-expanded', 'false');
this._elements.trigger.setAttribute('aria-controls', this._elements.selectList.id);
// Default reflected attributes
if (!this._variant) {
this.variant = variant.DEFAULT;
}
// Create a fragment
const frag = document.createDocumentFragment();
// Render the template
frag.appendChild(this._elements.field);
frag.appendChild(this._elements.inputGroup);
frag.appendChild(this._elements.tagList);
frag.appendChild(this._elements.overlay);
this._elements.overlay.target = this._elements.trigger;
// Clean up
while (this.firstChild) {
const child = this.firstChild;
// Only works if all root template elements have a handle attribute
if (child.nodeType === Node.TEXT_NODE || child.hasAttribute && !child.hasAttribute('handle')) {
// Add non-template elements to the content
frag.appendChild(child);
} else {
// Remove anything else
this.removeChild(child);
}
}
// Append the fragment to the component
this.appendChild(frag);
// Set the state from the DOM when initialized
this._setStateFromDOM();
// save initial selection (used for reset)
this._initialSelectedValues = this.values.slice(0);
}
/** @ignore */
disconnectedCallback() {
super.disconnectedCallback();
const overlay = this._elements.overlay;
// In case it was moved out don't forget to remove it
if (!this.contains(overlay)) {
overlay._parent = overlay._repositioned ? document.body : this;
overlay.remove();
}
}
/**
Triggered when the {@link Autocomplete} could accept external data to be loaded by the user.
If <code>preventDefault()</code> is called, then a loading indicator will be shown.
{@link Autocomplete#loading} should be set to false to indicate that the data has been successfully loaded.
@typedef {CustomEvent} coral-autocomplete:showsuggestions
@property {String} detail.value
The user input.
*/
/**
Triggered when the {@link Autocomplete} hides the suggestions.
This is typically used to cancel a load request because the suggestions will not be shown anymore.
@typedef {CustomEvent} coral-autocomplete:hidesuggestions
*/
});
export default Autocomplete;