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.0 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
matches('.content') | 955,191 ops/sec ±1.19% | 7,301 ops/sec ±0.75% | 9,322 ops/sec ±0.65% | 819,169 ops/sec ±0.43% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
compound selector:
matches('p.content[id]:is(:last-child, :only-child)') | 581,444 ops/sec ±1.46% | 7,152 ops/sec ±0.70% | 8,836 ops/sec ±1.32% | 402,486 ops/sec ±0.77% | jsdom is the fastest and 1.4 times faster than patched-jsdom. |
compound selector:
matches('p.content[id]:is(:invalid-nth-child, :only-child)') | F | 7,124 ops/sec ±0.90% | F | 129,958 ops/sec ±0.22% | patched-jsdom is the fastest. |
compound selector:
matches('p.content[id]:not(:is(.foo, .bar))') | 469,482 ops/sec ±1.48% | 7,179 ops/sec ±0.74% | 8,851 ops/sec ±0.71% | 343,674 ops/sec ±0.48% | 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') | 152,787 ops/sec ±0.23% | F | 5,806 ops/sec ±0.82% | 131,245 ops/sec ±0.28% | 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 .block.inner:has(> .content)') | F | F | 5,610 ops/sec ±0.57% | 9,285 ops/sec ±0.60% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
matches(':is(.box > .content, .block > .content)') | 407,178 ops/sec ±0.44% | F | 6,039 ops/sec ±0.33% | 332,047 ops/sec ±0.36% | jsdom is the fastest and 1.2 times faster than patched-jsdom. |
closest()
Selector | jsdom v24.1.0 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
closest('.container') | 367,267 ops/sec ±0.24% | 7,283 ops/sec ±0.67% | 9,298 ops/sec ±0.69% | 338,618 ops/sec ±1.73% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
compound selector:
closest('div.container[id]:not(.foo, .box)') | 135,537 ops/sec ±0.34% | F | 8,413 ops/sec ±1.50% | 120,204 ops/sec ±1.90% | 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,190 ops/sec ±1.50% | F | 5,821 ops/sec ±0.64% | 119,681 ops/sec ±1.44% | 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 .block.inner:has(> .content)') | F | F | 5,695 ops/sec ±0.58% | 7,659 ops/sec ±1.85% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
closest(':is(.container > .content, .container > .box)') | 198,275 ops/sec ±0.59% | 4,568 ops/sec ±0.73% | 5,922 ops/sec ±0.50% | 175,254 ops/sec ±0.27% | jsdom is the fastest and 1.1 times faster than patched-jsdom. |
querySelector()
Selector | jsdom v24.1.0 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
querySelector('.content') | 27,778 ops/sec ±1.10% | 8,861 ops/sec ±0.73% | 11,318 ops/sec ±0.66% | 29,314 ops/sec ±1.45% | patched-jsdom is the fastest. patched-jsdom is 1.1 times faster than jsdom. |
compound selector:
querySelector('p.content[id]:is(:last-child, :only-child)') | 9,731 ops/sec ±1.41% | 8,732 ops/sec ±1.50% | 9,954 ops/sec ±0.57% | 9,492 ops/sec ±1.59% | linkedom is the fastest and 1.0 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') | 221 ops/sec ±1.70% | F | 1,273 ops/sec ±1.13% | 272 ops/sec ±1.28% | 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 .block.inner:has(> .content)') | F | F | 1,594 ops/sec ±1.48% | 487 ops/sec ±2.03% | linkedom is the fastest and 3.3 times faster than patched-jsdom. |
complex selector within logical pseudo-class:
querySelector(':is(.box > .content, .block > .content)') | 3,108 ops/sec ±2.01% | F | 9,977 ops/sec ±0.99% | 90,725 ops/sec ±1.26% | patched-jsdom is the fastest. patched-jsdom is 29.2 times faster than jsdom. |
querySelectorAll()
Selector | jsdom v24.1.0 (nwsapi) | happy-dom | linkeDom | patched-jsdom (dom-selector) | Result |
---|
simple selector:
querySelectorAll('.content') | 2,610 ops/sec ±0.33% | 804 ops/sec ±0.47% | 1,190 ops/sec ±1.78% | 3,190 ops/sec ±0.91% | 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)') | 928 ops/sec ±1.31% | 753 ops/sec ±0.25% | 1,201 ops/sec ±0.18% | 1,041 ops/sec ±0.30% | linkedom is the fastest and 1.2 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') | 227 ops/sec ±1.32% | F | 425 ops/sec ±1.37% | 270 ops/sec ±1.39% | linkedom is the fastest and 1.6 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 .block.inner:has(> .content)') | F | F | 451 ops/sec ±1.73% | 535 ops/sec ±2.00% | patched-jsdom is the fastest. |
complex selector within logical pseudo-class:
querySelectorAll(':is(.box > .content, .block > .content)') | 302 ops/sec ±0.67% | F | 520 ops/sec ±0.32% | 254 ops/sec ±1.65% | linkedom is the fastest and 2.0 times faster than patched-jsdom. jsdom is 1.2 times faster than patched-jsdom. |
Acknowledgments
The following resources have been of great help in the development of the DOM Selector.
Copyright (c) 2023 asamuzaK (Kazz)