Skip to content

Commit 4acedd9

Browse files
committed
Add support for resolving pseudo element styles
Matching a pseudo-element was roughly implemented then removed in 864216d. The DOM does not allow matching pseudo-elements at the moment, although this is not entirely clear (whatwg/dom#1497). querySelector*() uses "match a selector against a tree". Its implementation here also does not support matching a tree against a pseudo-element selector, as it probably requires access to element internal data. This commit only adds support for matching a single pseudo-element to resolve its styles for getComputedStyle(), which all browsers support but diverge on the result (w3c/csswg-drafts#14210). When collecting declarations, the "match a selector against a pseudo-element" implementation is used instead, and nested rules are not inspected. This is not an issue for now, as CSS does not allow selectors of nested style rules to match pseudo-elements.
1 parent 7d8a7b6 commit 4acedd9

9 files changed

Lines changed: 260 additions & 12 deletions

File tree

‎lib/cssom/CSSPseudoElement-impl.js‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
2+
import CSSPseudoElement from './CSSPseudoElement.js'
3+
import { elements as definitions } from '../values/pseudos.js'
4+
import { isFailure } from '../utils/value.js'
5+
import { parseGrammar } from '../parse/parser.js'
6+
import { serializeComponentValue } from '../serialize.js'
7+
8+
/**
9+
* @see {@link https://drafts.csswg.org/css-pseudo-4/#csspseudoelement}
10+
*/
11+
export default class CSSPseudoElementImpl {
12+
13+
/**
14+
* @param {Window} globalObject
15+
* @param {*[]} args
16+
*/
17+
constructor(globalObject, { element, parent, selectorText, type }) {
18+
this._globalObject = globalObject
19+
this.element = element
20+
this.parent = parent
21+
this.selectorText = selectorText
22+
this.type = type
23+
}
24+
25+
/**
26+
* @returns {Document}
27+
*/
28+
get ownerDocument() {
29+
return this.element.ownerDocument
30+
}
31+
32+
/**
33+
* @param {object} options
34+
* @returns {Document|ShadowRoot}
35+
*/
36+
getRootNode(options) {
37+
return this.element.getRootNode(options)
38+
}
39+
40+
/**
41+
* @param {string} type
42+
* @returns {CSSPseudoElementImpl|null}
43+
* @see {@link https://drafts.csswg.org/css-pseudo-4/#dom-csspseudoelement-pseudo}
44+
*/
45+
pseudo(type) {
46+
47+
const selectorText = parseGrammar(type, '<pseudo-element-selector>')
48+
49+
if (isFailure(selectorText)) {
50+
return null
51+
}
52+
53+
const [, [, { value, name }]] = selectorText
54+
55+
type = name ?? value
56+
57+
if (!definitions[this.type.slice(2)].elements?.includes(type)) {
58+
return null
59+
}
60+
61+
const properties = {
62+
element: this.element,
63+
parent: this,
64+
selectorText: serializeComponentValue(selectorText),
65+
type: `::${type}`,
66+
}
67+
68+
return CSSPseudoElement.createImpl(this._globalObject, properties)
69+
}
70+
}

‎lib/cssom/CSSPseudoElement.webidl‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
// https://drafts.csswg.org/css-pseudo-4/#csspseudoelement
3+
[Exposed=Window]
4+
interface CSSPseudoElement {
5+
readonly attribute CSSOMString type;
6+
readonly attribute Element element;
7+
readonly attribute (Element or CSSPseudoElement) parent;
8+
readonly attribute CSSOMString selectorText;
9+
CSSPseudoElement? pseudo(CSSOMString type);
10+
};

‎lib/cssom/CSSStyleDeclaration-impl.js‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { UPDATE_READONLY_STYLE_DECLARATION_ERROR, create as error } from '../err
44
import { getDeclarationDefinition, getDeclarationName, parseDeclarations, parseDestructuredDeclaration } from '../parse/parser.js'
55
import { isFailure, isOmitted } from '../utils/value.js'
66
import { serializeDeclarationBlock, serializeValue } from '../serialize.js'
7+
import CSSPSeudoElement from './CSSPseudoElement.js'
78
import expandShorthandDeclaration from '../parse/shorthand.js'
89
import { getResolvedValue } from '../resolve.js'
910
import { isOfType } from '../utils/dom/element.js'
@@ -14,10 +15,13 @@ import shorthands from '../properties/shorthands.js'
1415
import { toLowerCase } from '../utils/string.js'
1516

1617
/**
17-
* @param {Element} element
18+
* @param {CSSPseudoElementImpl|Element} element
1819
* @returns {boolean}
1920
*/
2021
function hasResolvedStyle(element) {
22+
if (CSSPSeudoElement.isImpl(element)) {
23+
return hasResolvedStyle(element.element)
24+
}
2125
if (!element.isConnected) {
2226
return false
2327
}

‎lib/cssom/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export { default as CSSPageRule } from './CSSPageRule.js'
3030
export { default as CSSPositionTryDescriptors } from './CSSPositionTryDescriptors.js'
3131
export { default as CSSPositionTryRule } from './CSSPositionTryRule.js'
3232
export { default as CSSPropertyRule } from './CSSPropertyRule.js'
33+
export { default as CSSPseudoElement } from './CSSPseudoElement.js'
3334
export { default as CSSRule } from './CSSRule.js'
3435
export { default as CSSRuleList } from './CSSRuleList.js'
3536
export { default as CSSScopeRule } from './CSSScopeRule.js'

‎lib/match/selector.js‎

Lines changed: 86 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import { getElementLanguageTag, matchesLanguageRange, parseLanguageTag } from '.
2222
import { isDelimiter, isOmitted } from '../utils/value.js'
2323
import { isHTMLDocument, isShadowRoot, traverse } from '../utils/dom/tree.js'
2424
import { serializeComponentValue, serializeIdentifier } from '../serialize.js'
25+
import CSSPseudoElement from '../cssom/CSSPseudoElement.js'
2526
import getDirectionality from '../utils/dom/directionality.js'
27+
import { list } from '../values/value.js'
2628
import { parseGrammar } from '../parse/parser.js'
2729
import { toLowerCase } from '../utils/string.js'
2830

@@ -1186,19 +1188,90 @@ function matchPseudoClassSelector(element, { name, value }, context, options) {
11861188
}
11871189

11881190
/**
1189-
* @param {ElementImpl} element
1191+
* @param {CSSPseudoElementImpl} element
11901192
* @param {*[]} selector
11911193
* @param {object} context
11921194
* @param {object} options
11931195
* @returns {boolean}
11941196
*/
1195-
function matchPseudoCompoundSelectors(element, selector, context, { pseudos }) {
1196-
return selector.every(selector => {
1197-
const [pseudoElement, pseudoClasses] = selector
1198-
const type = `::${pseudoElement.types[0] === '<function>' ? `${pseudoElement.name}()` : pseudoElement.value}`
1199-
const pseudo = pseudos.includes(type) && element.pseudo(type)
1200-
return pseudo && pseudoClasses.length === 0
1201-
})
1197+
function matchPseudoCompoundSelector(element, [pseudoElement, pseudoClasses], context, options) {
1198+
return element.selectorText === serializeComponentValue(pseudoElement)
1199+
&& pseudoClasses.every(selector => matchPseudoClassSelector(element, selector, context, options))
1200+
}
1201+
1202+
/**
1203+
* @param {CSSPseudoElementImpl|ElementImpl} element
1204+
* @param {*[]} selector
1205+
* @param {object} context
1206+
* @param {object} options
1207+
* @returns {boolean}
1208+
*/
1209+
function matchPseudoCompoundSelectors(element, selector, context, options) {
1210+
for (let index = selector.length - 1; -1 < index; index--) {
1211+
if (!CSSPseudoElement.isImpl(element)) {
1212+
return false
1213+
}
1214+
if (!matchPseudoCompoundSelector(element, selector[index], context, options)) {
1215+
return false
1216+
}
1217+
element = element.parent
1218+
}
1219+
return true
1220+
}
1221+
1222+
/**
1223+
* @param {CSSPseudoElementImpl} element
1224+
* @param {*[]} selector
1225+
* @param {object} context
1226+
* @param {object} options
1227+
* @returns {boolean}
1228+
*/
1229+
function matchPseudoElementAgainstComplexSelector(element, selector, context, options) {
1230+
1231+
let [head, tail] = selector
1232+
let compound
1233+
let pseudoCompounds
1234+
1235+
// Resolve and replace <pseudo-compound-selector>* in the subject with empty list
1236+
if (tail.length === 0) {
1237+
[compound, pseudoCompounds] = head
1238+
const unit = list([compound, list([], '')], '', ['<complex-selector-unit>'])
1239+
selector = [unit, tail]
1240+
} else {
1241+
let combinator
1242+
tail = [...tail];
1243+
[combinator, [compound, pseudoCompounds]] = tail.pop()
1244+
const unit = list([compound, list([], '')], '', ['<complex-selector-unit>'])
1245+
tail.push(list([combinator, unit], ''))
1246+
selector = [head, list(tail, '')]
1247+
}
1248+
selector = list(selector, '', ['<complex-selector>'])
1249+
1250+
return matchPseudoCompoundSelectors(element, pseudoCompounds, context, options)
1251+
&& matchComplexSelector(element.element, selector, context, options)
1252+
}
1253+
1254+
/**
1255+
* @param {CSSPseudoElementImpl} element
1256+
* @param {*[]} selectors
1257+
* @param {object} [context]
1258+
* @param {object} [options]
1259+
* @returns {*[]|null}
1260+
* @see {@link https://drafts.csswg.org/selectors-4/#match-against-pseudo-element}
1261+
*/
1262+
function matchPseudoElementAgainstSelectors(element, selectors, context, options) {
1263+
1264+
if (selectors.types.includes('<complex-real-selector-list>')) {
1265+
return false
1266+
}
1267+
1268+
context = createContext(element.element, context)
1269+
options = createOptions(context, options)
1270+
1271+
if (options.all) {
1272+
return selectors.every(selector => matchPseudoElementAgainstComplexSelector(element, selector, context, options))
1273+
}
1274+
return selectors.find(selector => matchPseudoElementAgainstComplexSelector(element, selector, context, options)) ?? null
12021275
}
12031276

12041277
/**
@@ -1355,6 +1428,10 @@ function matchCompoundSelector(element, selector, context, options) {
13551428
return (isOmitted(compound) || matchCompoundSelector(element, compound, context, options))
13561429
&& matchPseudoCompoundSelectors(element, pseudos, context, options)
13571430
}
1431+
// It can be omitted only when matching a pseudo-element
1432+
if (isOmitted(selector)) {
1433+
return true
1434+
}
13581435
const [type, subclasses] = selector
13591436
return matchTypeSelector(element, type, context)
13601437
&& matchSubclassSelector(element, subclasses, context, options)
@@ -1675,5 +1752,6 @@ function matchTreesAgainstSelectors(trees, selectors, context, options) {
16751752

16761753
export {
16771754
matchElementAgainstSelectors,
1755+
matchPseudoElementAgainstSelectors,
16781756
matchTreesAgainstSelectors,
16791757
}

‎lib/resolve.js‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import {
88
CSSMediaRule,
99
CSSNamespaceRule,
1010
CSSNestedDeclarations,
11+
CSSPseudoElement,
1112
CSSScopeRule,
1213
CSSStyleRule,
1314
CSSSupportsRule,
1415
} from './cssom/index.js'
1516
import { delimiter, identifierToken, list, map, omitted } from './values/value.js'
1617
import { findAncestor, getParent } from './utils/dom/element.js'
1718
import { isDelimiter, isList, isOmitted } from './utils/value.js'
18-
import { matchElementAgainstSelectors, matchTreesAgainstSelectors } from './match/selector.js'
19+
import { matchElementAgainstSelectors, matchPseudoElementAgainstSelectors, matchTreesAgainstSelectors } from './match/selector.js'
1920
import { ampersand } from './values/defaults.js'
2021
import { getElementColorScheme } from './utils/color-scheme.js'
2122
import { implForWrapper } from './cssom/utils.js'
@@ -220,6 +221,17 @@ function getRuleDeclarations(property, element, rule, context) {
220221
const selectors = getAbsoluteSelectors(_selectors, context)
221222
const elements = matchTreesAgainstSelectors([context.root], selectors, context)
222223

224+
// Pseudo-elements cannot match nested rule selectors at the moment
225+
if (CSSPseudoElement.isImpl(element)) {
226+
if (matchPseudoElementAgainstSelectors(element, selectors, context)) {
227+
const declaration = style._declarations.find(declaration => declaration.name === property)
228+
if (declaration) {
229+
return [declaration]
230+
}
231+
}
232+
return []
233+
}
234+
223235
if (0 < elements.length) {
224236
context = { ...context, selectors: [...context.selectors, selectors] }
225237
const declarations = []
@@ -316,7 +328,7 @@ function getDeclaredValues(property, element) {
316328
getStyleSheetListDeclarations(property, element, styleSheets, createContext(root, AUTHOR_ORIGIN)),
317329
getStyleSheetListDeclarations(property, element, adoptedStyleSheets, createContext(root, AUTHOR_ORIGIN)),
318330
]
319-
const declaration = implForWrapper(style)._declarations.find(declaration => declaration.name === property)
331+
const declaration = implForWrapper(style)?._declarations.find(declaration => declaration.name === property)
320332
if (declaration) {
321333
declarations.push({ ...declaration, context: { attached: true, origin: AUTHOR_ORIGIN } })
322334
}

‎test/match.js‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ import { HTML_NAMESPACE, SVG_NAMESPACE, XLINK_NAMESPACE, XML_NAMESPACE } from '.
4141
import assert, { Assert, AssertionError } from 'node:assert/strict'
4242
import { createContext, parseGrammar } from '../lib/parse/parser.js'
4343
import { describe, test } from 'node:test'
44+
import { matchPseudoElementAgainstSelectors, matchTreesAgainstSelectors } from '../lib/match/selector.js'
45+
import { CSSPseudoElement } from '../lib/cssom/index.js'
4446
import { install } from '@cdoublev/css'
4547
import matchMediaQueryList from '../lib/match/media.js'
4648
import matchSupport from '../lib/match/support.js'
47-
import { matchTreesAgainstSelectors } from '../lib/match/selector.js'
4849
import { omitted } from '../lib/values/value.js'
4950

5051
install()
@@ -662,6 +663,24 @@ describe('selector', () => {
662663
selections.forEach(([selector, expected, context, options]) =>
663664
assert.match(selector, expected, document, context, options))
664665
})
666+
test('pseudo-element', () => {
667+
668+
const document = new HTMLDocument
669+
const html = new HTMLHtmlElement({ ownerDocument: document, parentNode: document })
670+
const highlight = CSSPseudoElement.createImpl(globalThis, {
671+
element: html,
672+
parent: html,
673+
selectorText: '::highlight(identifier)',
674+
type: '::highlight',
675+
})
676+
677+
const selectors = ['::highlight(identifier)', 'html::highlight(identifier)']
678+
679+
selectors.forEach(selector => {
680+
selector = parseGrammar(selector, '<selector-list>')
681+
assert.equal(!!matchPseudoElementAgainstSelectors(highlight, selector), true)
682+
})
683+
})
665684

666685
test('complex', () => {
667686

‎test/pseudo-element.js‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
2+
import { HTMLDivElement, HTMLDocument } from './dom.js'
3+
import { CSSPseudoElement } from '../lib/cssom/index.js'
4+
import assert from 'node:assert/strict'
5+
import { install } from '@cdoublev/css'
6+
import { test } from 'node:test'
7+
8+
install()
9+
10+
test('CSSPseudoElement', () => {
11+
12+
const document = new HTMLDocument
13+
const element = new HTMLDivElement({ ownerDocument: document })
14+
const properties = {
15+
element,
16+
parent: element,
17+
selectorText: '::before',
18+
type: '::before',
19+
}
20+
const before = CSSPseudoElement.create(globalThis, properties)
21+
22+
assert.equal(CSSPseudoElement.is(before), true)
23+
assert.equal(before.element, element)
24+
assert.equal(before.parent, element)
25+
assert.equal(before.selectorText, '::before')
26+
assert.equal(before.type, '::before')
27+
28+
assert.equal(before.pseudo('::after'), null)
29+
30+
const marker = before.pseudo('::marker')
31+
32+
assert.equal(CSSPseudoElement.is(marker), true)
33+
assert.equal(marker.element, element)
34+
assert.equal(marker.parent, before)
35+
assert.equal(marker.selectorText, '::marker')
36+
assert.equal(marker.type, '::marker')
37+
})

‎test/style.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CSSMarginDescriptors,
1010
CSSPageDescriptors,
1111
CSSPositionTryDescriptors,
12+
CSSPseudoElement,
1213
CSSStyleProperties,
1314
CSSStyleSheet,
1415
} from '../lib/cssom/index.js'
@@ -392,6 +393,22 @@ describe('CSSStyleDeclaration.setProperty(), CSSStyleDeclaration.getPropertyValu
392393
style.borderBlockStartColor = 'green'
393394
assert.equal(style.cssText, 'border-top-color: green; border-block-start-color: green;')
394395
})
396+
it('resolves a value for a pseudo-element', () => {
397+
398+
const document = new HTMLDocument
399+
const html = new HTMLHtmlElement({ ownerDocument: document, parentNode: document })
400+
const body = new HTMLBodyElement({ ownerDocument: document, parentNode: html })
401+
new HTMLStyleElement({ innerText: '::before { color: green }', ownerDocument: document, parentNode: body })
402+
const before = CSSPseudoElement.createImpl(globalThis, {
403+
element: body,
404+
parent: body,
405+
selectorText: '::before',
406+
type: '::before',
407+
})
408+
const style = createResolvedStyle(before)
409+
410+
assert.equal(style.color, 'rgb(0, 128, 0)')
411+
})
395412
it('resolves a value by collecting declarations in conditional rules', () => {
396413

397414
const styleSheet = `

0 commit comments

Comments
 (0)