toolshed-8.x-1.x-dev/assets/widgets/Autocomplete.es6.js
assets/widgets/Autocomplete.es6.js
(({
t,
behaviors,
debounce,
Toolshed: ts,
}) => {
/**
* Live region which is used to announce status of widget changes.
*/
class LiveRegion extends ts.Element {
/**
* Initialize a new LiveRegion element.
*
* @param {Object} options
* Options to apply to the Live Regions.
* @param {HTMLElement|ToolshedElement} attachTo
* The element to append this live region to.
*/
constructor(options = {}, attachTo) {
if (ts.isString(options.class)) {
options.class = [options.class];
}
options.class = (options.class || []).concat(['visually-hidden', 'sr-only']);
// Create a wrapper element for the two live update areas.
super('div', options, attachTo || document.body);
// Setup regions for swapping between, for live updating.
const regionOpts = {
role: 'status',
'aria-live': 'polite',
'aria-atomic': 'true',
};
this.active = 1;
this.msg = '';
this.regions = [
new ts.Element('div', regionOpts, this),
new ts.Element('div', regionOpts, this),
];
// Only announce the changes after a short delay to prevent the announcer
// from talking over itself.
this.updateMsg = debounce(() => {
this.regions[this.active].textContent = '';
this.active ^= 1; // eslint-disable-line no-bitwise
this.regions[this.active].textContent = this.msg;
}, 500);
}
/**
* The current text message to announce in the live region.
*
* @return {string}
* Return the current message to be announced.
*/
get message() {
return this.msg;
}
/**
* Set a new message for the live region. Note that there is a small delay
* before the message is announced to avoid collisions of messages which
* are too close together.
*
* @param {string} message
* Message to set for the live region.
*/
set message(message) {
this.msg = message;
this.updateMsg();
}
}
/**
* Object which represents a single autocomplete select option. This object
* manages its own resources and the display text as well as the underlying
* value to pass back to the original input element.
*/
class SuggestItem extends ts.Element {
/**
* Create a new SuggestItem representing values and a selectable element.
*
* @param {SuggestList} list
* The suggestion list that this item is a member of.
* @param {string} id
* The option identifier to use for the element ID.
* @param {Toolshed.Autocomplete} ac
* The callback when the item is clicked on.
*/
constructor(list, id, ac) {
super('a', { tabindex: 0, href: '#' });
this.wrap = new ts.Element('li', { id, role: 'option', class: 'autocomplete__opt' });
this.wrap.appendChild(this);
this.parent = list;
this.pos = -1;
this.uri = null;
this.on('click', (e) => {
if (!this.uri) {
e.preventDefault();
e.stopPropagation();
ac.selectItem(this);
}
}, true);
}
/**
* The getter for the SuggestItem element ID.
*/
get id() {
return this.wrap.id;
}
/**
* The setter for SuggestItem element ID.
*
* @param {string} value
* The ID to used for the SuggestItem HTMLElement and descendant ID.
*/
set id(value) {
this.wrap.id = value;
}
/**
* Get the stored URI value of this suggest item.
*/
get url() {
return this.uri;
}
/**
* Set the URL of the suggest item, if the item should work like a link,
* rather than use the autocomplete value.
*
* @param {string} value
* A URL the Suggest item should send select when the item is clicked.
*/
set url(value) {
if (value && value !== '#') {
this.uri = value;
this.el.href = value;
}
else {
this.uri = null;
this.el.href = '#';
}
}
/**
* Update this item to show it has focus.
*/
focus() {
this.wrap.addClass('autocomplete__opt--active');
}
/**
* Update this item to show that it doesn't have focus.
*/
blur() {
this.wrap.removeClass('autocomplete__opt--active');
}
/**
* Clean up listeners and other allocated resources.
*
* @param {bool} detach
* Should the suggest item be removed from the DOM?
*/
destroy(detach) {
this.parent = null;
super.destroy(detach);
this.wrap.destroy(detach);
}
}
/**
* A list of SuggestItems, and manage the interactions with the autocomplete
* suggestions traversal, building and clearing.
*/
class SuggestList extends ts.Element {
/**
* Create a new list of SuggestItem objects.
*
* @param {SuggestList|null} list
* An object that contains a list of SuggestItem instances.
* @param {string} wrapTag
* The HTMLElement tag to use to wrap this SuggestList.
* @param {ToolshedAutocomplete} ac
* The autocomplete instance that owns this SuggestList.
*/
constructor(list, wrapTag, ac) {
super('ul', { class: 'autocomplete__options' });
this.items = [];
this.parent = list;
this.ac = ac;
this.pos = 0;
this.wrap = new ts.Element(wrapTag);
this.wrap.appendChild(this);
}
/**
* Get the current number of suggests in the list.
*/
get length() {
let ct = 0;
for (let i = 0; i < this.items.length; ++i) {
ct += (this.items[i] instanceof SuggestList) ? this.items[i].length : 1;
}
return ct;
}
/**
* Is this SuggestList empty of selectable suggestion items?
*
* @return {Boolean}
* Returns true if there are no selectable items currently available
* as suggestions for the autocomplete query.
*/
isEmpty() {
return !this.items.length;
}
/**
* Set the label to display over a set items, if there is a caption.
*
* @param {string} text
* The label text to use as a caption over the SuggestList items.
* @param {string} itemId
* The ID attribute for the wrapper element.
*/
setLabel(text, itemId) {
if (!this.label) {
this.label = new ts.Element('span', { class: 'autocomplete__group-label' });
this.wrap.prependChild(this.label);
}
if (itemId) {
this.label.setAttrs({ id: `${itemId}-label` });
this.setAttrs({ ariaLabelledby: `${itemId}-label` });
}
this.label.textContent = (text && text.length) ? text : '';
}
/**
* Add a new SuggestItem to the SuggestList.
*
* @param {SuggestItem} item
* Add a SuggestItem to the end of the SuggestList.
*/
addItem(item) {
this.items.push(item);
this.appendChild(item.wrap);
}
/**
* Get the first item in the list.
*
* @return {SuggestItem|null}
* The first selectable SuggestItem or NULL if not items.
*/
getFirst() {
if (this.items.length) {
return this.items[0] instanceof SuggestList ? this.items[0].getFirst() : this.items[0];
}
return null;
}
/**
* Get the list item in the list.
*
* @return {SuggestItem|null}
* The last selectable SuggestItem in the list or NULL. This includes
* the last item in the nested set of items.
*/
getLast() {
if (this.items.length) {
const idx = this.items.length - 1;
return this.items[idx] instanceof SuggestList ? this.items[idx].getLast() : this.items[idx];
}
return null;
}
/**
* Construct the list of selectable SuggestItems from the passed in data.
*
* @param {array|object} data
* The data representing the autocomplete suggestions / values available.
* @param {string} baseId
* A pool of available SuggestItem to reuse.
*/
buildItems(data, baseId) {
let pos = 0;
data.forEach((row) => {
let item;
const itemId = `${baseId}-${pos}`;
if (Array.isArray(row.list)) {
if (!row.list.length) return;
item = new SuggestList(this, 'li', this.ac);
item.setAttrs({ role: 'group' });
item.setLabel(row.text, itemId);
item.buildItems(row.list, itemId);
}
else if (row.value) {
item = new SuggestItem(this, itemId, this.ac);
item.text = (row.text || row.value);
item.value = row.value;
if (row.url) item.url = row.url;
this.ac.itemDisplay(item, row);
}
this.addItem(item);
item.pos = pos++;
});
}
/**
* Remove suggestion items from the suggestioned items display.
*/
clear() {
this.items.forEach((item) => item.destroy(true));
this.items = [];
}
/**
* Cleans up this autocomplete suggestions list, and frees resources and
* event listeners.
*/
destroy() {
if (this.label) {
this.label.destroy(true);
}
this.clear();
super.destroy(true);
this.wrap.destroy(true);
}
}
/**
* Class which encapsulates the behaviors of and autocomplete widget of a
* textfield. It allows subclasses to override their fetchSuggestions() and
* init() methods to allow for different methods of loading autocomplete
* items to display.
*/
ts.Autocomplete = class ToolshedAutocomplete {
constructor(input, config = {}) {
let ac;
this.input = input;
this.pending = false;
this.config = {
delay: 375,
minLength: 3,
requireSelect: true,
pendingClasses: ['is-autocompleting'],
...config,
};
// Reassociate a label element to point to the new autocomplete input.
const inputLabel = (this.input.id)
? document.querySelector(`label[for='${this.input.id}']`) : null;
// Create the main suggestion list wrapper.
const list = new SuggestList(null, 'div', this);
list.setAttrs({ id: `${this.input.id}-listbox`, role: 'listbox' });
list.on('blur', this.onBlur);
this.list = list;
if (inputLabel) {
if (!inputLabel.id) inputLabel.id = `${this.input.id}-label`;
list.setAttr('aria-labelledby', inputLabel.id);
}
// Create auto-suggestion pane.
const wrapper = new ts.Element('div', { class: 'autocomplete__options-pane', style: { display: 'none' } });
wrapper.on('mousedown', this.onMouseDown.bind(this));
wrapper.appendChild(list.wrap);
wrapper.attachTo(this.input, 'after');
this.suggestWrap = wrapper;
// Create a container for displaying empty results message.
this.emptyMsg = new ts.Element('div', {
class: 'autocomplete__empty-msg',
style: { display: 'none' },
}, wrapper);
// Create a live region for announcing result updates.
this.liveRegion = new LiveRegion();
// Determine if the autocomplete value is separate from the displayed
// text. When this value is split, we have a cloned AC textfield.
if (this.config.separateValue) {
ac = new ts.FormElement(this.input.cloneNode(), {
placeholder: this.input.placeholder,
value: this.input.dataset.text || this.formatDisplayValue(this.input.value) || '',
'aria-autocomplete': 'list',
});
ac.removeClass('toolshed-autocomplete');
ac.removeAttrs(['name', 'data-autocomplete']);
this.input.style.display = 'none';
ac.attachTo(this.input, 'after');
// Remove these Drupal properties as this is just a stand-in object
// and we don't want to submit or to catch any Drupal behaviors.
delete ac.dataset.drupalSelector;
delete ac.dataset.autocompletePath;
delete ac.dataset.text;
// Reassociate a label element to point to the new autocomplete input.
if (inputLabel) {
ac.id = `${this.input.id}-autocomplete`;
inputLabel.htmlFor = ac.id;
}
}
else {
ac = new ts.FormElement(this.input);
ac.setAttr('aria-autocomplete', 'both');
}
this.ac = ac;
ac.setAttrs({
class: 'form-autocomplete',
autocomplete: 'off',
role: 'combobox',
'aria-owns': list.id,
'aria-haspopup': 'listbox',
'aria-expanded': 'false',
});
// Bind key change events.
ac.on('keydown', this.onTextKeydown.bind(this));
ac.on('input', this.onTextChange.bind(this));
ac.on('focus', this.onFocus.bind(this));
ac.on('blur', this.onBlur.bind(this));
if (this.config.delay > 0) {
this.fetchSuggestions = debounce(this.fetchSuggestions, this.config.delay);
}
if (this.config.params && this.input.form) {
const { form } = this.input;
this.onParamChange = this.onParamChange.bind(this);
Object.values(this.config.params).forEach((elemId) => {
const el = form.querySelector(`#${elemId}`);
if (el) el.addEventListener('change', this.onParamChange);
});
}
}
/**
* Refresh the autocomplete suggests to display.
*
* @param {Array|Object} [data={}]
* Values to use as the autocomplete suggestions. The property keys are
* the value, and the property values are the display autocomplete value.
*/
createSuggestions(data = {}) {
this.activeItem = null;
if (!this.list.isEmpty()) {
this.list.clear();
}
if (data.list.length) {
if (this.emptyMsg.style.display !== 'none') {
this.emptyMsg.style.display = 'none';
}
this.list.buildItems(data.list, `${this.input.id}-opt`);
}
else {
this.emptyMsg.textContent = (data.empty) ? data.empty : 'No results';
this.emptyMsg.style.display = '';
}
}
/**
* Format the input value to the display text. By default the text that appears before ":" is
* hidden and considered an internal value switch. Subclasses of this autocomplete can
* override this and define their own display text value formatting.
*/
formatDisplayValue(value) {
return value ? value.replace(/^[^:]*?\s*:\s*/, '') : '';
}
/**
* Clear the current input.
*/
clearInput() {
this.ac.value = '';
this.input.value = '';
this.clearSuggestions();
}
/**
* Remove existing autocomplete suggestions.
*/
clearSuggestions() {
this.ac.removeAttrs('aria-activedescendant');
this.activeItem = null;
this.list.clear();
}
/**
* Is the suggestion window open?
*
* @return {Boolean}
* TRUE if the suggestions window is open, otherwise FALSE.
*/
isSuggestionsVisible() {
return this.suggestWrap.style.display !== 'none';
}
/**
* Position and expose the suggestions window if it is not already open.
*/
displaySuggestions() {
if (!this.isSuggestionsVisible()) {
this.suggestWrap.setStyles({
display: '',
width: `${this.ac.el.clientWidth}px`,
top: `${this.ac.el.offsetTop + this.ac.el.offsetHeight}px`,
left: `${this.ac.el.offsetLeft}px`,
});
this.ac.setAttrs({ 'aria-expanded': 'true' });
}
const ct = this.list.length;
this.liveRegion.message = ct > 0
? t('@count results available, use up and down arrow keys to navigate.', { '@count': ct })
: t('No search results.');
}
/**
* Hide the suggestions window if it is open and reset the active item.
*/
hideSuggestions() {
this.activeItem = null;
this.suggestWrap.style.display = 'none';
this.ac.setAttrs({ 'aria-expanded': 'false' });
}
/**
* Create the content to display in the autocomplete.
*
* Handles either building a simple text display or building the HTML to
* a complex display, with a text label.
*
* @param {SuggestItem} item
* The suggestion item to change the inner content of.
* @param {Object} data
* The raw data from the autocomplete response.
*/
itemDisplay(item, data) { // eslint-disable-line class-methods-use-this
if (data.html) {
item.innerHTML = data.html;
item.setAttrs({ 'aria-label': data.text || data.value });
}
else {
item.textContent = item.text || item.value;
}
}
/**
* Set a SuggestItem as currently active based on the index. This method
* ensures that any currently active items are blurred (unfocused) and
* if the requested index is out of range, to select no items.
*
* @param {SuggestItem} item
* The item to set as the current active item.
*/
setActiveItem(item) {
if (this.activeItem) this.activeItem.blur();
if (item) {
item.focus();
this.activeItem = item;
if (item.id) {
this.ac.setAttrs({ 'aria-activedescendant': item.id });
}
}
else {
this.activeItem = null;
this.ac.removeAttrs('aria-activedescendant');
}
}
/**
* Transfer the values from the passed in item to the form elements.
*
* @param {SuggestItem} item
* Item to apply to as the values of the autocomplete widget.
*/
selectItem(item) {
if (item.url) {
window.location = item.url;
}
else {
if (this.ac.el !== this.input) {
this.ac.value = item.text || item.value;
}
this.input.value = item.value;
}
this.hideSuggestions();
}
/**
* Make the necessary requests and calls to get available text values.
*
* @param {string} text
* The text to try to match using the autocomplete service to
* generate suggestions with.
* @param {bool} display
* Should the suggestions list be displayed after fetching suggestions.
*/
fetchSuggestions(text, display = true) {
const {ac, config, pending} = this;
if (pending && !pending.promise.isResolved) {
// Abort any previously pending requests, so this late response
// won't overwrite our desired values if it returns out of order.
pending.xhr.abort();
}
if (!text || text.length < config.minLength) {
if (this.isSuggestionsVisible()) this.hideSuggestions();
return;
}
// Turn on the loading spinner.
ac.addClass(config.pendingClasses);
// Apply additional request parameters if there are linked inputs.
const requestParams = {};
if (config.params) {
Object.entries(config.params).forEach(([key, elemId]) => {
const input = document.getElementById(elemId);
if (input && input.value) requestParams[key] = input.value;
});
}
requestParams.q = text;
if (!this.requester) {
this.requester = ts.createRequester(config.uri);
}
this.pending = this.requester(requestParams);
this.pending.promise.then(
(response) => {
this.clearLoading(ac, config);
this.createSuggestions(response);
// If requested to display suggestions after they load.
if (display) this.displaySuggestions();
},
(reason) => {
// Only remove the autocomplete loading if the request was aborted.
if ((reason||{}).message !== 'Cancelled') {
this.clearLoading(ac, config);
}
},
);
}
/**
* Clear the autocomplete loading status and displays.
*
* @param {ToolshedElement} el
* Autocomplete element to remove loading status from.
*/
clearLoading(el, conf) {
// Clear the loading classes and states.
el.removeClass(conf.pendingClasses);
// Hide Claro or other theme autocomplete message elements.
const msg = el.parentElement.querySelector(':scope > [data-drupal-selector="autocomplete-message"]');
if (msg) {
msg.classList.add('hidden');
}
}
/**
* When focus is on the autocomplete input, show suggestions if they exist.
*/
onFocus() {
if (!this.list.isEmpty()) {
this.displaySuggestions();
}
}
/**
* When focus leaves the autocomplete input, hide suggestions.
*
* @param {BlurEvent} e
* The blur event information for when the autocomplete loses focus.
*/
onBlur(e) {
if (this.isSuggestionsVisible()) {
if (!e.relatedTarget || !(e.relatedTarget === this.ac.el || e.relatedTarget.closest('[role=listbox]') === this.list.el)) {
this.hideSuggestions();
}
}
}
/**
* Create a mouse down event which avoids having the AC input lose focus
* from clicking into the suggestions wrapper. This inconveniently prevents
* the click event because it hides the suggestions before the click event
* can occur.
*
* @param {Event} event
* The mouse down event object.
*/
// eslint-disable-next-line class-methods-use-this
onMouseDown(event) {
event.preventDefault();
}
/**
* Callback for updating the input value when the textfield is updated.
*
* @param {Event} event
* The input changed event object.
*/
onTextChange(event) {
if (this.ac.el !== this.input) {
this.input.value = this.config.requireSelect ? '' : this.ac.el.value;
}
this.fetchSuggestions(event.target.value);
}
/**
* Input value changed callback to refresh our input values when a
* dependent parameter changes.
*/
onParamChange() {
this.clearInput();
}
/**
* Respond to keyboard navigation, and move the active item.
*
* @param {KeydownEvent} e
* Respond to a key-up event for the autocomplete input textfield.
*/
onTextKeydown(e) {
if (!this.isSuggestionsVisible()) return;
let inc;
let method;
switch (e.keyCode) {
case 13: // Enter key
if (this.activeItem) {
e.preventDefault();
this.selectItem(this.activeItem);
return;
}
// eslint-ignore-line no-fallthrough
case 9: // Tab key
case 27: // Escape key
this.hideSuggestions();
return;
case 40: // Up key
method = SuggestList.prototype.getFirst;
inc = 1;
break;
case 38: // Down key
method = SuggestList.prototype.getLast;
inc = -1;
break;
default:
return;
}
e.preventDefault();
let item = this.activeItem;
if (item) {
let p = item.parent;
let i = item.pos + inc;
while (p && (i < 0 || p.items.length === i)) {
item = p;
i = item.pos + inc;
p = item.parent;
}
if (p) {
item = p.items[i];
}
}
item = item || this.list;
if (item instanceof SuggestList) {
item = method.call(item);
}
this.setActiveItem(item);
}
/**
* Clean up DOM and event listeners initialized by this autocompete widget.
*/
destroy() {
// Abort any pending request for this widget.
if (this.pending && !this.pending.promise.isResolved) {
this.pending.xhr.abort();
}
delete this.pending;
if (this.config.params) {
Object.values(this.config.params).forEach((elemId) => {
const el = document.getElementById(elemId);
if (el) el.removeEventListener('change', this.onParamChange);
});
}
if (this.ac.el !== this.input) {
if (this.ac.id && this.input.id) {
// Reassociate a label element to point to the original input.
const inputLabel = document.querySelector(`label[for='${this.ac.id}']`);
if (inputLabel) {
inputLabel.htmlFor = this.input.id;
}
}
this.ac.destroy(true);
}
this.list.destroy(true);
this.emptyMsg.destroy(true);
this.suggestWrap.destroy(true);
this.liveRegion.destroy(true);
this.input.style.display = '';
}
};
/**
* Find autocomplete inputs and attach the autocomplete behaviors to it.
*/
behaviors.toolshedAutocomplete = {
// Track instances of Autocomplete objects so they can be detached.
instances: new Map(),
attach(context) {
ts.walkByClass(context, 'toolshed-autocomplete', (item) => {
const settings = item.dataset.autocomplete ? JSON.parse(item.dataset.autocomplete) : {};
if (item.dataset.params) {
settings.params = JSON.parse(item.dataset.params);
}
if (settings.uri) {
const ac = new ts.Autocomplete(item, settings);
this.instances.set(item.id || item, ac);
}
}, 'autocomplete--processed');
},
detach(context, settings, trigger) {
if (trigger === 'unload') {
ts.walkBySelector(context, '.toolshed-autocomplete.autocomplete--processed', (item) => {
const ac = this.instances.get(item.id || item);
if (ac) {
item.classList.remove('autocomplete--processed');
this.instances.delete(item);
ac.destroy();
}
});
}
},
};
})(Drupal);
