diff --git a/.changeset/breezy-horses-smell.md b/.changeset/breezy-horses-smell.md new file mode 100644 index 0000000000..b503f71d38 --- /dev/null +++ b/.changeset/breezy-horses-smell.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Added:** The `:nth-child` and `:nth-last-child` pseudo-classes now accept the "of selector" syntax. diff --git a/docs/review/api/alfa-selector.api.md b/docs/review/api/alfa-selector.api.md index d8a9c3e574..51b103c2e4 100644 --- a/docs/review/api/alfa-selector.api.md +++ b/docs/review/api/alfa-selector.api.md @@ -11,6 +11,7 @@ import { Hash } from '@siteimprove/alfa-hash'; import { Hashable } from '@siteimprove/alfa-hash'; import { Iterable as Iterable_2 } from '@siteimprove/alfa-iterable'; import * as json from '@siteimprove/alfa-json'; +import { Maybe } from '@siteimprove/alfa-option'; import { Nth } from '@siteimprove/alfa-css'; import { Option } from '@siteimprove/alfa-option'; import { Parser } from '@siteimprove/alfa-parser'; diff --git a/packages/alfa-selector/src/selector/selector.ts b/packages/alfa-selector/src/selector/selector.ts index 0d26c02f9d..fc70608963 100644 --- a/packages/alfa-selector/src/selector/selector.ts +++ b/packages/alfa-selector/src/selector/selector.ts @@ -71,7 +71,7 @@ export abstract class Selector export namespace Selector { export interface JSON { - [key: string]: json.JSON; + [key: string]: json.JSON | undefined; type: T; specificity: Specificity.JSON; diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts index 0499c162df..1b650f1b37 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts @@ -128,8 +128,8 @@ export namespace PseudoClass { OnlyOfType.parse, Root.parse, Visited.parse, - NthChild.parse, - NthLastChild.parse, + NthChild.parse(parseSelector), + NthLastChild.parse(parseSelector), NthLastOfType.parse, NthOfType.parse, Has.parse(parseSelector), diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/nth-child.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-child.ts index 2e2c2c69f8..f1b077f5c7 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/nth-child.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-child.ts @@ -1,20 +1,30 @@ +import type { Parser as CSSParser } from "@siteimprove/alfa-css"; import type { Nth } from "@siteimprove/alfa-css"; import { Element } from "@siteimprove/alfa-dom"; +import { Maybe, None, Option } from "@siteimprove/alfa-option"; +import type { Thunk } from "@siteimprove/alfa-thunk"; -import { WithIndex } from "./pseudo-class"; +import type { Context } from "../../../context"; +import { Universal } from "../../index"; + +import type { Absolute } from "../../index"; + +import { WithIndexAndSelector } from "./pseudo-class"; const { isElement } = Element; /** * {@link https://drafts.csswg.org/selectors/#nth-child-pseudo} */ -export class NthChild extends WithIndex<"nth-child"> { - public static of(index: Nth): NthChild { - return new NthChild(index); +export class NthChild extends WithIndexAndSelector<"nth-child"> { + public static of(index: Nth, selector: Maybe = None): NthChild { + return new NthChild(index, Maybe.toOption(selector)); } - private constructor(index: Nth) { - super("nth-child", index); + private readonly _indices = new WeakMap(); + + private constructor(index: Nth, selector: Option) { + super("nth-child", index, selector); } /** @public (knip) */ @@ -22,19 +32,26 @@ export class NthChild extends WithIndex<"nth-child"> { yield this; } - public matches(element: Element): boolean { - const indices = NthChild._indices; - - if (!indices.has(element)) { + public matches(element: Element, context?: Context): boolean { + if (!this._indices.has(element)) { element .inclusiveSiblings() .filter(isElement) + .filter((element) => + this._selector + .getOr(Universal.of(Option.of("*"))) + .matches(element, context), + ) .forEach((element, i) => { - indices.set(element, i + 1); + this._indices.set(element, i + 1); }); } - return this._index.matches(indices.get(element)!); + if (!this._indices.has(element)) { + return false; + } + + return this._index.matches(this._indices.get(element)!); } public equals(value: NthChild): boolean; @@ -42,18 +59,21 @@ export class NthChild extends WithIndex<"nth-child"> { public equals(value: unknown): value is this; public equals(value: unknown): boolean { - return value instanceof NthChild && value._index.equals(this._index); + return value instanceof NthChild && super.equals(value); } public toJSON(): NthChild.JSON { - return { - ...super.toJSON(), - }; + return super.toJSON(); } } export namespace NthChild { - export interface JSON extends WithIndex.JSON<"nth-child"> {} + export interface JSON extends WithIndexAndSelector.JSON<"nth-child"> {} - export const parse = WithIndex.parseWithIndex("nth-child", NthChild.of); + export const parse = (parseSelector: Thunk>) => + WithIndexAndSelector.parseWithIndexAndSelector( + "nth-child", + parseSelector, + NthChild.of, + ); } diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-child.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-child.ts index d9b5d03e5c..79ed2ad931 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-child.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-child.ts @@ -1,20 +1,30 @@ +import type { Parser as CSSParser } from "@siteimprove/alfa-css"; import type { Nth } from "@siteimprove/alfa-css"; import { Element } from "@siteimprove/alfa-dom"; +import { Maybe, None, Option } from "@siteimprove/alfa-option"; +import type { Thunk } from "@siteimprove/alfa-thunk"; -import { WithIndex } from "./pseudo-class"; +import type { Context } from "../../../context"; +import { Universal } from "../../index"; + +import type { Absolute } from "../../index"; + +import { WithIndexAndSelector } from "./pseudo-class"; const { isElement } = Element; /** * {@link https://drafts.csswg.org/selectors/#nth-last-child-pseudo} */ -export class NthLastChild extends WithIndex<"nth-last-child"> { - public static of(index: Nth): NthLastChild { - return new NthLastChild(index); +export class NthLastChild extends WithIndexAndSelector<"nth-last-child"> { + public static of(index: Nth, selector: Maybe = None): NthLastChild { + return new NthLastChild(index, Maybe.toOption(selector)); } - private constructor(nth: Nth) { - super("nth-last-child", nth); + private readonly _indices = new WeakMap(); + + private constructor(nth: Nth, selector: Option) { + super("nth-last-child", nth, selector); } /** @public (knip) */ @@ -22,20 +32,27 @@ export class NthLastChild extends WithIndex<"nth-last-child"> { yield this; } - public matches(element: Element): boolean { - const indices = NthLastChild._indices; - - if (!indices.has(element)) { + public matches(element: Element, context?: Context): boolean { + if (!this._indices.has(element)) { element .inclusiveSiblings() .filter(isElement) + .filter((element) => + this._selector + .getOr(Universal.of(Option.of("*"))) + .matches(element, context), + ) .reverse() .forEach((element, i) => { - indices.set(element, i + 1); + this._indices.set(element, i + 1); }); } - return this._index.matches(indices.get(element)!); + if (!this._indices.has(element)) { + return false; + } + + return this._index.matches(this._indices.get(element)!); } public equals(value: NthLastChild): boolean; @@ -43,21 +60,21 @@ export class NthLastChild extends WithIndex<"nth-last-child"> { public equals(value: unknown): value is this; public equals(value: unknown): boolean { - return value instanceof NthLastChild && value._index.equals(this._index); + return value instanceof NthLastChild && super.equals(value); } public toJSON(): NthLastChild.JSON { - return { - ...super.toJSON(), - }; + return super.toJSON(); } } export namespace NthLastChild { - export interface JSON extends WithIndex.JSON<"nth-last-child"> {} + export interface JSON extends WithIndexAndSelector.JSON<"nth-last-child"> {} - export const parse = WithIndex.parseWithIndex( - "nth-last-child", - NthLastChild.of, - ); + export const parse = (parseSelector: Thunk>) => + WithIndexAndSelector.parseWithIndexAndSelector( + "nth-last-child", + parseSelector, + NthLastChild.of, + ); } diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts index 3ab2e4d401..f55f707396 100644 --- a/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts @@ -5,16 +5,17 @@ import { Token, } from "@siteimprove/alfa-css"; import type { Element } from "@siteimprove/alfa-dom"; +import { Option } from "@siteimprove/alfa-option"; import { Parser } from "@siteimprove/alfa-parser"; import { Thunk } from "@siteimprove/alfa-thunk"; -import type { Absolute } from "../../../selector"; +import type { Absolute } from "../../index"; import { Specificity } from "../../../specificity"; import { WithName } from "../../selector"; -const { end, left, map, right } = Parser; -const { parseColon } = Token; +const { delimited, end, left, map, option, pair, right } = Parser; +const { parseColon, parseIdent, parseWhitespace } = Token; /** * @internal @@ -72,12 +73,17 @@ export namespace PseudoClassSelector { export abstract class WithIndex< N extends string = string, > extends PseudoClassSelector { + // For pseudo-classes that do not filter the set of elements, we can use a static + // map of sibling positions. + // For pseudo-classes that may filter the set of elements, we need this to be + // an instance map since two instances may have different extra selector and + // set of candidates. protected static readonly _indices = new WeakMap(); protected readonly _index: Nth; - protected constructor(name: N, nth: Nth) { - super(name); + protected constructor(name: N, nth: Nth, specificity?: Specificity) { + super(name, specificity); this._index = nth; } @@ -102,6 +108,11 @@ export abstract class WithIndex< } } +const parseNth = left( + Nth.parse, + end((token) => `Unexpected token ${token}`), +); + /** * @internal */ @@ -111,13 +122,12 @@ export namespace WithIndex { index: Nth.JSON; } - const parseNth = left( - Nth.parse, - end((token) => `Unexpected token ${token}`), - ); - /** * Parses a functional pseudo-class accepting a nth argument (an+b) + * + * @privateRemarks + * This can't be named just "parse" as it is overwritten by subclasses with a + * different type of parameter (namely, the selector parser). */ export function parseWithIndex( name: string, @@ -180,6 +190,10 @@ export namespace WithSelector { /** * Parses a functional pseudo-class accepting a selector argument + * + * @privateRemarks + * This can't be named just "parse" as it is overwritten by subclasses with a + * different type of parameter (namely, no "name" or "of"). */ export function parseWithSelector( name: string, @@ -192,3 +206,97 @@ export namespace WithSelector { ); } } + +/** + * @internal + */ +export abstract class WithIndexAndSelector< + N extends string = string, +> extends WithIndex { + protected readonly _selector: Option; + + protected constructor( + name: N, + nth: Nth, + selector: Option, + // Both :nth-child and :nth-last-child have this specificity + specificity: Specificity = Specificity.sum( + Specificity.of(0, 1, 0), + selector.map((s) => s.specificity).getOr(Specificity.of(0, 0, 0)), + ), + ) { + super(name, nth, specificity); + + this._selector = selector; + } + + /** @public (knip) */ + public get selector(): Option { + return this._selector; + } + + public equals(value: WithIndexAndSelector): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof WithIndexAndSelector && + super.equals(value) && + value._selector.equals(this._selector) + ); + } + + public toJSON(): WithIndexAndSelector.JSON { + return { + ...super.toJSON(), + ...(this._selector.isSome() + ? { selector: this._selector.get().toJSON() } + : {}), + }; + } + + public toString(): string { + return `:${this.name}(${this._index} of ${this._selector})`; + } +} + +/** + * @internal + */ +export namespace WithIndexAndSelector { + export interface JSON extends WithIndex.JSON { + selector?: Absolute.JSON; + } + + /** + * Parses a functional pseudo-class accepting a nth argument (an+b) + * + * @privateRemarks + * This can't be named just "parse" as it is overwritten by subclasses with a + * different type of parameter (namely, no "name" or "of"). + */ + export function parseWithIndexAndSelector( + name: string, + parseSelector: Thunk>, + of: (nth: Nth, selector: Option) => T, + ): CSSParser { + return map( + right( + parseColon, + Function.parse(name, () => + pair( + Nth.parse, + option( + right( + delimited(parseWhitespace, parseIdent("of")), + parseSelector(), + ), + ), + ), + ), + ), + ([, [nth, selector]]) => of(nth, selector), + ); + } +} diff --git a/packages/alfa-selector/test/nth-[last]-child.spec.tsx b/packages/alfa-selector/test/nth-[last]-child.spec.tsx new file mode 100644 index 0000000000..5508432abe --- /dev/null +++ b/packages/alfa-selector/test/nth-[last]-child.spec.tsx @@ -0,0 +1,89 @@ +import { test } from "@siteimprove/alfa-test"; +import { parse, serialize } from "./parser"; + +test(".parse() parses an :nth-[last]-child selector", (t) => { + for (const name of ["nth-child", "nth-last-child"] as const) { + t.deepEqual(serialize(`:${name}(odd)`), { + type: "pseudo-class", + name, + index: { step: 2, offset: 1 }, + specificity: { a: 0, b: 1, c: 0 }, + }); + } +}); + +test(".parse() accepts the `of selector` syntax", (t) => { + for (const name of ["nth-child", "nth-last-child"] as const) { + t.deepEqual(serialize(`:${name}(odd of div)`), { + type: "pseudo-class", + name, + index: { step: 2, offset: 1 }, + selector: { + type: "type", + namespace: null, + name: "div", + specificity: { a: 0, b: 0, c: 1 }, + }, + specificity: { a: 0, b: 1, c: 1 }, + }); + } +}); + +test(".parse() correctly computes the specificity of :nth-[last]-child of", (t) => { + for (const name of ["nth-child", "nth-last-child"] as const) { + t.deepEqual(serialize(`:${name}(even of li, .item)`).specificity, { + a: 0, + b: 2, + c: 0, + }); + } +}); + +const a =

; +const b =

; +const c =

; +const span = Not a p; + +

+ {a} + Hello + {b} + {span} + {c} +
; + +test("#matches() checks if an element matches an :nth-child selector", (t) => { + const selector = parse(":nth-child(odd)"); + + t.equal(selector.matches(a), true); + t.equal(selector.matches(b), false); + t.equal(selector.matches(span), true); + t.equal(selector.matches(c), false); +}); + +test("#matches() checks if an element matches an :nth-child of selector", (t) => { + const selector = parse(":nth-child(odd of p)"); + + t.equal(selector.matches(a), true); + t.equal(selector.matches(b), false); + t.equal(selector.matches(span), false); + t.equal(selector.matches(c), true); +}); + +test("#matches() checks if an element matches an :nth-last-child selector", (t) => { + const selector = parse(":nth-last-child(odd)"); + + t.equal(selector.matches(a), false); + t.equal(selector.matches(b), true); + t.equal(selector.matches(span), false); + t.equal(selector.matches(c), true); +}); + +test("#matches() checks if an element matches an :nth-last-child of selector", (t) => { + const selector = parse(":nth-last-child(odd of p)"); + + t.equal(selector.matches(a), true); + t.equal(selector.matches(b), false); + t.equal(selector.matches(span), false); + t.equal(selector.matches(c), true); +}); diff --git a/packages/alfa-selector/test/pseudo-class.spec.tsx b/packages/alfa-selector/test/pseudo-class.spec.tsx index 51b3be6d4f..21612cabf5 100644 --- a/packages/alfa-selector/test/pseudo-class.spec.tsx +++ b/packages/alfa-selector/test/pseudo-class.spec.tsx @@ -32,50 +32,6 @@ test(".parse() parses a functional pseudo-class selector", (t) => { }); }); -test("#matches() checks if an element matches an :nth-child selector", (t) => { - const selector = parse(":nth-child(odd)"); - - const a =

; - const b =

; - const c =

; - const d =

; - -

- {a} - Hello - {b} - {c} - {d} -
; - - t.equal(selector.matches(a), true); - t.equal(selector.matches(b), false); - t.equal(selector.matches(c), true); - t.equal(selector.matches(d), false); -}); - -test("#matches() checks if an element matches an :nth-last-child selector", (t) => { - const selector = parse(":nth-last-child(odd)"); - - const a =

; - const b =

; - const c =

; - const d =

; - -

- {a} - Hello - {b} - {c} - {d} -
; - - t.equal(selector.matches(a), false); - t.equal(selector.matches(b), true); - t.equal(selector.matches(c), false); - t.equal(selector.matches(d), true); -}); - test("#matches() checks if an element matches a :first-child selector", (t) => { const selector = parse(":first-child"); @@ -330,14 +286,3 @@ test("#matches() checks if an element matches a :visited selector", (t) => { t.equal(selector.matches(element), false, element.toString()); } }); -test(".parse() parses an :nth-child selector", (t) => { - t.deepEqual(serialize(":nth-child(odd)"), { - type: "pseudo-class", - name: "nth-child", - index: { - step: 2, - offset: 1, - }, - specificity: { a: 0, b: 1, c: 0 }, - }); -}); diff --git a/packages/alfa-selector/tsconfig.json b/packages/alfa-selector/tsconfig.json index fc7e96b9f7..5caa0157e3 100644 --- a/packages/alfa-selector/tsconfig.json +++ b/packages/alfa-selector/tsconfig.json @@ -74,6 +74,7 @@ "test/complex.spec.ts", "test/compound.spec.ts", "test/list.spec.ts", + "test/nth-[last]-child.spec.tsx", "test/pseudo-class.spec.tsx", "test/pseudo-element.spec.ts", "test/specificity.spec.ts"