util/searchbox.js

/**
 * Inline installer shared by the search components. Keep it self-contained: components can be used
 * without the Hexo asset generator, and PJAX may execute this script more than once.
 * @module util/searchbox
 */
function installSearchbox(window) {
  if (window.loadSearchbox) return;

  const document = window.document;
  let current;
  window.loadSearchbox = function ($main, options) {
    const $ = window.jQuery;
    options = options || {};
    // Preserve focus state only when replacing the controller for the same open DOM element.
    let opener = current ? current.destroy($main[0]) : null;
    let touch = false;
    const $document = $(document);
    const namespace = '.hexoSearchbox';
    const controller = {
      isCurrent: function () {
        return current === controller && document.contains($main[0]);
      },
      destroy: function (nextMain) {
        if (current !== controller) return null;
        const nextOpener = nextMain === $main[0] && $main.hasClass('show') ? opener : null;
        $document.off(namespace);
        opener = null;
        current = null;
        if (options.onDestroy) options.onDestroy();
        return nextOpener;
      },
    };
    current = controller;

    function open() {
      if (!controller.isCurrent()) return;
      if (!$main.hasClass('show')) {
        opener = document.activeElement;
        $main.addClass('show');
      }
      $main.find('.searchbox-input').focus();
    }

    function close(restoreFocus) {
      $main.removeClass('show');
      if (restoreFocus !== false && opener && document.contains(opener)) opener.focus();
      opener = null;
    }

    function isSearchShortcut(e) {
      if (e.altKey) return false;
      if (e.ctrlKey || e.metaKey) {
        return !e.shiftKey && typeof e.key === 'string' && e.key.toLowerCase() === 'k';
      }
      // e.key already accounts for the keyboard layout (including Shift).
      const target = e.target;
      const typing = target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName);
      return e.key === '/' && !typing;
    }

    $document
      .on('click' + namespace, '.navbar-main .search', open)
      .on(
        'click' + namespace + (options.touch ? ' touchend' + namespace : ''),
        '.searchbox-close, .searchbox .searchbox-mask',
        (e) => {
          if (!controller.isCurrent()) return;
          if (e.type !== 'click' && !touch) return;
          if (options.touch) {
            $('.navbar-main').css('pointer-events', 'none');
            setTimeout(() => {
              $('.navbar-main').css('pointer-events', 'auto');
            }, 400);
          }
          close();
          touch = false;
        },
      )
      .on('focusin' + namespace, (e) => {
        if (controller.isCurrent() && $main.hasClass('show') && !$main[0].contains(e.target)) {
          close(false);
        }
      })
      .on('keydown' + namespace, (e) => {
        if (!controller.isCurrent()) return;
        if (isSearchShortcut(e)) {
          e.preventDefault();
          open();
          return;
        }
        if (!$main.hasClass('show')) return;
        if (e.keyCode === 27) close();
        else if (options.onKeyDown) options.onKeyDown(e);
      })
      .on('touchstart' + namespace, () => {
        touch = true;
      })
      .on('touchmove' + namespace, () => {
        touch = false;
      });

    return controller;
  };
}

module.exports = '(' + installSearchbox.toString() + ')(window);';