Skip to content

Commit

Permalink
AG-16951 allow has/is/where inside has
Browse files Browse the repository at this point in the history
Squashed commit of the following:

commit 9752adcfcc7f47ed8c4648ebcbaf35d2bcd587e1
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Thu Oct 13 17:36:28 2022 +0300

    add has(has) tests

commit 96d0cfdd9f6ba202617475432b35a65f295606c1
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Thu Oct 13 16:30:37 2022 +0300

    improve readme about has pseudo-class

commit 53e16449dfdfafe9550e9c1016f4e47f5a360e26
Author: Slava Leleka <v.leleka@adguard.com>
Date:   Thu Oct 13 16:29:43 2022 +0300

    allow has/is/where inside has
  • Loading branch information
slavaleleka committed Oct 14, 2022
1 parent 1f14f0c commit b8b7e8a
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 47 deletions.
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ The idea of extended capabilities is an opportunity to match DOM elements with s

### <a id="extended-css-has"></a> Pseudo-class `:has()`

Draft CSS 4.0 specification describes [pseudo-class `:has`](https://www.w3.org/TR/selectors-4/#relational). Unfortunately, it is not yet widely [supported by browsers](https://developer.mozilla.org/en-US/docs/Web/CSS/:has#browser_compatibility).
Draft CSS 4.0 specification describes [pseudo-class `:has`](https://www.w3.org/TR/selectors-4/#relational). Unfortunately, it is not yet [supported by all popular browsers](https://caniuse.com/css-has).

> Synonyms `:-abp-has` and `:if` are supported for better compatibility.
> Rules with `:has()` pseudo-class should use [native implementation of `:has()`]() if rules use `##` marker and it is possible, i.e. with no other extended pseudo-classes inside. To force ExtendedCss applying of rules with `:has()`, use `#?#`/`#$?#` marker obviously.
> Synonyms `:-abp-has` and `:if` are supported by ExtendedCss for better compatibility.
**Syntax**

Expand All @@ -63,12 +65,15 @@ Draft CSS 4.0 specification describes [pseudo-class `:has`](https://www.w3.org/T

Pseudo-class `:has()` selects the `target` elements that includes the elements that fit to the `selector`. Also `selector` can start with a combinator. Selector list can be set in `selector` as well.

<a id="extended-css-has-limitations"></a> **Limitations**
<a id="extended-css-has-limitations"></a> **Limitations and notes**

> Usage of `:has()` pseudo-class is [restricted for some cases (2, 3)](https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54):
> - disallow `:has()` inside the pseudos accepting only compound selectors;
> - disallow `:has()` after regular pseudo-elements.
> Native `:has()` pseudo-class does not allow `:has()`, `:is()`, `:where()` inside `:has()` argument to avoid increasing the `:has()` invalidation complexity ([case 1](https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54)). But ExtendedCss did not have such limitation earlier and filter lists already contain such rules, so we will not add this limitation in ExtendedCss and allow to use `:has()` inside `:has()` as it was possible before. To use it, just force ExtendedCss usage by setting `#?#`/`#$?#` rule marker.
> Usage of `:has()` pseudo-class is [restricted for some cases](https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54):
> 1. Disallow `:has()`, `:is()`, `:where()` inside `:has()` argument to avoid increasing the :has() invalidation complexity.
> 2. Disallow `:has()` inside the pseudos accepting only compound selectors.
> 3. Disallow `:has()` after regular pseudo-elements.
> Native implementation does not allow any usage of `:scope` inside `:has()` argument ([[1]](https://github.com/w3c/csswg-drafts/issues/7211), [[2]](https://github.com/w3c/csswg-drafts/issues/6399)). Still there some such rules in filter lists: `div:has(:scope > a)` which we will continue to support simply converting them to `div:has(> a)` as it was earlier.
**Examples**

Expand Down
1 change: 0 additions & 1 deletion src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,6 @@ export const SUPPORTED_PSEUDO_CLASSES = [
*/
export const REGULAR_PSEUDO_CLASSES = {
SCOPE: 'scope',
WHERE: 'where',
};

/**
Expand Down
13 changes: 0 additions & 13 deletions src/selector/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import {
IS_PSEUDO_CLASS_MARKER,
NOT_PSEUDO_CLASS_MARKER,
REMOVE_PSEUDO_MARKER,
REGULAR_PSEUDO_CLASSES,
REGULAR_PSEUDO_ELEMENTS,
UPWARD_PSEUDO_CLASS_MARKER,
NTH_ANCESTOR_PSEUDO_CLASS_MARKER,
Expand Down Expand Up @@ -708,18 +707,6 @@ export const parse = (selector: string): AnySelectorNodeInterface => {
// is covered by 'bufferNode === null' above at start of COLON checking
updateBufferNode(context, ASTERISK);
}
// Disallow :has(), :is(), :where() inside :has() argument
// to avoid increasing the :has() invalidation complexity
// https://bugs.chromium.org/p/chromium/issues/detail?id=669058#c54 [1]
if (context.extendedPseudoNamesStack.length > 0
// check the last extended pseudo-class name from context
&& HAS_PSEUDO_CLASS_MARKERS.includes(getLast(context.extendedPseudoNamesStack))
// and check the processing pseudo-class
&& (HAS_PSEUDO_CLASS_MARKERS.includes(nextTokenValue)
|| nextTokenValue === IS_PSEUDO_CLASS_MARKER
|| nextTokenValue === REGULAR_PSEUDO_CLASSES.WHERE)) {
throw new Error(`Usage of :${nextTokenValue} pseudo-class is not allowed inside upper :has`); // eslint-disable-line max-len
}
handleNextTokenOnColon(context, selector, tokenValue, nextTokenValue, nextToNextTokenValue);
}
if (bufferNode?.type === NodeType.Selector) {
Expand Down
146 changes: 133 additions & 13 deletions test/selector/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,98 @@ describe('combined extended selectors', () => {
expect(parse(actual)).toEqual(expected);
});

it('has(has)', () => {
const actual = 'div:has(.banner:has(> a > img))';
const expected = {
type: NodeType.SelectorList,
children: [
{
type: NodeType.Selector,
children: [
getRegularSelector('div'),
{
type: NodeType.ExtendedSelector,
children: [
{
type: NodeType.RelativePseudoClass,
name: 'has',
children: [
{
type: NodeType.SelectorList,
children: [
{
type: NodeType.Selector,
children: [
getRegularSelector('.banner'),
getRelativeExtendedWithSingleRegular('has', '> a > img'),
],
},
],
},
],
},
],
},
],
},
],
};
expect(parse(actual)).toEqual(expected);
});

it('has(has(contains))', () => {
const actual = 'div:has(.banner:has(> span:contains(inner text)))';
const expected = {
type: NodeType.SelectorList,
children: [
{
type: NodeType.Selector,
children: [
getRegularSelector('div'),
{
type: NodeType.ExtendedSelector,
children: [
{
type: NodeType.RelativePseudoClass,
name: 'has',
children: [
{
type: NodeType.SelectorList,
children: [
{
type: NodeType.Selector,
children: [
getRegularSelector('.banner'),
{
type: NodeType.ExtendedSelector,
children: [
{
type: NodeType.RelativePseudoClass,
name: 'has',
children: [
getSingleSelectorAstWithAnyChildren([
{ isRegular: true, value: '> span' },
{ isAbsolute: true, name: 'contains', value: 'inner text' }, // eslint-disable-line max-len
]),
],
},
],
},
],
},
],
},
],
},
],
},
],
},
],
};
expect(parse(actual)).toEqual(expected);
});

it('is(selector list) contains', () => {
const actual = '#__next > :is(.header, .footer):contains(ads)';
const expected = {
Expand Down Expand Up @@ -1723,19 +1815,6 @@ describe('combined selectors', () => {

describe('has pseudo-class limitation', () => {
const toThrowInputs = [
// no :has, :is, :where inside :has
{
selector: 'banner:has(> div:has(> img))',
error: 'Usage of :has pseudo-class is not allowed inside upper :has',
},
{
selector: 'banner:has(> div:is(> img))',
error: 'Usage of :is pseudo-class is not allowed inside upper :has',
},
{
selector: 'banner:has(> div:where(> img))',
error: 'Usage of :where pseudo-class is not allowed inside upper :has',
},
// no :has inside regular pseudos
{
selector: '::slotted(:has(.a))',
Expand Down Expand Up @@ -2069,6 +2148,47 @@ describe('combined selectors', () => {
];
test.each(testsInputs)('%s', (input) => expectSingleSelectorAstWithAnyChildren(input));

it('has(has) + has', () => {
const actual = 'div:has(> .banner:has(> a > img)) + .ad:has(> img)';
const expected = {
type: NodeType.SelectorList,
children: [
{
type: NodeType.Selector,
children: [
getRegularSelector('div'),
{
type: NodeType.ExtendedSelector,
children: [
{
type: NodeType.RelativePseudoClass,
name: 'has',
children: [
{
type: NodeType.SelectorList,
children: [
{
type: NodeType.Selector,
children: [
getRegularSelector('> .banner'),
getRelativeExtendedWithSingleRegular('has', '> a > img'),
],
},
],
},
],
},
],
},
getRegularSelector('+ .ad'),
getRelativeExtendedWithSingleRegular('has', '> img'),
],
},
],
};
expect(parse(actual)).toEqual(expected);
});

it('not > has(not > regular)', () => {
// eslint-disable-next-line max-len
const actual = 'body > div:not([class]) > div[class]:has(> div:not([class]) > .branch-journeys-top a[target="_blank"][href^="/policy/"])';
Expand Down
19 changes: 6 additions & 13 deletions test/selector/query-jsdom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,12 @@ describe('combined pseudo-classes', () => {
{ actual: 'div[id^="inn"][class]:has(p:contains(inner))', expected: 'div#inner' },
// has(contains) has contains
{ actual: '#root div:has(:contains(text)):has(#paragraph):contains(inner paragraph)', expected: '#parent' }, // eslint-disable-line max-len
// has(has)
{ actual: '#parent div:has(div:has(> p))', expected: '#child' },
// has(has contains)
{ actual: '#parent div:has(div:has(> p):contains(inner span text))', expected: '#child' },
// has(has(contains))
{ actual: '#parent div:has(div:has(> p:contains(inner)))', expected: '#child' },
// matches-attr matches-attr upward
{ actual: '#root *[id^="p"][random] > *:matches-attr("/class/"="/base/"):matches-attr("/level$/"="/^[0-9]$/"):upward(1)', expected: '#parent' }, // eslint-disable-line max-len
// matches-attr contains xpath
Expand Down Expand Up @@ -1383,19 +1389,6 @@ describe('combined pseudo-classes', () => {

describe('has limitation', () => {
const toThrowInputs = [
// no :has, :is, :where inside :has
{
selector: 'banner:has(> div:has(> img))',
error: 'Usage of :has pseudo-class is not allowed inside upper :has',
},
{
selector: 'banner:has(> div:is(> img))',
error: 'Usage of :is pseudo-class is not allowed inside upper :has',
},
{
selector: 'banner:has(> div:where(> img))',
error: 'Usage of :where pseudo-class is not allowed inside upper :has',
},
// no :has inside regular pseudos
{
selector: '::slotted(:has(.a))',
Expand Down

0 comments on commit b8b7e8a

Please sign in to comment.