From 21d9b0ea4348d353dc85cacfb3fcace5eac6e4ca Mon Sep 17 00:00:00 2001 From: Steven Lambert <2433219+straker@users.noreply.github.com> Date: Mon, 11 Jan 2021 08:13:50 -0700 Subject: [PATCH] feat(utils/matches): support selectors level 4 :not and :is (#2742) * feat(utils/matches): support selector-v4 :not and :is * fix typo * Update test/core/utils/matches.js Co-authored-by: Wilco Fiers * test Co-authored-by: Wilco Fiers --- lib/checks/keyboard/page-has-heading-one.json | 2 +- lib/checks/navigation/header-present.json | 2 +- lib/core/utils/css-parser.js | 1 + lib/core/utils/matches.js | 10 +- lib/rules/color-contrast-matches.js | 9 +- lib/rules/role-img-alt.json | 2 +- test/core/utils/matches.js | 102 ++++++++++++++---- 7 files changed, 98 insertions(+), 30 deletions(-) diff --git a/lib/checks/keyboard/page-has-heading-one.json b/lib/checks/keyboard/page-has-heading-one.json index daf97fe2cb..0fde9f9b18 100644 --- a/lib/checks/keyboard/page-has-heading-one.json +++ b/lib/checks/keyboard/page-has-heading-one.json @@ -3,7 +3,7 @@ "evaluate": "has-descendant-evaluate", "after": "has-descendant-after", "options": { - "selector": "h1:not([role]):not([aria-level]), h1:not([role])[aria-level=1], h2:not([role])[aria-level=1], h3:not([role])[aria-level=1], h4:not([role])[aria-level=1], h5:not([role])[aria-level=1], h6:not([role])[aria-level=1], [role=heading][aria-level=1]" + "selector": "h1:not([role], [aria-level]), :is(h1, h2, h3, h4, h5, h6):not([role])[aria-level=1], [role=heading][aria-level=1]" }, "metadata": { "impact": "moderate", diff --git a/lib/checks/navigation/header-present.json b/lib/checks/navigation/header-present.json index d88227ad40..e54d2e20b3 100644 --- a/lib/checks/navigation/header-present.json +++ b/lib/checks/navigation/header-present.json @@ -3,7 +3,7 @@ "evaluate": "has-descendant-evaluate", "after": "has-descendant-after", "options": { - "selector": "h1:not([role]), h2:not([role]), h3:not([role]), h4:not([role]), h5:not([role]), h6:not([role]), [role=heading]" + "selector": ":is(h1, h2, h3, h4, h5, h6):not([role]), [role=heading]" }, "metadata": { "impact": "serious", diff --git a/lib/core/utils/css-parser.js b/lib/core/utils/css-parser.js index fd6505471f..355a40236d 100644 --- a/lib/core/utils/css-parser.js +++ b/lib/core/utils/css-parser.js @@ -2,6 +2,7 @@ import { CssSelectorParser } from 'css-selector-parser'; const parser = new CssSelectorParser(); parser.registerSelectorPseudos('not'); +parser.registerSelectorPseudos('is'); parser.registerNestingOperators('>'); parser.registerAttrEqualityMods('^', '$', '*', '~'); diff --git a/lib/core/utils/matches.js b/lib/core/utils/matches.js index 9659d2b0e5..865f70c5b1 100644 --- a/lib/core/utils/matches.js +++ b/lib/core/utils/matches.js @@ -30,7 +30,13 @@ function matchesPseudos(target, exp) { !exp.pseudos || exp.pseudos.every(pseudo => { if (pseudo.name === 'not') { - return !matchesExpression(target, pseudo.expressions[0]); + return !pseudo.expressions.some(expression => { + return matchesExpression(target, expression); + }); + } else if (pseudo.name === 'is') { + return pseudo.expressions.some(expression => { + return matchesExpression(target, expression); + }); } throw new Error( 'the pseudo selector ' + pseudo.name + ' has not yet been implemented' @@ -148,7 +154,7 @@ function convertPseudos(pseudos) { return pseudos.map(p => { var expressions; - if (p.name === 'not') { + if (['is', 'not'].includes(p.name)) { expressions = p.value; expressions = expressions.selectors ? expressions.selectors diff --git a/lib/rules/color-contrast-matches.js b/lib/rules/color-contrast-matches.js index f6f12ec47d..c3fd792410 100644 --- a/lib/rules/color-contrast-matches.js +++ b/lib/rules/color-contrast-matches.js @@ -82,8 +82,13 @@ function colorContrastMatches(node, virtualNode) { // implicit label of disabled control const query = - 'input:not([type="hidden"]):not([type="image"])' + - ':not([type="button"]):not([type="submit"]):not([type="reset"]), select, textarea'; + 'input:not(' + + '[type="hidden"],' + + '[type="image"],' + + '[type="button"],' + + '[type="submit"],' + + '[type="reset"]' + + '), select, textarea'; const implicitControl = querySelectorAll(labelVirtual, query)[0]; if (implicitControl && isDisabled(implicitControl)) { diff --git a/lib/rules/role-img-alt.json b/lib/rules/role-img-alt.json index 6c37e63ccb..a2957b1621 100644 --- a/lib/rules/role-img-alt.json +++ b/lib/rules/role-img-alt.json @@ -1,6 +1,6 @@ { "id": "role-img-alt", - "selector": "[role='img']:not(img):not(area):not(input):not(object)", + "selector": "[role='img']:not(img, area, input, object)", "matches": "html-namespace-matches", "tags": [ "cat.text-alternatives", diff --git a/test/core/utils/matches.js b/test/core/utils/matches.js index 22e165bb17..a729e71f04 100644 --- a/test/core/utils/matches.js +++ b/test/core/utils/matches.js @@ -132,38 +132,94 @@ describe('utils.matches', function() { }); describe('pseudos', function() { - it('returns true if :not matches using tag', function() { + it('throws error if pseudo is not implemented', function() { var virtualNode = queryFixture('

foo

'); - assert.isTrue(matches(virtualNode, 'h1:not(span)')); + assert.throws(function() { + matches(virtualNode, 'h1:empty'); + }); + assert.throws(function() { + matches(virtualNode, 'h1::before'); + }); }); - it('returns true if :not matches using class', function() { - var virtualNode = queryFixture('

foo

'); - assert.isTrue(matches(virtualNode, 'h1:not(.foo)')); - }); + describe(':not', function() { + it('returns true if :not matches using tag', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:not(span)')); + }); - it('returns true if :not matches using attribute', function() { - var virtualNode = queryFixture('

foo

'); - assert.isTrue(matches(virtualNode, 'h1:not([class])')); - }); + it('returns true if :not matches using class', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:not(.foo)')); + }); - it('returns true if :not matches using id', function() { - var virtualNode = queryFixture('

foo

'); - assert.isTrue(matches(virtualNode, 'h1:not(#foo)')); - }); + it('returns true if :not matches using attribute', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:not([class])')); + }); - it('returns false if :not matches element', function() { - var virtualNode = queryFixture('

foo

'); - assert.isFalse(matches(virtualNode, 'h1:not([id])')); + it('returns true if :not matches using id', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:not(#foo)')); + }); + + it('returns true if :not matches none of the selectors', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:not([role=heading], span)')); + }); + + it('returns false if :not matches element', function() { + var virtualNode = queryFixture('

foo

'); + assert.isFalse(matches(virtualNode, 'h1:not([id])')); + }); + + it('returns false if :not matches one of the selectors', function() { + var virtualNode = queryFixture('

foo

'); + assert.isFalse(matches(virtualNode, 'h1:not([role=heading], [id])')); + }); }); - it('throws error if pseudo is not implemented', function() { - var virtualNode = queryFixture('

foo

'); - assert.throws(function() { - matches(virtualNode, 'h1:empty'); + describe(':is', function() { + it('returns true if :is matches using tag', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, ':is(h1)')); }); - assert.throws(function() { - matches(virtualNode, 'h1::before'); + + it('returns true if :is matches using class', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:is(.foo)')); + }); + + it('returns true if :is matches using attribute', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:is([class])')); + }); + + it('returns true if :is matches using id', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:is(#target)')); + }); + + it('returns true if :is matches one of the selectors', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, ':is([role=heading], h1)')); + }); + + it('returns true if :is matches complex selector', function() { + var virtualNode = queryFixture('

foo

'); + assert.isTrue(matches(virtualNode, 'h1:is(div > #target)')); + }); + + it('returns false if :is does not match element', function() { + var virtualNode = queryFixture('

foo

'); + assert.isFalse(matches(virtualNode, 'h1:is([class])')); + }); + + it('returns false if :is matches none of the selectors', function() { + var virtualNode = queryFixture('

foo

'); + assert.isFalse( + matches(virtualNode, 'h1:is([class], span, #foo, .bar)') + ); }); }); });