Skip to content

Commit 16f77ef

Browse files
Fix :where/:is in scoped selectors (#204)
1 parent 5833d4e commit 16f77ef

File tree

5 files changed

+117
-48
lines changed

5 files changed

+117
-48
lines changed

.changeset/funny-pets-laugh.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@vanilla-extract/css': patch
3+
---
4+
5+
Ensure `:where`/`:is` selectors are supported when validating scoped selectors

packages/css/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"@emotion/hash": "^0.8.0",
3737
"@vanilla-extract/private": "^1.0.0",
3838
"chalk": "^4.1.1",
39-
"css-selector-parser": "^1.4.1",
39+
"css-what": "^5.0.1",
4040
"cssesc": "^3.0.0",
4141
"csstype": "^3.0.7",
4242
"dedent": "^0.7.0",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { validateSelector } from './validateSelector';
2+
3+
describe('validateSelector', () => {
4+
describe('valid selectors', () => {
5+
const validSelectors = [
6+
'.target',
7+
'.target, .target',
8+
'.target:hover',
9+
'.target:hover:focus',
10+
'.target:where(:hover, :focus)',
11+
'.target:where(:hover, :focus), .target',
12+
'.target:is(:hover, :focus)',
13+
'.target:hover:focus:not(.a)',
14+
'.target:hover:focus:where(:not(.a, .b))',
15+
'.target:hover:focus:is(:not(.a, .b))',
16+
'.target.a',
17+
'.a.target',
18+
'.a.target.b',
19+
'.a.b.target',
20+
'.a .target',
21+
'.a .target:hover',
22+
'.a > .target',
23+
'.a ~ .target',
24+
'.a + .target',
25+
'.a > .b ~ .target',
26+
'.a > .b + .target:hover',
27+
'.a:where(.b, .c) > .target',
28+
'.a:is(.b, .c) > .target',
29+
'.target, .foo .target',
30+
];
31+
32+
validSelectors.forEach((selector) =>
33+
it(selector, () => {
34+
expect(() => validateSelector(selector, 'target')).not.toThrow();
35+
}),
36+
);
37+
});
38+
39+
describe('invalid selectors', () => {
40+
const invalidSelectors = [
41+
'.a',
42+
'.target .a',
43+
'.target, .a',
44+
'.a, .target',
45+
'.target, .target, .a',
46+
'.a .target .b',
47+
'.target :hover',
48+
'.a .target :hover',
49+
'.target > .a',
50+
'.target + .a',
51+
'.target ~ .a',
52+
'.target:where(:hover, :focus) .a',
53+
];
54+
55+
invalidSelectors.forEach((selector) =>
56+
it(selector, () => {
57+
expect(() => validateSelector(selector, 'target')).toThrow();
58+
}),
59+
);
60+
});
61+
});

packages/css/src/validateSelector.ts

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CssSelectorParser } from 'css-selector-parser';
1+
import { parse } from 'css-what';
22
import cssesc from 'cssesc';
33
import dedent from 'dedent';
44

@@ -7,12 +7,6 @@ function escapeRegex(string: string) {
77
return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
88
}
99

