Skip to content

Commit 63a060e

Browse files
authored
css has pseudo : visitedness (csstools#191)
* css has pseudo : visitedness * update * Apply suggestions from code review * more tests from WPT
1 parent bc64ff3 commit 63a060e

13 files changed

Lines changed: 354 additions & 127 deletions

File tree

experimental/css-has-pseudo/.tape.js

Lines changed: 0 additions & 118 deletions
This file was deleted.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import postcssTape from '../../packages/postcss-tape/dist/index.mjs';
2+
import plugin from '@csstools/css-has-pseudo-experimental';
3+
import postcssLogical from 'postcss-logical';
4+
import postcssNesting from 'postcss-nesting';
5+
import postcssDirPseudoClass from 'postcss-dir-pseudo-class';
6+
7+
postcssTape(plugin)({
8+
'basic': {
9+
message: 'supports basic usage'
10+
},
11+
'basic:preserve': {
12+
message: 'supports { preserve: false } usage',
13+
options: {
14+
preserve: false
15+
}
16+
},
17+
'basic:specificity-matching-name': {
18+
message: 'supports { specificityMatchingName: "other-thing-that-does-not-exist" } usage',
19+
options: {
20+
specificityMatchingName: 'other-thing-that-does-not-exist'
21+
}
22+
},
23+
'generated-selector-cases': {
24+
message: 'correctly handles generated cases',
25+
warnings: 1,
26+
options: {
27+
preserve: false
28+
}
29+
},
30+
'browser': {
31+
message: 'prepare CSS for chrome test',
32+
options: {
33+
preserve: false
34+
}
35+
},
36+
'plugin-order-logical:before': {
37+
message: 'works with other plugins that modify selectors',
38+
plugins: [postcssLogical({preserve: false}), postcssDirPseudoClass({preserve: false}), plugin({preserve: false})],
39+
},
40+
'plugin-order-logical:after': {
41+
message: 'works with other plugins that modify selectors',
42+
plugins: [plugin({ preserve: false }), postcssLogical({ preserve: false }), postcssDirPseudoClass({ preserve: false })],
43+
},
44+
'plugin-order-logical:before:preserve': {
45+
message: 'works with other plugins that modify selectors',
46+
plugins: [postcssLogical({preserve: true}), postcssDirPseudoClass({preserve: true}), plugin({preserve: true})],
47+
},
48+
'plugin-order-logical:after:preserve': {
49+
message: 'works with other plugins that modify selectors',
50+
plugins: [plugin({ preserve: true }), postcssLogical({ preserve: true }), postcssDirPseudoClass({ preserve: true })],
51+
},
52+
'plugin-order-nesting:before': {
53+
message: 'works with other plugins that modify selectors',
54+
plugins: [postcssNesting({preserve: false}), plugin({preserve: false})],
55+
},
56+
'plugin-order-nesting:after': {
57+
message: 'works with other plugins that modify selectors',
58+
plugins: [postcssNesting({preserve: false}), plugin({preserve: false})],
59+
},
60+
'plugin-order-nesting:before:preserve': {
61+
message: 'works with other plugins that modify selectors',
62+
plugins: [plugin({preserve: true}), postcssNesting({preserve: true})],
63+
},
64+
'plugin-order-nesting:after:preserve': {
65+
message: 'works with other plugins that modify selectors',
66+
plugins: [plugin({preserve: true}), postcssNesting({preserve: true})],
67+
}
68+
});

experimental/css-has-pseudo/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changes to CSS Has Pseudo
22

3+
### Unreleased (patch)
4+
5+
- Do not leak visitedness via `:has` pseudo-class.
6+
37
### 0.2.0 (January 12, 2022)
48

59
- Added : documentation and tests for CORS.

experimental/css-has-pseudo/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@
3636
"lint": "eslint ./src --ext .js --ext .ts --ext .mjs --no-error-on-unmatched-pattern",
3737
"prepublishOnly": "npm run clean && npm run build && npm run test",
3838
"stryker": "stryker run --logLevel error",
39-
"test": "postcss-tape --ci && npm run test:unit && npm run test:exports",
39+
"test": "node .tape.mjs && npm run test:unit && npm run test:exports",
4040
"test:browser": "node ./test/_browser.mjs",
4141
"test:exports": "node ./test/_import.mjs && node ./test/_require.cjs",
42+
"test:rewrite-expects": "REWRITE_EXPECTS=true node .tape.mjs",
4243
"test:unit": "node ./src/encode/test.mjs"
4344
},
4445
"engines": {
@@ -49,8 +50,6 @@
4950
},
5051
"devDependencies": {
5152
"@mrhenry/core-web": "^0.6.9",
52-
"postcss": "^8.3.6",
53-
"postcss-tape": "^6.0.1",
5453
"puppeteer": "^13.0.1"
5554
},
5655
"peerDependencies": {

experimental/css-has-pseudo/src/index.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,35 @@ const creator: PluginCreator<{ preserve?: boolean, specificityMatchingName?: str
3939

4040
let containsHasPseudo = false;
4141
selectorAST.walkPseudos((node) => {
42-
containsHasPseudo = containsHasPseudo || node.value === ':has' && node.nodes;
42+
containsHasPseudo = containsHasPseudo || (node.value === ':has' && node.nodes);
43+
44+
// see : https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c34
45+
// When we have ':has(:visited) {...}', the subject elements of the rule
46+
// are the ancestors of the visited link element.
47+
48+
// To prevent leaking visitedness to the link's ancestors, the ':visited'
49+
// selector does not match if it is inside the ':has()' argument selector.
50+
// So if a ':has()' argument selector requires a matching ':visited', the
51+
// style rule are not applied.
52+
if (node.value === ':visited') {
53+
// We can't leave `:has` untouched as that might cause broken selector lists.
54+
// Replacing with the specificity matching name as this should never match anything without `:not()`.
55+
node.replaceWith(parser.className({
56+
value: '.' + options.specificityMatchingName,
57+
}));
58+
}
59+
60+
if (node.value === ':any-link') {
61+
// we can transform `:any-link` to `:link` as this is allowed
62+
node.value = ':link';
63+
}
4364
});
4465

4566
if (!containsHasPseudo) {
4667
return selector;
4768
}
4869

49-
const encodedSelector = '[' + encodeCSS(selector) + ']';
70+
const encodedSelector = '[' + encodeCSS(selectorAST.toString()) + ']';
5071
const abcSpecificity = selectorSpecificity(selectorAST);
5172

5273
let encodedSelectorWithSpecificity = encodedSelector;

experimental/css-has-pseudo/test/_browser.html

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,21 @@
2828
}
2929

3030
self.runTest = async function runTest() {
31-
const invalidationResult = await testInvalidation();
3231
const adjacentPositionResult = await testAdjacentPosition();
33-
const parentPositionResult = await testParentPosition();
3432
const hasWithPseudoClassesResult = await testHasWithPseudoClasses();
35-
36-
return invalidationResult && adjacentPositionResult && parentPositionResult;
33+
const invalidationResult = await testInvalidation();
34+
const parentPositionResult = await testParentPosition();
35+
const specificityResult = await testSpecificity();
36+
const visitednessResult = await testVisitedness()
37+
38+
return (
39+
adjacentPositionResult &&
40+
hasWithPseudoClassesResult &&
41+
invalidationResult &&
42+
parentPositionResult &&
43+
specificityResult &&
44+
visitednessResult
45+
);
3746
}
3847

3948
async function testHasWithPseudoClasses() {
@@ -120,6 +129,73 @@
120129
return true;
121130
}
122131

132+
async function testVisitedness() {
133+
// https://github.com/web-platform-tests/wpt/blob/master/css/selectors/has-visited.html
134+
135+
fixture.innerHTML = `
136+
<div id="visited-1">
137+
<div>parent color should be green with <a href="">visited link</a>.</div>
138+
</div>
139+
<div id="visited-2">
140+
<div>parent color should be black with <a href="unvisited">unvisited link</a>.</div>
141+
</div>
142+
<div id="visited-3">
143+
<div>parent color should be yellowgreen with <a href="unvisited">any link</a>.</div>
144+
</div>
145+
<div id="visited-4">
146+
<div>parent color should be black with <a href="">visited link</a>.</div>
147+
</div>
148+
`;
149+
150+
const black = 'rgb(0, 0, 0)';
151+
const green = 'rgb(0, 128, 0)';
152+
const yellow_green = 'rgb(154, 205, 50)';
153+
154+
function testColor(el, color) {
155+
var actual = getComputedStyle(el).color;
156+
if (actual !== color) {
157+
throw new Error('div#' + el.id + '.color; expected ' + color + ' but got ' + actual);
158+
}
159+
}
160+
161+
await rafP(() => {
162+
testColor(document.getElementById('visited-1'), green);
163+
testColor(document.getElementById('visited-2'), black);
164+
testColor(document.getElementById('visited-3'), yellow_green);
165+
testColor(document.getElementById('visited-4'), black);
166+
});
167+
168+
return true;
169+
}
170+
171+
async function testSpecificity() {
172+
// https://github.com/web-platform-tests/wpt/blob/2b811aa5c2/css/selectors/has-specificity.html
173+
174+
fixture.innerHTML = `
175+
<main id="main_specificity">
176+
<div id=div><p><span id=foo class=foo></span><span class=bar></span><li></li></p></div>
177+
</main>
178+
`;
179+
180+
function test_value(name, description) {
181+
let actual = getComputedStyle(div).getPropertyValue(name);
182+
if (actual !== 'PASS') {
183+
throw new Error(`${name}: expected "PASS", got "${actual}"`);
184+
}
185+
}
186+
187+
await rafP(() => {
188+
test_value('--t0', ':has(#foo) wins over :has(.foo)');
189+
test_value('--t1', ':has(span#foo) wins over :has(#foo)');
190+
test_value('--t2', ':has(.bar, #foo) has same specificity as :has(#foo, .bar)');
191+
test_value('--t3', ':has(.bar, #foo) wins over :has(.foo, .bar)');
192+
test_value('--t4', ':has(span + span) wins over :has(span)');
193+
test_value('--t5', ':has(span, li, p) wins over :has(span, lo, p)');
194+
});
195+
196+
return true;
197+
}
198+
123199
async function testParentPosition() {
124200
// https://github.com/web-platform-tests/wpt/blob/master/css/selectors/invalidation/has-in-ancestor-position.html
125201

experimental/css-has-pseudo/test/basic.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,11 @@ body:not(:has(:focus)) {
122122
.x:has(> .b *) {
123123
order: 30;
124124
}
125+
126+
.x:has(> :visited) {
127+
order: 31;
128+
}
129+
130+
.x:has(> :any-link) {
131+
order: 31;
132+
}

experimental/css-has-pseudo/test/basic.expect.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,19 @@ body:not(:has(:focus)) {
242242
.x:has(> .b *) {
243243
order: 30;
244244
}
245+
246+
[\.x\:has\(\%3E\%20\.\.does-not-exist\)]:not(.does-not-exist) {
247+
order: 31;
248+
}
249+
250+
.x:has(> :visited) {
251+
order: 31;
252+
}
253+
254+
[\.x\:has\(\%3E\%20\:link\)]:not(.does-not-exist) {
255+
order: 31;
256+
}
257+
258+
.x:has(> :any-link) {
259+
order: 31;
260+
}

0 commit comments

Comments
 (0)