Source: lib/ac.js

/**
 * Based on the MIT-licensed remote-ac project (https://github.com/danqing/autocomplete).
 * We maintain an in-repo fork to support accessibility improvements and Node 22 tooling.
 */
(function(root, factory) {
  const exported = factory(root);
  if (typeof module === 'object' && module.exports)
    module.exports = exported;
  if (root && !root.AC)
    root.AC = exported;
})(typeof globalThis !== 'undefined' ? globalThis :
  typeof self !== 'undefined' ? self :
    typeof window !== 'undefined' ? window : this, function(globalRoot) {

  'use strict';

  const DEFAULT_DELAY_MS = 300;
  const DEFAULT_MIN_LENGTH = 1;

  /**
   * Lightweight autocomplete widget tailored for lib.reviews adapters.
   * Exposes the same surface as the legacy remote-ac package while improving
   * keyboard and assistive technology support.
   *
   * @class
   */
  class Autocomplete {
    constructor(inputEl, urlFn, requestFn, resultFn, rowFn, triggerFn, anchorEl) {
      if (!inputEl || typeof inputEl !== 'object')
        throw new TypeError('Autocomplete requires a DOM input element.');

      this.inputEl = inputEl;
      this.anchorEl = anchorEl || inputEl;

      this.urlBuilderFn = urlFn || null;
      this.requestFn = requestFn || null;
      this.resultFn = resultFn || null;
      this.rowFn = rowFn || null;
      this.triggerFn = triggerFn || null;

      this.primaryTextKey = 'title';
      this.secondaryTextKey = 'subtitle';
      this.delay = DEFAULT_DELAY_MS;
      this.minLength = DEFAULT_MIN_LENGTH;
      this.cssPrefix = 'ac-';
      this.adapter = null;

      this.value = '';
      this.results = [];
      this.rows = [];
      this.selectedIndex = -1;
      this.isRightArrowComplete = false;
      this.isMounted = false;

      this.el = null;
      this.rowWrapperEl = null;
      this._abortController = null;
      this._listId = `ac-list-${Math.random().toString(36).slice(2, 9)}`;
      this._rowIdPrefix = `${this._listId}-row`;

      this.timeoutID = null;
      this.latestQuery = undefined;

      this.keydownHandler = this.handleKeydown.bind(this);
      this.inputHandler = this.handleInput.bind(this);
      this.clickHandler = this.handleClick.bind(this);
      this.resizeHandler = this.position.bind(this);
      this.mountHandler = this.mount.bind(this);

      this.activate();
      this.inputEl.setAttribute('aria-autocomplete', 'list');
    }

    activate() {
      this.inputEl.addEventListener('focus', this.mountHandler);
    }

    deactivate() {
      this.unmount();
      this.inputEl.removeEventListener('focus', this.mountHandler);
    }

    mount() {
      if (this.isMounted)
        return;

      if (!this.el) {
        this.el = Autocomplete.createEl('div', this.getCSS('WRAPPER'));
        this.el.setAttribute('role', 'listbox');
        this.el.id = this._listId;
        document.body.appendChild(this.el);
      } else {
        this.el.style.display = '';
      }

      if (!this.rowWrapperEl) {
        this.rowWrapperEl = Autocomplete.createEl('div', this.getCSS('ROW_WRAPPER'));
        this.el.appendChild(this.rowWrapperEl);
      }

      const win = Autocomplete._getWindow();
      if (win) {
        win.addEventListener('keydown', this.keydownHandler);
        win.addEventListener('input', this.inputHandler, true);
        win.addEventListener('resize', this.resizeHandler);
        if (Autocomplete.isMobileSafari())
          win.addEventListener('touchend', this.clickHandler);
        else
          win.addEventListener('click', this.clickHandler);
      }

      this.inputEl.setAttribute('aria-expanded', 'true');
      this.inputEl.setAttribute('aria-owns', this._listId);

      this.position();
      this.render();
      this.isMounted = true;

      const viewportWidth = Autocomplete._getViewportWidth();
      if (viewportWidth !== null && viewportWidth < 500) {
        Autocomplete._withFallback(() => {
          this.inputEl.scrollIntoView({ block: 'nearest' });
        }, () => {
          this.inputEl.scrollIntoView();
        });
      }
    }

    unmount() {
      if (!this.isMounted)
        return;

      const win = Autocomplete._getWindow();
      if (win) {
        win.removeEventListener('keydown', this.keydownHandler);
        win.removeEventListener('input', this.inputHandler, true);
        win.removeEventListener('resize', this.resizeHandler);
        if (Autocomplete.isMobileSafari())
          win.removeEventListener('touchend', this.clickHandler);
        else
          win.removeEventListener('click', this.clickHandler);
      }

      if (this.el)
        this.el.style.display = 'none';

      this.abortPendingRequest();

      this.inputEl.removeAttribute('aria-activedescendant');
      this.inputEl.setAttribute('aria-expanded', 'false');
      this.isMounted = false;
    }

    position() {
      if (!this.el)
        return;

      const rect = this.anchorEl.getBoundingClientRect();
      const offset = Autocomplete.findPosition(this.anchorEl);
      this.el.style.top = `${offset.top + rect.height}px`;
      this.el.style.left = `${offset.left}px`;
      this.el.style.width = `${rect.width}px`;
    }

    handleKeydown(event) {
      switch (event.keyCode) {
        case Autocomplete.KEYCODE.UP:
          event.preventDefault();
          this.setSelectedIndex(this.selectedIndex - 1);
          break;
        case Autocomplete.KEYCODE.DOWN:
          event.preventDefault();
          this.setSelectedIndex(this.selectedIndex + 1);
          break;
        case Autocomplete.KEYCODE.RIGHT:
          if (this.selectedIndex > -1 && this.results[this.selectedIndex]) {
            this.inputEl.value = this.results[this.selectedIndex][this.primaryTextKey];
            this.isRightArrowComplete = true;
          }
          break;
        case Autocomplete.KEYCODE.ENTER:
          if (this.selectedIndex > -1) {
            event.preventDefault();
            this.trigger(event);
          }
          break;
        case Autocomplete.KEYCODE.ESC:
          this.inputEl.blur();
          this.unmount();
          break;
        default:
          break;
      }
    }

    handleInput() {
      this.value = this.inputEl.value;
      this.isRightArrowComplete = false;
      if (this.timeoutID)
        clearTimeout(this.timeoutID);
      this.timeoutID = setTimeout(() => this.requestMatch(), this.delay);
    }

    setSelectedIndex(index) {
      if (!this.rows.length)
        return;

      let nextIndex = index;
      if (nextIndex === this.selectedIndex)
        return;
      if (nextIndex >= this.rows.length)
        nextIndex = nextIndex - this.rows.length;
      if (nextIndex < 0)
        nextIndex = this.rows.length + nextIndex;

      if (this.selectedIndex >= 0 && this.rows[this.selectedIndex]) {
        const previousRow = this.rows[this.selectedIndex];
        this._removeClasses(previousRow, 'SELECTED_ROW');
        previousRow.setAttribute('aria-selected', 'false');
      }

      const row = this.rows[nextIndex];
      this._addClasses(row, 'SELECTED_ROW');
      row.setAttribute('aria-selected', 'true');
      this.selectedIndex = nextIndex;
      this.inputEl.setAttribute('aria-activedescendant', row.id);

      if (this.isRightArrowComplete)
        this.inputEl.value = this.results[this.selectedIndex][this.primaryTextKey];
    }

    handleClick(event) {
      const target = event.target;
      if (!target)
        return;

      if (target === this.inputEl)
        return;

      if (this.el && this.el.contains(target)) {
        const row = target.closest('[data-rid]');
        if (!row)
          return;

        const rowId = parseInt(row.getAttribute('data-rid'), 10);
        if (Number.isNaN(rowId))
          return;

        this.selectedIndex = rowId;
        this.trigger(event);
        return;
      }

      this.unmount();
    }

    trigger(event) {
      if (this.selectedIndex < 0 || !this.results[this.selectedIndex])
        return;

      const result = this.results[this.selectedIndex];
      this.value = result[this.primaryTextKey];
      this.inputEl.value = this.value;
      this.inputEl.blur();

      if (typeof this.triggerFn === 'function')
        this.triggerFn(result, event);

      this.unmount();
    }

    requestMatch() {
      if (typeof this.requestFn === 'function') {
        this.requestFn(this.value);
        return;
      }

      if (!this.urlBuilderFn)
        return;

      this.abortPendingRequest();

      if (typeof this.value !== 'string' || this.value.length < this.minLength) {
        this.results = [];
        this.selectedIndex = -1;
        this.render();
        return;
      }

      if (typeof fetch !== 'function') {
        throw new Error('No fetch implementation available for autocomplete.');
      }

      this._abortController = typeof AbortController === 'function' ? new AbortController() : null;
      const signal = this._abortController ? this._abortController.signal : undefined;

      fetch(this.urlBuilderFn(this.value), { signal })
        .then(response => {
          if (!response.ok)
            throw new Error(`HTTP ${response.status}`);
          return response.json();
        })
        .then(results => {
          this.results = Array.isArray(results) ? results : [];
          this.render();
        })
        .catch(error => {
          if (error && error.name === 'AbortError')
            return;
          this.results = [];
          this.render();
        });
    }

    abortPendingRequest() {
      if (this._abortController) {
        this._abortController.abort();
        this._abortController = null;
      }
    }

    render() {
      this.selectedIndex = -1;
      this.rows = [];

      if (!this.rowWrapperEl)
        return;

      this.rowWrapperEl.innerHTML = '';

      if (this.results.length) {
        const fragment = document.createDocumentFragment();
        this.results.forEach((result, index) => {
          let row = null;
          if (typeof this.rowFn === 'function') {
            row = this.rowFn(result);
          } else {
            row = this.createRow(index);
          }

          if (!row)
            return;

          this._addClasses(row, 'ROW');
          row.setAttribute('data-rid', index);
          row.setAttribute('role', 'option');
          row.setAttribute('aria-selected', 'false');
          row.id = `${this._rowIdPrefix}-${index}`;

          fragment.appendChild(row);
          this.rows.push(row);
        });
        this.rowWrapperEl.style.display = '';
        this.rowWrapperEl.appendChild(fragment);
      } else {
        this.rowWrapperEl.style.display = 'none';
      }
    }

    createRow(index) {
      const data = this.results[index];
      const element = Autocomplete.createEl('div', this.getCSS('ROW'));

      const primary = Autocomplete.createEl('span', this.getCSS('PRIMARY_SPAN'));
      primary.appendChild(Autocomplete.createMatchTextEls(this.value, data[this.primaryTextKey]));
      element.appendChild(primary);

      const secondary = data[this.secondaryTextKey];
      if (secondary)
        element.appendChild(Autocomplete.createEl('span', this.getCSS('SECONDARY_SPAN'), secondary));

      return element;
    }

    getCSS(key) {
      if (!Object.prototype.hasOwnProperty.call(Autocomplete.CLASS, key))
        throw new Error(`CSS element ID "${key}" not recognized.`);
      return `${this.cssPrefix}${Autocomplete.CLASS[key]}`;
    }

    _addClasses(element, key) {
      if (!element)
        return;

      const classes = this.getCSS(key).split(/\s+/);
      classes.forEach(cls => {
        if (cls)
          element.classList.add(cls);
      });
    }

    _removeClasses(element, key) {
      if (!element)
        return;

      const classes = this.getCSS(key).split(/\s+/);
      classes.forEach(cls => {
        if (cls)
          element.classList.remove(cls);
      });
    }

    static isMobileSafari() {
      /* istanbul ignore next */
      const nav = Autocomplete._getNavigator();
      if (!nav || !nav.userAgent)
        return false;
      const ua = nav.userAgent;
      const iOS = /iPad|iPhone/.test(ua);
      return iOS && /WebKit/.test(ua) && !/CriOS/.test(ua);
    }

    static createMatchTextEls(input, complete) {
      const fragment = document.createDocumentFragment();
      if (!complete)
        return fragment;

      const trimmedInput = input ? input.trim() : '';
      const len = trimmedInput.length;
      const lowerComplete = complete.toLowerCase();
      const lowerInput = trimmedInput.toLowerCase();
      const index = len ? lowerComplete.indexOf(lowerInput) : -1;

      if (index === 0) {
        fragment.appendChild(Autocomplete.createEl('b', null, complete.substring(0, len)));
        fragment.appendChild(Autocomplete.createEl('span', null, complete.substring(len)));
      } else if (index > 0) {
        fragment.appendChild(Autocomplete.createEl('span', null, complete.substring(0, index)));
        fragment.appendChild(Autocomplete.createEl('b', null, complete.substring(index, index + len)));
        fragment.appendChild(Autocomplete.createEl('span', null, complete.substring(index + len)));
      } else {
        fragment.appendChild(Autocomplete.createEl('span', null, complete));
      }

      return fragment;
    }

    static createEl(tag, className, textContent) {
      const element = document.createElement(tag);
      if (className)
        className.split(/\s+/).filter(Boolean).forEach(cls => element.classList.add(cls));
      if (textContent)
        element.appendChild(document.createTextNode(textContent));
      return element;
    }

    static findPosition(el) {
      const rect = el.getBoundingClientRect();
      const win = Autocomplete._getWindow();
      const doc = el.ownerDocument || (win && win.document);
      const pageYOffset = win && typeof win.pageYOffset === 'number' ? win.pageYOffset :
        doc && doc.documentElement ? doc.documentElement.scrollTop : 0;
      const pageXOffset = win && typeof win.pageXOffset === 'number' ? win.pageXOffset :
        doc && doc.documentElement ? doc.documentElement.scrollLeft : 0;
      const top = rect.top + pageYOffset;
      const left = rect.left + pageXOffset;
      return { left, top };
    }

    static encodeQuery(obj) {
      if (!obj || typeof obj !== 'object')
        return '';
      return Object.keys(obj)
        .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
        .join('&');
    }

    static _getWindow() {
      if (typeof window !== 'undefined')
        return window;
      if (globalRoot && globalRoot.window)
        return globalRoot.window;
      return null;
    }

    static _getNavigator() {
      if (typeof navigator !== 'undefined')
        return navigator;
      const win = Autocomplete._getWindow();
      if (win && win.navigator)
        return win.navigator;
      return null;
    }

    static _getViewportWidth() {
      const win = Autocomplete._getWindow();
      if (!win)
        return null;
      return Math.max(
        win.document && win.document.documentElement ? win.document.documentElement.clientWidth : 0,
        win.innerWidth || 0
      );
    }

    static _withFallback(primaryFn, fallbackFn) {
      try {
        primaryFn();
      } catch (_error) {
        fallbackFn();
      }
    }
  }

  Autocomplete.KEYCODE = {
    ENTER: 13,
    ESC: 27,
    LEFT: 37,
    UP: 38,
    RIGHT: 39,
    DOWN: 40
  };

  Autocomplete.CLASS = {
    WRAPPER: 'wrap',
    ROW_WRAPPER: 'rwrap',
    ROW: 'row',
    SELECTED_ROW: 'row selected',
    PRIMARY_SPAN: 'pr',
    SECONDARY_SPAN: 'sc',
    MOBILE_INPUT: 'minput',
    CANCEL: 'cancel'
  };

  return Autocomplete;
});