10-
const parser = new CssSelectorParser();
11-
parser.registerSelectorPseudos('has');
12-
parser.registerNestingOperators('>', '+', '~');
13-
parser.registerAttrEqualityMods('^', '$', '*', '~');
14-
parser.enableSubstitutes();
15-
1610
export const validateSelector = (selector: string, targetClassName: string) => {
1711
const replaceTarget = () => {
1812
const targetRegex = new RegExp(
@@ -22,45 +16,54 @@ export const validateSelector = (selector: string, targetClassName: string) => {
2216
return selector.replace(targetRegex, '&');
2317
};
2418

25-
return selector.split(',').map((selectorPart) => {
26-
let currentRule;
19+
let selectorParts: ReturnType<typeof parse>;
2720

28-
try {
29-
const result = parser.parse(selectorPart);
21+
try {
22+
selectorParts = parse(selector);
23+
} catch (err) {
24+
throw new Error(`Invalid selector: ${replaceTarget()}`);
25+
}
3026

31-
if (result.type === 'ruleSet') {
32-
currentRule = result.rule;
33-
} else {
34-
throw new Error();
35-
}
36-
} catch (err) {
37-
throw new Error(`Invalid selector: ${replaceTarget()}`);
38-
}
27+
selectorParts.forEach((tokens) => {
28+
try {
29+
for (let i = tokens.length - 1; i >= -1; i--) {
30+
if (!tokens[i]) {
31+
throw new Error();
32+
}
3933

40-
while (currentRule.rule) {
41-
currentRule = currentRule.rule;
42-
}
34+
const token = tokens[i];
4335

44-
const targetRule = currentRule;
36+
if (
37+
token.type === 'child' ||
38+
token.type === 'parent' ||
39+
token.type === 'sibling' ||
40+
token.type === 'adjacent' ||
41+
token.type === 'descendant'
42+
) {
43+
throw new Error();
44+
}
4545

46-
if (
47-
!Array.isArray(targetRule.classNames) ||
48-
!targetRule.classNames.find(
49-
(className: string) => className === targetClassName,
50-
)
51-
) {
46+
if (
47+
token.type === 'attribute' &&
48+
token.name === 'class' &&
49+
token.value === targetClassName
50+
) {
51+
return; // Found it
52+
}
53+
}
54+
} catch (err) {
5255
throw new Error(
5356
dedent`
54-
Invalid selector: ${replaceTarget()}
55-
56-
Style selectors must target the '&' character (along with any modifiers), e.g. ${'`${parent} &`'} or ${'`${parent} &:hover`'}.
57-
58-
This is to ensure that each style block only affects the styling of a single class.
59-
60-
If your selector is targeting another class, you should move it to the style definition for that class, e.g. given we have styles for 'parent' and 'child' elements, instead of adding a selector of ${'`& ${child}`'}) to 'parent', you should add ${'`${parent} &`'} to 'child').
61-
62-
If your selector is targeting something global, use the 'globalStyle' function instead, e.g. if you wanted to write ${'`& h1`'}, you should instead write 'globalStyle(${'`${parent} h1`'}, { ... })'
63-
`,
57+
Invalid selector: ${replaceTarget()}
58+
59+
Style selectors must target the '&' character (along with any modifiers), e.g. ${'`${parent} &`'} or ${'`${parent} &:hover`'}.
60+
61+
This is to ensure that each style block only affects the styling of a single class.
62+
63+
If your selector is targeting another class, you should move it to the style definition for that class, e.g. given we have styles for 'parent' and 'child' elements, instead of adding a selector of ${'`& ${child}`'}) to 'parent', you should add ${'`${parent} &`'} to 'child').
64+
65+
If your selector is targeting something global, use the 'globalStyle' function instead, e.g. if you wanted to write ${'`& h1`'}, you should instead write 'globalStyle(${'`${parent} h1`'}, { ... })'
66+
`,
6467
);
6568
}
6669
});

yarn.lock

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3191,7 +3191,7 @@ __metadata:
31913191
"@types/dedent": ^0.7.0
31923192
"@vanilla-extract/private": ^1.0.0
31933193
chalk: ^4.1.1
3194-
css-selector-parser: ^1.4.1
3194+
css-what: ^5.0.1
31953195
cssesc: ^3.0.0
31963196
csstype: ^3.0.7
31973197
dedent: ^0.7.0
@@ -5352,13 +5352,6 @@ __metadata:
53525352
languageName: node
53535353
linkType: hard
53545354

5355-
"css-selector-parser@npm:^1.4.1":
5356-
version: 1.4.1
5357-
resolution: "css-selector-parser@npm:1.4.1"
5358-
checksum: 1f5332e601c9bb402d804b7561dfe067cf50888c62c5c66aa9754b13e29d50d29b1b1e0798cdda7235eac2e83b1320e42f597b0976c893fe182c0f9c7a2dac59
5359-
languageName: node
5360-
linkType: hard
5361-
53625355
"css-unit-converter@npm:^1.1.1":
53635356
version: 1.1.2
53645357
resolution: "css-unit-converter@npm:1.1.2"
@@ -5373,6 +5366,13 @@ __metadata:
53735366
languageName: node
53745367
linkType: hard
53755368

5369+
"css-what@npm:^5.0.1":
5370+
version: 5.0.1
5371+
resolution: "css-what@npm:5.0.1"
5372+
checksum: 051bcda396ef25fbc58f66a0c9b54c3bd11f5b8a9f9cdf138865c3bff029fddb6df8fffb487a079110d691856385769fe4e9345262fabeb7a09783dd6f6a7bc2
5373+
languageName: node
5374+
linkType: hard
5375+
53765376
"css.escape@npm:^1.5.1":
53775377
version: 1.5.1
53785378
resolution: "css.escape@npm:1.5.1"

0 commit comments

Comments
 (0)