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);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc