Relative querySelector and querySelectorAll

Calling querySelector or querySelectorAll on an element has slightly odd behavior where the selector is matched relative to the document, not the element. It’s the equivalent of calling querySelector* on the document and then filtering out all the nodes that are not descendants of the element.

This is different to most JS libraries (e.g. jQuery’s find() method) where the selector is relative to the element, not the whole document. Luckily there’s a simple way to make querySelector* work like jQuery by using the :scope pseudo-class.

For example given the HTML:

<div class="outer">
  <div id="middle">
    <div class="inner"></div>
  </div>
</div>
let middle = document.getElementById('middle');

// This will return the inner element as it's
// realtive to the document root
middle.querySelector('.outer .inner');

// This will return null as expected
middle.querySelector(':scope .outer .inner');

According to MDN, the :scope pseudo-class is supported in Firefox 32+, Chrome 20+, Opera 15+ & Safari 7+.

To support in older browsers (and IE) I’ve created a small polyfill:

/**
 * Scope pseudo-class polyfill
 *
 * @license MIT
 * @author Sam Clarke <sam@samclarke.com>
 */
(function (elmPrototype, docPrototype) {
  var scopeRegex = /:scope\b/gi;

  function patchElement(method) {
    var native = elmPrototype[method];

    elmPrototype[method] = function (selector) {
      var element = this;
      var id = element.id || 'qsid' + new Date().getTime();
      var needsId = !element.id;
      var hasScope = scopeRegex.test(selector);

      try {
        if (hasScope) {
          if (needsId) {
            element.id = id;
          }
          
          selector = selector.replace(scopeRegex, '#' + id);
        }

        return native.call(this, selector);
      } finally {
        if (needsId) {
          element.id = null;
        }
      }
    }
  }

  function patchDocument(method) {
    var native = docPrototype[method];
    docPrototype[method] = function (selector) {      
      // In context of document, :scope is the same 
      // as :root so can just be stripped
      // https://www.w3.org/TR/selectors4/#scope-pseudo
      return native.call(this, selector.replace(scopeRegex, ''));
    }
  }

  try {
    document.querySelector(':scope');
  } catch (err) {
    patchElement('querySelector');
    patchElement('querySelectorAll');
    patchDocument('querySelector');
    patchDocument('querySelectorAll');
  }
}(Element.prototype, Document.prototype));

It works by replacing the :scope pseudo-class with the element ID (adds a temporary ID if the element doesn’t have one) which makes the selector relative to the element the same as :scope.

Comments