From b73883eb54345b47fa5e9ec4fe49805f01aade61 Mon Sep 17 00:00:00 2001
From: romainmenke
Date: Thu, 7 Jul 2022 18:37:09 +0200
Subject: [PATCH 1/2] css has pseudo : pseudo element support and
.js-has-pseudo class
---
plugins/css-has-pseudo/.tape.mjs | 3 -
plugins/css-has-pseudo/src/browser.js | 53 ++-
plugins/css-has-pseudo/src/index.ts | 118 ++++-
plugins/css-has-pseudo/test/_browser.html | 40 +-
plugins/css-has-pseudo/test/_browser.mjs | 28 +-
plugins/css-has-pseudo/test/basic.css | 6 +-
plugins/css-has-pseudo/test/basic.expect.css | 78 +--
.../test/basic.preserve.expect.css | 72 +--
...basic.specificity-matching-name.expect.css | 78 +--
plugins/css-has-pseudo/test/browser.css | 4 +
.../css-has-pseudo/test/browser.expect.css | 263 +++++++++--
.../test/examples/example.expect.css | 2 +-
.../example.preserve-false.expect.css | 2 +-
.../test/generated-selector-cases.expect.css | 446 +++++++++---------
.../plugin-order-logical.after.expect.css | 4 +-
...in-order-logical.after.preserve.expect.css | 28 +-
.../plugin-order-logical.before.expect.css | 4 +-
...n-order-logical.before.preserve.expect.css | 28 +-
.../plugin-order-nesting.after.expect.css | 6 +-
...in-order-nesting.after.preserve.expect.css | 6 +-
.../plugin-order-nesting.before.expect.css | 6 +-
...n-order-nesting.before.preserve.expect.css | 6 +-
22 files changed, 814 insertions(+), 467 deletions(-)
diff --git a/plugins/css-has-pseudo/.tape.mjs b/plugins/css-has-pseudo/.tape.mjs
index 58629ecdf..740b2253f 100644
--- a/plugins/css-has-pseudo/.tape.mjs
+++ b/plugins/css-has-pseudo/.tape.mjs
@@ -38,9 +38,6 @@ postcssTape(plugin)({
},
'browser': {
message: 'prepare CSS for chrome test',
- options: {
- preserve: false
- }
},
'plugin-order-logical:before': {
message: 'works with other plugins that modify selectors',
diff --git a/plugins/css-has-pseudo/src/browser.js b/plugins/css-has-pseudo/src/browser.js
index 398156d8c..41ffc3e6f 100644
--- a/plugins/css-has-pseudo/src/browser.js
+++ b/plugins/css-has-pseudo/src/browser.js
@@ -4,6 +4,28 @@ import '@mrhenry/core-web/modules/~element-qsa-has.js';
import extractEncodedSelectors from './encode/extract.mjs';
import encodeCSS from './encode/encode.mjs';
+function hasNativeSupport(document) {
+ try {
+ // Chrome does not support forgiving selector lists in :has()
+ document.querySelector(':has(*, :does-not-exist, > *)');
+ document.querySelector(':has(:has(any))');
+
+ // Safari incorrectly returns the html element with this query
+ if (document.querySelector(':has(:scope *)')) {
+ return false;
+ }
+
+ if (!('CSS' in self) || !('supports' in self.CSS) || !self.CSS.supports(':has(any)')) {
+ return false;
+ }
+
+ } catch (_) {
+ return false;
+ }
+
+ return true;
+}
+
export default function cssHasPseudo(document, options) {
// OPTIONS
{
@@ -18,23 +40,7 @@ export default function cssHasPseudo(document, options) {
forcePolyfill: (!!options.forcePolyfill) || false,
};
- if (!options.forcePolyfill) {
- try {
- // Chrome does not support forgiving selector lists in :has()
- document.querySelector(':has(*, :does-not-exist, > *)');
-
- // Safari incorrectly returns the html element with this query
- if (!document.querySelector(':has(:scope *)')) {
- // Native support detected.
- // Doing early return.
- return;
- }
-
- // fallthrough to polyfill
- } catch (_) {
- // fallthrough to polyfill
- }
- }
+ options.mustPolyfill = options.forcePolyfill || !hasNativeSupport(document);
if (!Array.isArray(options.observedAttributes)) {
options.observedAttributes = [];
@@ -56,6 +62,12 @@ export default function cssHasPseudo(document, options) {
// walk all stylesheets to collect observed css rules
[].forEach.call(document.styleSheets, walkStyleSheet);
+ if (!options.mustPolyfill) {
+ // Cleanup of rules will have happened in `walkStyleSheet`
+ // Native support will take over from here
+ return;
+ }
+
transformObservedItemsThrottled();
// observe DOM modifications that affect selectors
@@ -228,6 +240,8 @@ export default function cssHasPseudo(document, options) {
// walk a css rule to collect observed css rules
[].forEach.call(styleSheet.cssRules || [], (rule) => {
if (rule.selectorText) {
+ rule.selectorText = rule.selectorText.replace(/\.js-has-pseudo\s/g, '');
+
try {
// decode the selector text in all browsers to:
const hasSelectors = extractEncodedSelectors(rule.selectorText.toString());
@@ -235,6 +249,11 @@ export default function cssHasPseudo(document, options) {
return;
}
+ if (!options.mustPolyfill) {
+ rule.deleteRule();
+ return;
+ }
+
for (let i = 0; i < hasSelectors.length; i++) {
const hasSelector = hasSelectors[i];
observedItems.push({
diff --git a/plugins/css-has-pseudo/src/index.ts b/plugins/css-has-pseudo/src/index.ts
index a7527f225..63b0a2a2e 100644
--- a/plugins/css-has-pseudo/src/index.ts
+++ b/plugins/css-has-pseudo/src/index.ts
@@ -39,9 +39,20 @@ const creator: PluginCreator<{ preserve?: boolean, specificityMatchingName?: str
return selector;
}
- let containsHasPseudo = false;
selectorAST.walkPseudos((node) => {
- containsHasPseudo = containsHasPseudo || (node.value.toLowerCase() === ':has' && node.nodes);
+ let parent = node.parent;
+ let insideHasPseudoClass = false;
+ while (parent) {
+ if (parser.isPseudoClass(parent) && parent.value.toLowerCase() === ':has') {
+ insideHasPseudoClass = true;
+ }
+
+ parent = parent.parent;
+ }
+
+ if (!insideHasPseudoClass) {
+ return;
+ }
// see : https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c34
// When we have ':has(:visited) {...}', the subject elements of the rule
@@ -65,26 +76,97 @@ const creator: PluginCreator<{ preserve?: boolean, specificityMatchingName?: str
}
});
- if (!containsHasPseudo) {
- return selector;
- }
+ selectorAST.walkPseudos((node) => {
+ if (node.value.toLowerCase() !== ':has' || !node.nodes) {
+ return;
+ }
- const encodedSelector = '[' + encodeCSS(selectorAST.toString()) + ']';
- const abcSpecificity = selectorSpecificity(selectorAST);
+ let container = node.parent ?? node;
+
+ // Split the selector at the pseudo element boundary
+ // - :has(...)::before -> :has(...) | ::before
+ // - :has(...) ~ span::before -> :has(...) ~ span | ::before
+ if (container !== node) {
+ let sliceIndex = container.nodes.length;
+
+ PSEUDO_ELEMENT_LOOP:
+ for (let i = 0; i < container.nodes.length; i++) {
+ const element = container.nodes[i];
+
+ if (parser.isPseudoElement(element)) {
+ for (let j = i - 1; j >= 0; j--) {
+ if (container.nodes[i].type === 'combinator' || container.nodes[i].type === 'comment') {
+ continue;
+ }
+
+ sliceIndex = j + 1;
+ break PSEUDO_ELEMENT_LOOP;
+ }
+ }
+ }
+
+ if (sliceIndex < container.nodes.length) {
+ const a = parser.selector({
+ value: '',
+ nodes: [],
+ });
+
+ const aNodes = container.nodes.slice(0, sliceIndex);
+ aNodes.forEach((x) => {
+ delete x.parent;
+ a.append(x);
+ });
+
+ const b = parser.selector({
+ value: '',
+ nodes: [],
+ });
+
+ const bNodes = container.nodes.slice(sliceIndex);
+ bNodes.forEach((x) => {
+ delete x.parent;
+ b.append(x);
+ });
+
+ const newContainer = parser.selector({
+ value: '',
+ nodes: [],
+ });
+
+ newContainer.append(a);
+ newContainer.append(b);
+
+ container.replaceWith(newContainer);
+ container = a;
+ }
+ }
- let encodedSelectorWithSpecificity = encodedSelector;
- for (let i = 0; i < abcSpecificity.a; i++) {
- encodedSelectorWithSpecificity += specificityMatchingNameId;
- }
- const bSpecificity = Math.max(1, abcSpecificity.b) - 1;
- for (let i = 0; i < bSpecificity; i++) {
- encodedSelectorWithSpecificity += specificityMatchingNameClass;
- }
- for (let i = 0; i < abcSpecificity.c; i++) {
- encodedSelectorWithSpecificity += specificityMatchingNameTag;
+ const encodedSelector = '[' + encodeCSS(container.toString()) + ']';
+ const abcSpecificity = selectorSpecificity(container);
+
+ let encodedSelectorWithSpecificity = encodedSelector;
+ for (let i = 0; i < abcSpecificity.a; i++) {
+ encodedSelectorWithSpecificity += specificityMatchingNameId;
+ }
+ const bSpecificity = Math.max(1, abcSpecificity.b) - 1;
+ for (let i = 0; i < bSpecificity; i++) {
+ encodedSelectorWithSpecificity += specificityMatchingNameClass;
+ }
+ for (let i = 0; i < abcSpecificity.c; i++) {
+ encodedSelectorWithSpecificity += specificityMatchingNameTag;
+ }
+
+ const encodedSelectorAST = parser().astSync(encodedSelectorWithSpecificity);
+
+ container.replaceWith(encodedSelectorAST.nodes[0]);
+ });
+
+ const modifiedSelector = selectorAST.toString();
+ if (modifiedSelector !== selector) {
+ return '.js-has-pseudo ' + modifiedSelector;
}
- return encodedSelectorWithSpecificity;
+ return selector;
});
if (selectors.join(',') === rule.selectors.join(',')) {
diff --git a/plugins/css-has-pseudo/test/_browser.html b/plugins/css-has-pseudo/test/_browser.html
index 4d8c007f9..23ebff966 100644
--- a/plugins/css-has-pseudo/test/_browser.html
+++ b/plugins/css-has-pseudo/test/_browser.html
@@ -10,7 +10,7 @@
-
+
@@ -28,22 +28,24 @@
}
self.runTest = async function runTest() {
- const nestedResult = await testNestedHas();
const adjacentPositionResult = await testAdjacentPosition();
const hasWithPseudoClassesResult = await testHasWithPseudoClasses();
const invalidationResult = await testInvalidation();
+ const nestedResult = await testNestedHas();
const parentPositionResult = await testParentPosition();
+ const pseudoResult = await testPseudos();
const specificityResult = await testSpecificity();
- const visitednessResult = await testVisitedness()
+ const visitednessResult = await testVisitedness();
return (
adjacentPositionResult &&
hasWithPseudoClassesResult &&
invalidationResult &&
+ nestedResult &&
parentPositionResult &&
+ pseudoResult &&
specificityResult &&
- visitednessResult &&
- nestedResult
+ visitednessResult
);
}
@@ -1184,6 +1186,34 @@
return true;
}
+
+ async function testPseudos() {
+ // https://github.com/web-platform-tests/wpt/blob/master/css/selectors/invalidation/has-pseudo-class.html
+
+ fixture.innerHTML = `
+
+
+
+ `;
+
+ const red = 'rgb(255, 0, 0)';
+ var green = 'rgb(0, 128, 0)';
+
+ function testColor(el, pseudo, color) {
+ var actual = getComputedStyle(el, pseudo).color;
+ if (actual !== color) {
+ throw new Error('pseudo elements after :has should work : div#' + el.id + '.color; expected ' + color + ' but got ' + actual);
+ }
+ }
+
+ await rafP(() => {
+ testColor(document.getElementById('a'), '::before', green);
+ });
+
+ return true;
+ }