LibWeb/CSS: Implement the :state(foo) pseudo-class

This matches custom elements that have `foo` in their custom states set.

The 2 test failures here are because we don't support `::part()` yet.
This commit is contained in:
Sam Atkins 2025-07-04 15:18:08 +01:00 committed by Tim Ledbetter
commit 202c55bf28
Notes: github-actions[bot] 2025-07-04 17:11:37 +00:00
12 changed files with 503 additions and 2 deletions

View file

@ -155,6 +155,9 @@
"stalled": {
"argument": ""
},
"state": {
"argument": "<ident>"
},
"suboptimal-value": {
"argument": ""
},

View file

@ -61,6 +61,7 @@ static bool can_selector_use_fast_matches(Selector const& selector)
PseudoClass::LocalLink,
PseudoClass::OnlyChild,
PseudoClass::Root,
PseudoClass::State,
PseudoClass::Visited))
return false;
} else if (!first_is_one_of(simple_selector.type,

View file

@ -15,6 +15,7 @@
#include <LibWeb/DOM/NamedNodeMap.h>
#include <LibWeb/DOM/Text.h>
#include <LibWeb/HTML/AttributeNames.h>
#include <LibWeb/HTML/CustomElements/CustomStateSet.h>
#include <LibWeb/HTML/HTMLAnchorElement.h>
#include <LibWeb/HTML/HTMLDetailsElement.h>
#include <LibWeb/HTML/HTMLDialogElement.h>
@ -1059,6 +1060,15 @@ static inline bool matches_pseudo_class(CSS::Selector::SimpleSelector::PseudoCla
return false;
}
case CSS::PseudoClass::State: {
// https://html.spec.whatwg.org/multipage/semantics-other.html#selector-custom
// The :state(identifier) pseudo-class must match all custom elements whose states set's set entries contains identifier.
if (!element.is_custom())
return false;
if (auto* custom_state_set = element.custom_state_set())
return custom_state_set->has_state(pseudo_class.ident->string_value);
return false;
}
}
return false;

View file

@ -2,5 +2,5 @@ Harness status: OK
Found 1 tests
1 Fail
Fail Test :has() invalidation with :state() pseudo-classes
1 Pass
Pass Test :has() invalidation with :state() pseudo-classes

View file

@ -0,0 +1,8 @@
Harness status: OK
Found 3 tests
3 Pass
Pass state selector has influence on nth-of when state is applied
Pass state selector only applies on given ident
Pass style is invalided on clear()

View file

@ -0,0 +1,8 @@
Harness status: OK
Found 3 tests
3 Pass
Pass state selector has no influence when state is not applied
Pass state selector has influence when state is applied
Pass state selector only applies on given ident

View file

@ -0,0 +1,15 @@
Harness status: OK
Found 10 tests
10 Pass
Pass state selector has no influence when state is not applied
Pass state selector has no influence on sibling selectors when not applied
Pass state selector has influence when state is applied
Pass state selector influences siblings when state is applied
Pass state selector influences has() when state is applied
Pass state selector only applies on given ident
Pass state selector only applies to siblings on given ident
Pass state selector only applies to has() on given ident
Pass states added multiple times counts as one
Pass style is invalided on clear()

View file

@ -0,0 +1,14 @@
Harness status: OK
Found 8 tests
6 Pass
2 Fail
Pass :state() parsing passes
Pass :state() parsing failures
Pass deprecated :--state parsing failures
Fail :state(foo) serialization
Pass :state(foo) in simple cases
Pass :state(foo) and other pseudo classes
Fail :state(foo) and ::part()
Pass :state(foo) and :host()

View file

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="author" title="Keith Cirkel" href="mailto:wpt@keithcirkel.co.uk" />
<link rel="help" href="https://html.spec.whatwg.org/multipage/custom-elements.html#custom-state-pseudo-class" />
<title>:state() css selector applies to nth-of selectors</title>
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
</head>
<body>
<custom-state id="myCE">First Element</custom-state>
<p id="mySibling">First Sibling</p>
<custom-state id="myCE2">Second Element</custom-state>
<p id="mySibling2">Second Sibling</p>
<style>
:nth-child(1), :nth-child(2) {
color: #f00;
}
:nth-child(2 of :state(--green)) {
color: #0f0;
}
:nth-child(2 of :state(--green)) + p {
color: #00f;
}
</style>
<script>
customElements.define('custom-state', class extends HTMLElement {
connectedCallback() {
this.elementInternals = this.attachInternals();
}
});
test(function(t) {
t.add_cleanup(() => { myCE.elementInternals.states.delete('--green') });
t.add_cleanup(() => { myCE2.elementInternals.states.delete('--green') });
assert_false(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(myCE2).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling2).getPropertyValue('color'), 'rgb(255, 0, 0)');
myCE.elementInternals.states.add('--green');
assert_true(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(myCE2).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling2).getPropertyValue('color'), 'rgb(255, 0, 0)');
myCE2.elementInternals.states.add('--green');
assert_true(myCE2.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(myCE2).getPropertyValue('color'), 'rgb(0, 255, 0)');
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling2).getPropertyValue('color'), 'rgb(0, 0, 255)');
}, "state selector has influence on nth-of when state is applied");
test(function(t) {
t.add_cleanup(() => { myCE.elementInternals.states.delete('--foo') });
t.add_cleanup(() => { myCE2.elementInternals.states.delete('--foo') });
myCE.elementInternals.states.add('--foo');
myCE2.elementInternals.states.add('--foo');
assert_false(myCE.elementInternals.states.has('--green'));
assert_true(myCE.elementInternals.states.has('--foo'));
assert_false(myCE2.elementInternals.states.has('--green'));
assert_true(myCE2.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(myCE2).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling2).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "state selector only applies on given ident");
test(function(t) {
myCE.elementInternals.states.add('--green');
myCE.elementInternals.states.add('--foo');
myCE2.elementInternals.states.add('--green');
myCE2.elementInternals.states.add('--foo');
assert_true(myCE.elementInternals.states.has('--green'));
assert_true(myCE.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(myCE2).getPropertyValue('color'), 'rgb(0, 255, 0)');
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling2).getPropertyValue('color'), 'rgb(0, 0, 255)');
myCE.elementInternals.states.clear();
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(myCE2).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_equals(getComputedStyle(mySibling2).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "style is invalided on clear()");
</script>
</body>
</html>

View file

@ -0,0 +1,74 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="timeout" content="long" />
<meta
name="author"
title="Keith Cirkel"
href="mailto:wpt@keithcirkel.co.uk"
/>
<link rel="help" href="https://html.spec.whatwg.org/multipage/custom-elements.html#custom-state-pseudo-class" />
<title>:state() css selector applies in shadow roots</title>
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
</head>
<body>
<custom-state id="myCE"> I should be green </custom-state>
<style></style>
<script>
customElements.define(
"custom-state",
class extends HTMLElement {
connectedCallback() {
this.elementInternals = this.attachInternals();
const css = new CSSStyleSheet();
css.replaceSync(`
:host {
color: #f00;
}
:host(:state(green)) {
color: #0f0;
}
`);
this.attachShadow({ mode: "open" }).adoptedStyleSheets.push(css);
}
},
);
test(function () {
assert_false(myCE.elementInternals.states.has("green"));
assert_equals(
getComputedStyle(myCE).getPropertyValue("color"),
"rgb(255, 0, 0)",
);
}, "state selector has no influence when state is not applied");
test(function (t) {
myCE.elementInternals.states.add("green");
t.add_cleanup(() => {
myCE.elementInternals.states.delete("green");
});
assert_true(myCE.elementInternals.states.has("green"));
assert_equals(
getComputedStyle(myCE).getPropertyValue("color"),
"rgb(0, 255, 0)",
);
}, "state selector has influence when state is applied");
test(function (t) {
myCE.elementInternals.states.add("foo");
t.add_cleanup(() => {
myCE.elementInternals.states.delete("foo");
});
assert_false(myCE.elementInternals.states.has("green"));
assert_true(myCE.elementInternals.states.has("foo"));
assert_equals(
getComputedStyle(myCE).getPropertyValue("color"),
"rgb(255, 0, 0)",
);
}, "state selector only applies on given ident");
</script>
</body>
</html>

View file

@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="timeout" content="long">
<meta name="author" title="Keith Cirkel" href="mailto:wpt@keithcirkel.co.uk" />
<link rel="help" href="https://html.spec.whatwg.org/multipage/custom-elements.html#custom-state-pseudo-class" />
<title>:state() css selector applies</title>
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
</head>
<body>
<custom-state id="myCE">I should be green</custom-state>
<p id="mySibling">I should be blue</p>
<p id="myHas">I should be blue</p>
<style>
custom-state {
color: #f00;
}
custom-state + p {
color: #f00;
}
custom-state:state(--green) {
color: #0f0;
}
custom-state:--green {
color: #0f0;
}
body:has(custom-state:state(--green)) p {
color: #0ff;
}
custom-state:state(--green) + p[id] {
color: #00f;
}
custom-state:--green + p {
color: #00f;
}
</style>
<script>
customElements.define('custom-state', class extends HTMLElement {
connectedCallback() {
this.elementInternals = this.attachInternals();
}
});
test(function() {
assert_false(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "state selector has no influence when state is not applied");
test(function() {
assert_false(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "state selector has no influence on sibling selectors when not applied");
test(function(t) {
myCE.elementInternals.states.add('--green');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--green') });
assert_true(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(0, 255, 0)');
}, "state selector has influence when state is applied");
test(function(t) {
myCE.elementInternals.states.add('--green');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--green') });
assert_true(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(0, 0, 255)');
}, "state selector influences siblings when state is applied");
test(function(t) {
myCE.elementInternals.states.add('--green');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--green') });
assert_true(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myHas).getPropertyValue('color'), 'rgb(0, 255, 255)');
}, "state selector influences has() when state is applied");
test(function(t) {
myCE.elementInternals.states.add('--foo');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--foo') });
assert_false(myCE.elementInternals.states.has('--green'));
assert_true(myCE.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "state selector only applies on given ident");
test(function(t) {
myCE.elementInternals.states.add('--foo');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--foo') });
assert_false(myCE.elementInternals.states.has('--green'));
assert_true(myCE.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "state selector only applies to siblings on given ident");
test(function(t) {
myCE.elementInternals.states.add('--foo');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--foo') });
assert_false(myCE.elementInternals.states.has('--green'));
assert_true(myCE.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(mySibling).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "state selector only applies to has() on given ident");
test(function(t) {
myCE.elementInternals.states.add('--green');
myCE.elementInternals.states.add('--green');
myCE.elementInternals.states.add('--green');
t.add_cleanup(() => { myCE.elementInternals.states.delete('--green') });
assert_true(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(0, 255, 0)');
assert_true(myCE.elementInternals.states.delete('--green'));
assert_false(myCE.elementInternals.states.has('--green'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
assert_false(myCE.elementInternals.states.delete('--green'));
}, "states added multiple times counts as one");
test(function(t) {
myCE.elementInternals.states.add('--green');
myCE.elementInternals.states.add('--foo');
t.add_cleanup(() => { myCE.elementInternals.states.clear() });
assert_true(myCE.elementInternals.states.has('--green'));
assert_true(myCE.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(0, 255, 0)');
myCE.elementInternals.states.clear();
assert_false(myCE.elementInternals.states.has('--green'));
assert_false(myCE.elementInternals.states.has('--foo'));
assert_equals(getComputedStyle(myCE).getPropertyValue('color'), 'rgb(255, 0, 0)');
}, "style is invalided on clear()");
</script>
</body>
</html>

View file

@ -0,0 +1,146 @@
<!DOCTYPE html>
<link rel=help href="https://html.spec.whatwg.org/multipage/custom-elements.html#custom-state-pseudo-class">
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
<style>
#state-and-part::part(inner) {
opacity: 0;
}
#state-and-part::part(inner):state(innerFoo) {
opacity: 0.5;
}
#state-and-part:state(outerFoo)::part(inner) {
opacity: 0.25;
}
:state( \(escaped\ state ) {}
</style>
<body>
<script>
class TestElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
}
get i() {
return this._internals;
}
}
customElements.define('test-element', TestElement);
class ContainerElement extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
this._shadow = this.attachShadow({mode:'open'});
this._shadow.innerHTML = `
<style>
:host {
border-style: solid;
}
:host(:state(dotted)) {
border-style: dotted;
}
</style>
<test-element part="inner"></test-element>`;
}
get i() {
return this._internals;
}
get innerElement() {
return this._shadow.querySelector('test-element');
}
}
customElements.define('container-element', ContainerElement);
test(() => {
document.querySelector(':state(foo)');
document.querySelector(':state(--foo)');
document.querySelector(':state(--)');
document.querySelector(':state(--16px)');
}, ':state() parsing passes');
test(() => {
assert_throws_dom('SyntaxError', () => { document.querySelector(':state'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':state('); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':state()'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':state(=)'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':state(name=value)'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':state( foo bar)'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':state(16px)'); });
}, ':state() parsing failures');
test(() => {
assert_throws_dom('SyntaxError', () => { document.querySelector(':--('); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':--()'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':--)'); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':--='); });
assert_throws_dom('SyntaxError', () => { document.querySelector(':--name=value'); });
}, 'deprecated :--state parsing failures');
test(() => {
assert_equals(document.styleSheets[0].cssRules[1].cssText,
'#state-and-part::part(inner):state(innerFoo) { opacity: 0.5; }');
assert_equals(document.styleSheets[0].cssRules[3].selectorText,
':state(\\(escaped\\ state)');
}, ':state(foo) serialization');
test(() => {
let element = new TestElement();
let states = element.i.states;
assert_false(element.matches(':state(foo)'));
assert_true(element.matches(':not(:state(foo))'));
states.add('foo');
assert_true(element.matches(':state(foo)'));
assert_true(element.matches(':is(:state(foo))'));
element.classList.add('c1', 'c2');
assert_true(element.matches('.c1:state(foo)'));
assert_true(element.matches(':state(foo).c1'));
assert_true(element.matches('.c2:state(foo).c1'));
}, ':state(foo) in simple cases');
test(() => {
let element = new TestElement();
element.tabIndex = 0;
document.body.appendChild(element);
element.focus();
let states = element.i.states;
states.add('foo');
assert_true(element.matches(':focus:state(foo)'));
assert_true(element.matches(':state(foo):focus'));
}, ':state(foo) and other pseudo classes');
test(() => {
let outer = new ContainerElement();
outer.id = 'state-and-part';
document.body.appendChild(outer);
let inner = outer.innerElement;
let innerStates = inner.i.states;
innerStates.add('innerFoo');
assert_equals(getComputedStyle(inner).opacity, '0.5',
'::part() followed by :state()');
innerStates.delete('innerFoo');
innerStates.add('innerfoo');
assert_equals(getComputedStyle(inner).opacity, '0',
':state() matching should be case-sensitive');
innerStates.delete('innerfoo');
outer.i.states.add('outerFoo');
assert_equals(getComputedStyle(inner).opacity, '0.25',
':state(foo) followed by ::part()');
}, ':state(foo) and ::part()');
test(() => {
let outer = new ContainerElement();
document.body.appendChild(outer);
assert_equals(getComputedStyle(outer).borderStyle, 'solid');
outer.i.states.add('dotted');
assert_equals(getComputedStyle(outer).borderStyle, 'dotted');
}, ':state(foo) and :host()');
</script>
</body>