DOM Selector
A CSS selector engine.
Install
npm i @asamuzakjp/dom-selector
Usage
import { DOMSelector } from '@asamuzakjp/dom-selector';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM();
const {
closest, matches, querySelector, querySelectorAll
} = new DOMSelector(window);
matches(selector, node, opt)
matches - same functionality as Element.matches()
Parameters
selector
string CSS selectornode
object Element nodeopt
object? options
opt.event
object? instance of MouseEvent, KeyboardEventopt.noexcept
boolean? no exceptionopt.warn
boolean? console warn e.g. unsupported pseudo-class
Returns boolean true
if matched, false
otherwise
closest(selector, node, opt)
closest - same functionality as Element.closest()
Parameters
selector
string CSS selectornode
object Element nodeopt
object? options
opt.event
object? instance of MouseEvent, KeyboardEventopt.noexcept
boolean? no exceptionopt.warn
boolean? console warn e.g. unsupported pseudo-class
Returns object? matched node
querySelector(selector, node, opt)
querySelector - same functionality as Document.querySelector(), DocumentFragment.querySelector(), Element.querySelector()
Parameters
selector
string CSS selectornode
object Document, DocumentFragment or Element nodeopt
object? options
opt.event
object? instance of MouseEvent, KeyboardEventopt.noexcept
boolean? no exceptionopt.warn
boolean? console warn e.g. unsupported pseudo-class
Returns object? matched node
querySelectorAll(selector, node, opt)
querySelectorAll - same functionality as Document.querySelectorAll(), DocumentFragment.querySelectorAll(), Element.querySelectorAll()
NOTE: returns Array, not NodeList
Parameters
selector
string CSS selectornode
object Document, DocumentFragment or Element nodeopt
object? options
opt.event
object? instance of MouseEvent, KeyboardEventopt.noexcept
boolean? no exceptionopt.warn
boolean? console warn e.g. unsupported pseudo-class
Returns Array<(object | undefined)> array of matched nodes
Supported CSS selectors
Pattern | Supported | Note |
---|
* | ✓ | |
ns|E | ✓ | |
*|E | ✓ | |
|E | ✓ | |
E | ✓ | |
E:not(s1, s2, …) | ✓ | |
E:is(s1, s2, …) | ✓ | |
E:where(s1, s2, …) | ✓ | |
E:has(rs1, rs2, …) | ✓ | |
E.warning | ✓ | |
E#myid | ✓ | |
E[foo] | ✓ | |
E[foo="bar"] | ✓ | |
E[foo="bar" i] | ✓ | |
E[foo="bar" s] | ✓ | |
E[foo~="bar"] | ✓ | |
E[foo^="bar"] | ✓ | |
E[foo$="bar"] | ✓ | |
E[foo*="bar"] | ✓ | |
E[foo|="en"] | ✓ | |
E:defined | Partially supported | Matching with MathML is not yet supported. |
E:dir(ltr) | ✓ | |
E:lang(en) | Partially supported | Comma-separated list of language codes, e.g. :lang(en, fr) , is not yet supported. |
E:any‑link | ✓ | |
E:link | ✓ | |
E:visited | ✓ | Returns false or null to prevent fingerprinting. |
E:local‑link | ✓ | |
E:target | ✓ | |
E:target‑within | ✓ | |
E:scope | ✓ | |
E:current | Unsupported | |
E:current(s) | Unsupported | |
E:past | Unsupported | |
E:future | Unsupported | |
E:active | ✓ | Enabled if a mousedown / pointerdown event is passed as an option. |
E:hover | ✓ | Enabled if a mouseover / pointerover event is passed as an option. |
E:focus | ✓ | |
E:focus‑within | ✓ | |
E:focus‑visible | ✓ | Enabled if a keydown event is passed as an option. |
E:open E:closed | Partially supported | Matching with <select>, e.g. select:open , is not supported. |
E:enabled E:disabled | ✓ | |
E:read‑write E:read‑only | ✓ | |
E:placeholder‑shown | ✓ | |
E:default | ✓ | |
E:checked | ✓ | |
E:indeterminate | ✓ | |
E:valid E:invalid | ✓ | |
E:required E:optional | ✓ | |
E:blank | Unsupported | |
E:user‑valid E:user‑invalid | Unsupported | |
E:root | ✓ | |
E:empty | ✓ | |
E:nth‑child(n [of S]?) | ✓ | |
E:nth‑last‑child(n [of S]?) | ✓ | |
E:first‑child | ✓ | |
E:last‑child | ✓ | |
E:only‑child | ✓ | |
E:nth‑of‑type(n) | ✓ | |
E:nth‑last‑of‑type(n) | ✓ | |
E:first‑of‑type | ✓ | |
E:last‑of‑type | ✓ | |
E:only‑of‑type | ✓ | |
E F | ✓ | |
E > F | ✓ | |
E + F | ✓ | |
E ~ F | ✓ | |
F || E | Unsupported | |
E:nth‑col(n) | Unsupported | |
E:nth‑last‑col(n) | Unsupported | |
E:host | ✓ | |
E:host(s) | ✓ | |
E:host‑context(s) | ✓ | |
E:popover-open | ✓ | |
E:state(v) | ✓ | *1 |
E:host(:state(v)) | ✓ | *1 |
*1: ElementInternals.states
, i.e. CustomStateSet
, is not implemented in jsdom, so you need to apply a patch in the custom element constructor.
class LabeledCheckbox extends window.HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
if (!this.#internals.states) {
this.#internals.states = new Set();
}
this.addEventListener('click', this._onClick.bind(this));
}
get checked() {
return this.#internals.states.has('checked');
}
set checked(flag) {
if (flag) {
this.#internals.states.add('checked');
} else {
this.#internals.states.delete('checked');
}
}
_onClick(event) {
this.checked = !this.checked;
}
}
Monkey patch jsdom
import { DOMSelector } from '@asamuzakjp/dom-selector';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('', {
runScripts: 'dangerously',
url: 'http://localhost/',
beforeParse: window => {
const domSelector = new DOMSelector(window);
const matches = domSelector.matches.bind(domSelector);
window.Element.prototype.matches = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return matches(selector, this);
};
const closest = domSelector.closest.bind(domSelector);
window.Element.prototype.closest = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return closest(selector, this);
};
const querySelector = domSelector.querySelector.bind(domSelector);
window.Document.prototype.querySelector = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return querySelector(selector, this);
};
window.DocumentFragment.prototype.querySelector = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return querySelector(selector, this);
};
window.Element.prototype.querySelector = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return querySelector(selector, this);
};
const querySelectorAll = domSelector.querySelectorAll.bind(domSelector);
window.Document.prototype.querySelectorAll = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return querySelectorAll(selector, this);
};
window.DocumentFragment.prototype.querySelectorAll = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return querySelectorAll(selector, this);
};
window.Element.prototype.querySelectorAll = function (...args) {
if (!args.length) {
throw new window.TypeError('1 argument required, but only 0 present.');
}
const [selector] = args;
return querySelectorAll(selector, this);
};
}
});
Performance
See benchmark for the latest results.
F
: Failed because the selector is not supported or the result was incorrect.
matches()
Selector | jsdom v24.1.1 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
matches('.content') | 957,875 ops/sec ±0.97% | 7,248 ops/sec ±0.58% | 7,435 ops/sec ±0.63% | 811,680 ops/sec ±1.32% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
compound selector:
matches('p.content[id]:is(:last-child, :only-child)') | 591,790 ops/sec ±1.01% | 7,003 ops/sec ±0.84% | 7,118 ops/sec ±0.77% | 400,189 ops/sec ±0.29% | jsdom is the fastest and 1.5 times faster than patched-jsdom. |
compound selector:
matches('p.content[id]:is(:invalid-nth-child, :only-child)') | F | 7,018 ops/sec ±0.53% | F | 74,449 ops/sec ±0.90% | patched-jsdom is the fastest. |
compound selector:
matches('p.content[id]:not(:is(.foo, .bar))') | 471,272 ops/sec ±0.38% | 6,777 ops/sec ±0.57% | 7,067 ops/sec ±0.54% | 338,117 ops/sec ±1.49% | jsdom is the fastest and 1.4 times faster than patched-jsdom. |
complex selector:
matches('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner > .content') | 151,368 ops/sec ±1.59% | F | 4,723 ops/sec ±0.69% | 124,920 ops/sec ±1.31% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
complex selector:
matches('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner:has(> .content)') | F | F | 4,610 ops/sec ±0.68% | 41,718 ops/sec ±2.02% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
matches(':is(.box > .content, .block > .content)') | 411,511 ops/sec ±1.61% | F | 4,958 ops/sec ±0.35% | 333,788 ops/sec ±1.24% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
closest()
Selector | jsdom v24.1.1 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
closest('.container') | 370,268 ops/sec ±1.72% | 7,123 ops/sec ±0.63% | 7,399 ops/sec ±0.75% | 339,853 ops/sec ±1.49% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
compound selector:
closest('div.container[id]:not(.foo, .box)') | 133,846 ops/sec ±1.40% | F | 6,977 ops/sec ±0.61% | 124,635 ops/sec ±1.41% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
complex selector:
closest('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner > .content') | 139,233 ops/sec ±1.93% | F | 4,648 ops/sec ±0.87% | 119,991 ops/sec ±0.23% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
complex selector:
closest('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner:has(> .content)') | F | F | 4,618 ops/sec ±0.76% | 37,743 ops/sec ±1.86% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
closest(':is(.container > .content, .container > .box)') | 196,742 ops/sec ±1.46% | 4,454 ops/sec ±1.05% | 4,846 ops/sec ±0.61% | 176,010 ops/sec ±1.34% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
querySelector()
Selector | jsdom v24.1.1 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
querySelector('.content') | 32,150 ops/sec ±1.48% | 8,957 ops/sec ±1.16% | 8,707 ops/sec ±0.68% | 29,777 ops/sec ±1.81% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
compound selector:
querySelector('p.content[id]:is(:last-child, :only-child)') | 10,249 ops/sec ±1.33% | 8,618 ops/sec ±1.28% | 8,311 ops/sec ±0.60% | 9,614 ops/sec ±1.31% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
complex selector:
querySelector('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner > .content') | 230 ops/sec ±1.26% | F | 1,265 ops/sec ±1.58% | 268 ops/sec ±1.23% | linkedom is the fastest and 4.7 times faster than patched-jsdom. patched-jsdom is 1.2 times faster than jsdom. |
complex selector:
querySelector('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner:has(> .content)') | F | F | 1,501 ops/sec ±1.51% | 706 ops/sec ±2.25% | linkedom is the fastest and 2.1 times faster than patched-jsdom. |
complex selector within logical pseudo-class:
querySelector(':is(.box > .content, .block > .content)') | 3,305 ops/sec ±1.72% | F | 8,133 ops/sec ±0.71% | 154,046 ops/sec ±2.15% | patched-jsdom is the fastest. patched-jsdom is 46.6 times faster than jsdom. |
querySelectorAll()
Selector | jsdom v24.1.1 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
querySelectorAll('.content') | 3,099 ops/sec ±1.08% | 785 ops/sec ±1.79% | 1,145 ops/sec ±1.41% | 3,032 ops/sec ±0.28% | jsdom is the fastest and 1.0 times faster than patched-jsdom. |
compound selector:
querySelectorAll('p.content[id]:is(:last-child, :only-child)') | 1,018 ops/sec ±0.34% | 731 ops/sec ±1.57% | 1,155 ops/sec ±1.23% | 995 ops/sec ±1.18% | linkedom is the fastest and 1.2 times faster than patched-jsdom. jsdom is 1.0 times faster than patched-jsdom. |
complex selector:
querySelectorAll('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner > .content') | 231 ops/sec ±1.23% | F | 420 ops/sec ±1.37% | 276 ops/sec ±0.23% | linkedom is the fastest and 1.5 times faster than patched-jsdom. patched-jsdom is 1.2 times faster than jsdom. |
complex selector:
querySelectorAll('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner:has(> .content)') | F | F | 447 ops/sec ±1.41% | 801 ops/sec ±2.65% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
querySelectorAll(':is(.box > .content, .block > .content)') | 322 ops/sec ±0.40% | F | 508 ops/sec ±1.40% | 767 ops/sec ±1.66% | patched-jsdom is the fastest. patched-jsdom is 2.4 times faster than jsdom. |
Acknowledgments
The following resources have been of great help in the development of the DOM Selector.
Copyright (c) 2023 asamuzaK (Kazz)