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') | 989,454 ops/sec ±1.51% | 7,613 ops/sec ±1.10% | 9,516 ops/sec ±0.60% | 848,327 ops/sec ±0.66% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
compound selector:
matches('p.content[id]:is(:last-child, :only-child)') | 602,507 ops/sec ±0.32% | 7,328 ops/sec ±0.81% | 9,052 ops/sec ±0.48% | 405,877 ops/sec ±0.15% | 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,365 ops/sec ±0.90% | F | 66,159 ops/sec ±0.98% | patched-jsdom is the fastest. |
compound selector:
matches('p.content[id]:not(:is(.foo, .bar))') | 479,695 ops/sec ±1.60% | 7,470 ops/sec ±0.57% | 8,804 ops/sec ±0.44% | 346,257 ops/sec ±0.31% | 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') | 154,869 ops/sec ±0.80% | F | 5,845 ops/sec ±0.34% | 132,313 ops/sec ±0.22% | 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 | 5,754 ops/sec ±1.33% | 21,105 ops/sec ±1.16% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
matches(':is(.box > .content, .block > .content)') | 411,631 ops/sec ±0.31% | F | 6,123 ops/sec ±0.38% | 340,899 ops/sec ±0.19% | 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') | 379,529 ops/sec ±0.13% | 7,469 ops/sec ±1.56% | 9,306 ops/sec ±0.72% | 346,182 ops/sec ±1.52% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
compound selector:
closest('div.container[id]:not(.foo, .box)') | 137,674 ops/sec ±1.76% | F | 8,696 ops/sec ±0.72% | 125,138 ops/sec ±1.62% | 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') | 142,675 ops/sec ±0.43% | F | 5,804 ops/sec ±0.87% | 122,213 ops/sec ±1.38% | 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 | 5,733 ops/sec ±0.87% | 17,024 ops/sec ±0.74% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
closest(':is(.container > .content, .container > .box)') | 200,172 ops/sec ±1.51% | 4,808 ops/sec ±1.19% | 6,145 ops/sec ±0.53% | 176,908 ops/sec ±1.42% | 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') | 28,846 ops/sec ±0.23% | 9,426 ops/sec ±0.83% | 10,729 ops/sec ±0.58% | 29,550 ops/sec ±0.89% | patched-jsdom is the fastest. patched-jsdom is 1.0 times faster than jsdom. |
compound selector:
querySelector('p.content[id]:is(:last-child, :only-child)') | 10,152 ops/sec ±0.29% | 9,270 ops/sec ±0.75% | 10,907 ops/sec ±0.62% | 9,682 ops/sec ±1.47% | linkedom is the fastest and 1.1 times faster than patched-jsdom. jsdom is 1.0 times faster than patched-jsdom. |
complex selector:
querySelector('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner > .content') | 229 ops/sec ±1.41% | F | 1,319 ops/sec ±1.57% | 281 ops/sec ±0.46% | 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,606 ops/sec ±1.47% | 803 ops/sec ±1.47% | linkedom is the fastest and 2.0 times faster than patched-jsdom. |
complex selector within logical pseudo-class:
querySelector(':is(.box > .content, .block > .content)') | 3,246 ops/sec ±1.58% | F | 9,831 ops/sec ±0.80% | 153,338 ops/sec ±1.76% | patched-jsdom is the fastest. patched-jsdom is 47.2 times faster than jsdom. |
querySelectorAll()
Selector | jsdom v24.1.1 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
querySelectorAll('.content') | 2,660 ops/sec ±1.25% | 782 ops/sec ±0.27% | 1,234 ops/sec ±1.40% | 3,276 ops/sec ±0.90% | patched-jsdom is the fastest. patched-jsdom is 1.2 times faster than jsdom. |
compound selector:
querySelectorAll('p.content[id]:is(:last-child, :only-child)') | 977 ops/sec ±1.05% | 730 ops/sec ±1.55% | 1,191 ops/sec ±1.07% | 1,046 ops/sec ±1.11% | linkedom is the fastest and 1.1 times faster than patched-jsdom. patched-jsdom is 1.1 times faster than jsdom. |
complex selector:
querySelectorAll('.box:first-child ~ .box:nth-of-type(4n+1) + .box[id] .block.inner > .content') | 228 ops/sec ±1.21% | F | 428 ops/sec ±1.36% | 279 ops/sec ±1.28% | 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 | 465 ops/sec ±0.20% | 924 ops/sec ±0.52% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
querySelectorAll(':is(.box > .content, .block > .content)') | 312 ops/sec ±1.25% | F | 519 ops/sec ±1.57% | 780 ops/sec ±2.24% | patched-jsdom is the fastest. patched-jsdom is 2.5 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)