diff --git a/.changeset/lemon-rabbits-appear.md b/.changeset/lemon-rabbits-appear.md new file mode 100644 index 0000000000..bfadb7919e --- /dev/null +++ b/.changeset/lemon-rabbits-appear.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Breaking:** The type guards on selectors are now under the namespace of the same name. + +That is, use `Compound.isCompound` instead of `Selector.isCompound`, … diff --git a/.changeset/metal-pandas-end.md b/.changeset/metal-pandas-end.md new file mode 100644 index 0000000000..0fcf7d9f12 --- /dev/null +++ b/.changeset/metal-pandas-end.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Breaking:** `Compound` selectors are now built on top of Iterable, rather than re-inventing chained lists. + +That is, `Compound#left` and `Compound#right` are no more available, but `Compound.selectors` replaces them. diff --git a/.changeset/moody-kings-nail.md b/.changeset/moody-kings-nail.md new file mode 100644 index 0000000000..5461589984 --- /dev/null +++ b/.changeset/moody-kings-nail.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Breaking:** The various kinds of selectors are now directly exported from the package, out of the `Selector` namespace. + +That is, use `Id` instead of `Selector.Id`, … (or `import * as Selector` and keep using `Selector.Id`). diff --git a/.changeset/tidy-cars-own.md b/.changeset/tidy-cars-own.md new file mode 100644 index 0000000000..e94b8a6877 --- /dev/null +++ b/.changeset/tidy-cars-own.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-css": minor +--- + +**Added:** `Function.parse` now also accepts a `Thunk` as body parser. + +This notably allows to build recursive parsers by wrapping them in continuation. diff --git a/.changeset/twenty-seahorses-try.md b/.changeset/twenty-seahorses-try.md new file mode 100644 index 0000000000..2e5abcbb39 --- /dev/null +++ b/.changeset/twenty-seahorses-try.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Breaking:** `List` selectors are now built on top of Iterable, rather than re-inventing chained lists. + +That is, `List#left` and `List#right` are no more available, but `List.selectors` replaces them. diff --git a/docs/review/api/alfa-css.api.md b/docs/review/api/alfa-css.api.md index 7ad5e5fe07..44f90a0db7 100644 --- a/docs/review/api/alfa-css.api.md +++ b/docs/review/api/alfa-css.api.md @@ -20,6 +20,7 @@ import { Record as Record_2 } from '@siteimprove/alfa-record'; import { Result } from '@siteimprove/alfa-result'; import { Serializable } from '@siteimprove/alfa-json'; import { Slice } from '@siteimprove/alfa-slice'; +import { Thunk } from '@siteimprove/alfa-thunk'; // @public (undocumented) export type Angle = Angle.Calculated | Angle.Fixed; @@ -539,7 +540,7 @@ namespace Function_2 { const // (undocumented) consume: Parser; const // (undocumented) - parse: (query?: string | Predicate, body?: Parser | undefined) => Parser_2, readonly [Function_2, T], string, []>; + parse: (query?: string | Predicate, body?: Parser | Thunk> | undefined) => Parser_2, readonly [Function_2, T], string, []>; } export { Function_2 as Function } diff --git a/docs/review/api/alfa-selector.api.md b/docs/review/api/alfa-selector.api.md index f360c20448..b1d217c2ee 100644 --- a/docs/review/api/alfa-selector.api.md +++ b/docs/review/api/alfa-selector.api.md @@ -5,7 +5,6 @@ ```ts import { Array as Array_2 } from '@siteimprove/alfa-array'; -import * as dom from '@siteimprove/alfa-dom'; import { Element } from '@siteimprove/alfa-dom'; import { Equatable } from '@siteimprove/alfa-equatable'; import { Iterable as Iterable_2 } from '@siteimprove/alfa-iterable'; @@ -13,945 +12,542 @@ import * as json from '@siteimprove/alfa-json'; import { Nth } from '@siteimprove/alfa-css'; import { Option } from '@siteimprove/alfa-option'; import { Parser } from '@siteimprove/alfa-parser'; +import { Parser as Parser_2 } from '@siteimprove/alfa-css'; import { Serializable } from '@siteimprove/alfa-json'; import { Slice } from '@siteimprove/alfa-slice'; +import { Thunk } from '@siteimprove/alfa-thunk'; import { Token } from '@siteimprove/alfa-css'; +// Warning: (ae-internal-missing-underscore) The name "Absolute" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export type Absolute = Simple | Compound | Complex | List; + +// @internal (undocumented) +export namespace Absolute { + // (undocumented) + export type JSON = Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; +} + +// Warning: (ae-forgotten-export) The symbol "WithName" needs to be exported by the entry point index.d.ts +// // @public (undocumented) -export class Context { +export class Attribute extends WithName<"attribute"> { // (undocumented) - active(element: Element): Context; + [Symbol.iterator](): Iterator; // (undocumented) - static active(element: Element): Context; + equals(value: Attribute): boolean; // (undocumented) - addState(element: Element, state: Context.State): Context; + equals(value: unknown): value is this; // (undocumented) - static empty(): Context; + get matcher(): Option; // (undocumented) - focus(element: Element): Context; + matches(element: Element): boolean; // (undocumented) - static focus(element: Element): Context; + get modifier(): Option; // (undocumented) - getState(element: Element): Context.State; + get namespace(): Option; // (undocumented) - hasState(element: Element, state: Context.State): boolean; + static of(namespace: Option, name: string, value?: Option, matcher?: Option, modifier?: Option): Attribute; // (undocumented) - hover(element: Element): Context; + toJSON(): Attribute.JSON; // (undocumented) - static hover(element: Element): Context; + toString(): string; // (undocumented) - isActive(element: Element): boolean; + get value(): Option; +} + +// @public (undocumented) +export namespace Attribute { // (undocumented) - isEmpty(): boolean; + export function isAttribute(value: unknown): value is Attribute; // (undocumented) - isFocused(element: Element): boolean; + export interface JSON extends WithName.JSON<"attribute"> { + // (undocumented) + matcher: string | null; + // (undocumented) + modifier: string | null; + // (undocumented) + namespace: string | null; + // (undocumented) + value: string | null; + } // (undocumented) - isHovered(element: Element): boolean; + export enum Matcher { + // (undocumented) + DashMatch = "|=", + // (undocumented) + Equal = "=", + // (undocumented) + Includes = "~=", + // (undocumented) + Prefix = "^=", + // (undocumented) + Substring = "*=", + // (undocumented) + Suffix = "$=" + } // (undocumented) - isVisited(element: Element): boolean; + export enum Modifier { + // (undocumented) + CaseInsensitive = "i", + // (undocumented) + CaseSensitive = "s" + } + const // @internal (undocumented) + parse: Parser, Attribute, string, []>; +} + +// @public (undocumented) +export class Class extends WithName<"class"> { // (undocumented) - static of(state: Iterable<[Element, Context.State]>): Context; + [Symbol.iterator](): Iterator; // (undocumented) - setState(element: Element, state: Context.State): Context; + equals(value: Class): boolean; // (undocumented) - visit(element: Element): Context; + equals(value: unknown): value is this; // (undocumented) - static visit(element: Element): Context; + matches(element: Element): boolean; // (undocumented) - withState(state: Context.State): Iterable; + static of(name: string): Class; + // (undocumented) + toJSON(): Class.JSON; + // (undocumented) + toString(): string; } // @public (undocumented) -export namespace Context { +export namespace Class { // (undocumented) - export enum State { - // (undocumented) - Active = 2, - // (undocumented) - Focus = 4, - // (undocumented) - Hover = 1, - // (undocumented) - None = 0, - // (undocumented) - Visited = 8 + export function isClass(value: unknown): value is Class; + // (undocumented) + export interface JSON extends WithName.JSON<"class"> { } + const // @internal (undocumented) + parse: Parser, Class, string, []>; } // @public (undocumented) -export type Selector = Selector.Simple | Selector.Compound | Selector.Complex | Selector.Relative | Selector.List; +export enum Combinator { + // (undocumented) + Descendant = " ", + // (undocumented) + DirectDescendant = ">", + // (undocumented) + DirectSibling = "+", + // (undocumented) + Sibling = "~" +} // @public (undocumented) -export namespace Selector { +export namespace Combinator { + const // @internal (undocumented) + parseCombinator: Parser_2; +} + +// Warning: (ae-forgotten-export) The symbol "Selector_2" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class Complex extends Selector_2<"complex"> { // (undocumented) - export class Active extends Pseudo.Class<"active"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(): Active; - } + [Symbol.iterator](): Iterator; // (undocumented) - export class After extends Pseudo.Element<"after"> { - // (undocumented) - static of(): After; - } + get combinator(): Combinator; // (undocumented) - export class Attribute extends Selector<"attribute"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: Attribute): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - get matcher(): Option; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - get modifier(): Option; - // (undocumented) - get name(): string; - // (undocumented) - get namespace(): Option; - // (undocumented) - static of(namespace: Option, name: string, value?: Option, matcher?: Option, modifier?: Option): Attribute; - // (undocumented) - toJSON(): Attribute.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "attribute"; - // (undocumented) - get value(): Option; - } + equals(value: Complex): boolean; // (undocumented) - export namespace Attribute { - // (undocumented) - export interface JSON extends Selector.JSON<"attribute"> { - // (undocumented) - matcher: string | null; - // (undocumented) - modifier: string | null; - // (undocumented) - name: string; - // (undocumented) - namespace: string | null; - // (undocumented) - value: string | null; - } - // (undocumented) - export enum Matcher { - // (undocumented) - DashMatch = "|=", - // (undocumented) - Equal = "=", - // (undocumented) - Includes = "~=", - // (undocumented) - Prefix = "^=", - // (undocumented) - Substring = "*=", - // (undocumented) - Suffix = "$=" - } - // (undocumented) - export enum Modifier { - // (undocumented) - CaseInsensitive = "i", - // (undocumented) - CaseSensitive = "s" - } - } + equals(value: unknown): value is this; // (undocumented) - export class Backdrop extends Pseudo.Element<"backdrop"> { - // (undocumented) - static of(): Backdrop; - } + get left(): Simple | Compound | Complex; // (undocumented) - export class Before extends Pseudo.Element<"before"> { - // (undocumented) - static of(): Before; - } + matches(element: Element, context?: Context): boolean; // (undocumented) - export class Class extends Selector<"class"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: Class): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - get name(): string; - // (undocumented) - static of(name: string): Class; - // (undocumented) - toJSON(): Class.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "class"; - } + static of(combinator: Combinator, left: Simple | Compound | Complex, right: Simple | Compound): Complex; // (undocumented) - export namespace Class { - // (undocumented) - export interface JSON extends Selector.JSON<"class"> { - // (undocumented) - name: string; - } - } + get right(): Simple | Compound; // (undocumented) - export enum Combinator { - // (undocumented) - Descendant = " ", - // (undocumented) - DirectDescendant = ">", - // (undocumented) - DirectSibling = "+", - // (undocumented) - Sibling = "~" - } + toJSON(): Complex.JSON; // (undocumented) - export class Complex extends Selector<"complex"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - get combinator(): Combinator; - // (undocumented) - equals(value: Complex): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - get left(): Simple | Compound | Complex; - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(combinator: Combinator, left: Simple | Compound | Complex, right: Simple | Compound): Complex; - // (undocumented) - get right(): Simple | Compound; - // (undocumented) - toJSON(): Complex.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "complex"; - } + toString(): string; +} + +// @public (undocumented) +export namespace Complex { // (undocumented) - export namespace Complex { - // (undocumented) - export interface JSON extends Selector.JSON<"complex"> { - // (undocumented) - combinator: string; - // (undocumented) - left: Simple.JSON | Compound.JSON | JSON; - // (undocumented) - right: Simple.JSON | Compound.JSON; - } - } + export function isComplex(value: unknown): value is Complex; // (undocumented) - export class Compound extends Selector<"compound"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: Compound): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - get left(): Simple; + export interface JSON extends Selector_2.JSON<"complex"> { // (undocumented) - matches(element: Element, context?: Context): boolean; + combinator: Combinator; // (undocumented) - static of(left: Simple, right: Simple | Compound): Compound; + left: Simple.JSON | Compound.JSON | Complex.JSON; // (undocumented) - get right(): Simple | Compound; - // (undocumented) - toJSON(): Compound.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "compound"; + right: Simple.JSON | Compound.JSON; } + const // @internal (undocumented) + parseComplex: (parseSelector: Thunk>) => Parser, Simple | Compound | Complex, string, []>; +} + +// @public (undocumented) +export class Compound extends Selector_2<"compound"> { // (undocumented) - export namespace Compound { - // (undocumented) - export interface JSON extends Selector.JSON<"compound"> { - // (undocumented) - left: Simple.JSON; - // (undocumented) - right: Simple.JSON | JSON; - } - } + [Symbol.iterator](): Iterator; // (undocumented) - export namespace Cue { - // (undocumented) - export interface JSON extends Pseudo.Element.JSON<"cue"> { - // (undocumented) - selector: Option.JSON; - } - } + equals(value: Compound): boolean; // (undocumented) - export namespace CueRegion { - // (undocumented) - export interface JSON extends Pseudo.Element.JSON<"cue-region"> { - // (undocumented) - selector: Option.JSON; - } - } + equals(value: unknown): value is this; // (undocumented) - export class Disabled extends Pseudo.Class<"disabled"> { - // (undocumented) - matches(element: dom.Element, context?: Context): boolean; - // (undocumented) - static of(): Disabled; - } + get length(): number; // (undocumented) - export class Empty extends Pseudo.Class<"empty"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): Empty; - } + matches(element: Element, context?: Context): boolean; // (undocumented) - export class Enabled extends Pseudo.Class<"enabled"> { - // (undocumented) - matches(element: dom.Element, context?: Context): boolean; - // (undocumented) - static of(): Enabled; - } - const // (undocumented) - isPseudoClass: typeof Pseudo.isClass, // (undocumented) - isPseudoElement: typeof Pseudo.isElement; + static of(...selectors: Array_2): Compound; // (undocumented) - export class FileSelectorButton extends Pseudo.Element<"file-selector-button"> { - // (undocumented) - static of(): FileSelectorButton; - } + get selectors(): Iterable_2; // (undocumented) - export class FirstChild extends Pseudo.Class<"first-child"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): FirstChild; - } + toJSON(): Compound.JSON; // (undocumented) - export class FirstLetter extends Pseudo.Element<"first-letter"> { - // (undocumented) - static of(): FirstLetter; - } + toString(): string; +} + +// @public (undocumented) +export namespace Compound { // (undocumented) - export class FirstLine extends Pseudo.Element<"first-line"> { - // (undocumented) - static of(): FirstLine; - } + export function isCompound(value: unknown): value is Compound; // (undocumented) - export class FirstOfType extends Pseudo.Class<"first-of-type"> { - // (undocumented) - matches(element: Element): boolean; + export interface JSON extends Selector_2.JSON<"compound"> { // (undocumented) - static of(): FirstOfType; + selectors: Array_2; } + const // @internal (undocumented) + parseCompound: (parseSelector: () => Parser, Absolute, string>) => Parser, Simple | Compound, string, []>; +} + +// @public (undocumented) +export class Context { // (undocumented) - export class Focus extends Pseudo.Class<"focus"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(): Focus; - } + active(element: Element): Context; // (undocumented) - export class FocusVisible extends Pseudo.Class<"focus-visible"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(): FocusVisible; - } + static active(element: Element): Context; // (undocumented) - export class FocusWithin extends Pseudo.Class<"focus-within"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(): FocusWithin; - } + addState(element: Element, state: Context.State): Context; // (undocumented) - export class GrammarError extends Pseudo.Element<"grammar-error"> { - // (undocumented) - static of(): GrammarError; - } + static empty(): Context; // (undocumented) - export class Has extends Pseudo.Class<"has"> { - // (undocumented) - equals(value: Has): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - static of(selector: Simple | Compound | Complex | List): Has; - // (undocumented) - get selector(): Simple | Compound | Complex | List; - // (undocumented) - toJSON(): Has.JSON; - // (undocumented) - toString(): string; - } + focus(element: Element): Context; // (undocumented) - export namespace Has { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"has"> { - // (undocumented) - selector: Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; - } - } + static focus(element: Element): Context; // (undocumented) - export class Host extends Pseudo.Class<"host"> { - // (undocumented) - static of(): Host; - } + getState(element: Element): Context.State; // (undocumented) - export class Hover extends Pseudo.Class<"hover"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(): Hover; - } + hasState(element: Element, state: Context.State): boolean; // (undocumented) - export class Id extends Selector<"id"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: Id): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - get name(): string; - // (undocumented) - static of(name: string): Id; - // (undocumented) - toJSON(): Id.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "id"; - } + hover(element: Element): Context; // (undocumented) - export namespace Id { - // (undocumented) - export interface JSON extends Selector.JSON<"id"> { - // (undocumented) - name: string; - } - } + static hover(element: Element): Context; // (undocumented) - export class Is extends Pseudo.Class<"is"> { - // (undocumented) - equals(value: Is): boolean; - // (undocumented) - equals(value: unknown): value is this; + isActive(element: Element): boolean; + // (undocumented) + isEmpty(): boolean; + // (undocumented) + isFocused(element: Element): boolean; + // (undocumented) + isHovered(element: Element): boolean; + // (undocumented) + isVisited(element: Element): boolean; + // (undocumented) + static of(state: Iterable<[Element, Context.State]>): Context; + // (undocumented) + setState(element: Element, state: Context.State): Context; + // (undocumented) + visit(element: Element): Context; + // (undocumented) + static visit(element: Element): Context; + // (undocumented) + withState(state: Context.State): Iterable; +} + +// @public (undocumented) +export namespace Context { + // (undocumented) + export enum State { // (undocumented) - matches(element: Element, context?: Context): boolean; + Active = 2, // (undocumented) - static of(selector: Simple | Compound | Complex | List): Is; + Focus = 4, // (undocumented) - get selector(): Simple | Compound | Complex | List; + Hover = 1, // (undocumented) - toJSON(): Is.JSON; + None = 0, // (undocumented) - toString(): string; + Visited = 8 } +} + +// @public (undocumented) +export class Id extends WithName<"id"> { // (undocumented) - export namespace Is { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"is"> { - // (undocumented) - selector: Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; - } - } + [Symbol.iterator](): Iterator; // (undocumented) - export function isAttribute(value: unknown): value is Attribute; + equals(value: Id): boolean; // (undocumented) - export function isClass(value: unknown): value is Class; + equals(value: unknown): value is this; // (undocumented) - export function isComplex(value: unknown): value is Complex; + matches(element: Element): boolean; // (undocumented) - export function isCompound(value: unknown): value is Compound; + static of(name: string): Id; + // (undocumented) + toJSON(): Id.JSON; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +export namespace Id { // (undocumented) export function isId(value: unknown): value is Id; // (undocumented) - export function isPseudo(value: unknown): value is Pseudo; + export interface JSON extends WithName.JSON<"id"> { + } + const // @internal (undocumented) + parse: Parser, Id, string, []>; +} + +// Warning: (ae-forgotten-export) The symbol "Item" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export class List extends Selector_2<"list"> { // (undocumented) - export function isSimple(value: unknown): value is Simple; + [Symbol.iterator](): Iterator; // (undocumented) - export function isType(value: unknown): value is Type; + equals(value: List): boolean; // (undocumented) - export interface JSON { - // (undocumented) - [key: string]: json.JSON; - // (undocumented) - type: T; - } + equals(value: unknown): value is this; // (undocumented) - export class LastChild extends Pseudo.Class<"last-child"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): LastChild; - } + get length(): number; // (undocumented) - export class LastOfType extends Pseudo.Class<"last-of-type"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): LastOfType; - } + matches(element: Element, context?: Context): boolean; // (undocumented) - export class Link extends Pseudo.Class<"link"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(): Link; - } + static of(...selectors: Array_2): List; // (undocumented) - export class List extends Selector<"list"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: List): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - get left(): T; - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(left: T, right: T | List): List; - // (undocumented) - get right(): T | List; - // (undocumented) - toJSON(): List.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "list"; - } + get selectors(): Iterable_2; // (undocumented) - export namespace List { - // (undocumented) - export interface JSON extends Selector.JSON<"list"> { - // (undocumented) - left: Simple.JSON | Compound.JSON | Complex.JSON | Relative.JSON; - // (undocumented) - right: Simple.JSON | Compound.JSON | Complex.JSON | Relative.JSON | JSON; - } - } + toJSON(): List.JSON; // (undocumented) - export class Marker extends Pseudo.Element<"marker"> { - // (undocumented) - static of(): Marker; - } + toString(): string; +} + +// @public (undocumented) +export namespace List { // (undocumented) - export class Not extends Pseudo.Class<"not"> { - // (undocumented) - equals(value: Not): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element, context?: Context): boolean; - // (undocumented) - static of(selector: Simple | Compound | Complex | List): Not; - // (undocumented) - get selector(): Simple | Compound | Complex | List; - // (undocumented) - toJSON(): Not.JSON; + export interface JSON extends Selector_2.JSON<"list"> { // (undocumented) - toString(): string; + selectors: Array_2>; } + const // @internal (undocumented) + parseList: (parseSelector: Thunk>) => Parser, List, string, []>; +} + +// Warning: (ae-forgotten-export) The symbol "Active" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Disabled" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Empty" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Enabled" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FirstChild" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FirstOfType" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Focus" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FocusVisible" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FocusWithin" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Has" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Host" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Hover" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Is" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "LastChild" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "LastOfType" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Link" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Not" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "NthChild" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "NthLastChild" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "NthLastOfType" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "NthOfType" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "OnlyChild" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "OnlyOfType" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Root" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Visited" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type PseudoClass = Active | Disabled | Empty | Enabled | FirstChild | FirstOfType | Focus | FocusVisible | FocusWithin | Has | Host | Hover | Is | LastChild | LastOfType | Link | Not | NthChild | NthLastChild | NthLastOfType | NthOfType | OnlyChild | OnlyOfType | Root | Visited; + +// @public (undocumented) +export namespace PseudoClass { // (undocumented) - export namespace Not { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"not"> { - // (undocumented) - selector: Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; - } - } + export function isPseudoClass(value: unknown): value is PseudoClass; // (undocumented) - export class NthChild extends Pseudo.Class<"nth-child"> { - // (undocumented) - equals(value: NthChild): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(index: Nth): NthChild; - // (undocumented) - toJSON(): NthChild.JSON; - // (undocumented) - toString(): string; - } + export type JSON = Active.JSON | Disabled.JSON | Empty.JSON | Enabled.JSON | FirstChild.JSON | FirstOfType.JSON | Focus.JSON | FocusVisible.JSON | FocusWithin.JSON | Has.JSON | Host.JSON | Hover.JSON | Is.JSON | LastChild.JSON | LastOfType.JSON | Link.JSON | Not.JSON | NthChild.JSON | NthLastChild.JSON | NthLastOfType.JSON | NthOfType.JSON | OnlyChild.JSON | OnlyOfType.JSON | Root.JSON | Visited.JSON; + // Warning: (ae-incompatible-release-tags) The symbol "parse" is marked as @public, but its signature references "Absolute" which is marked as @internal + // // (undocumented) - export namespace NthChild { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"nth-child"> { - // (undocumented) - index: Nth.JSON; - } - } + export function parse(parseSelector: Thunk>): Parser_2; +} + +// Warning: (ae-forgotten-export) The symbol "After" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Backdrop" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Before" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Cue" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "CueRegion" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FileSelectorButton" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FirstLetter" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "FirstLine" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "GrammarError" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Marker" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Part" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Placeholder" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Selection" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "Slotted" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "SpellingError" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "TargetText" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +export type PseudoElement = After | Backdrop | Before | Cue | CueRegion | FileSelectorButton | FirstLetter | FirstLine | GrammarError | Marker | Part | Placeholder | Selection | Slotted | SpellingError | TargetText; + +// @public (undocumented) +export namespace PseudoElement { // (undocumented) - export class NthLastChild extends Pseudo.Class<"nth-last-child"> { - // (undocumented) - equals(value: NthLastChild): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(index: Nth): NthLastChild; - // (undocumented) - toJSON(): NthLastChild.JSON; - // (undocumented) - toString(): string; - } + export function isPseudoElement(value: unknown): value is PseudoElementSelector; + // Warning: (ae-forgotten-export) The symbol "PseudoElementSelector" needs to be exported by the entry point index.d.ts + // // (undocumented) - export namespace NthLastChild { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"nth-last-child"> { - // (undocumented) - index: Nth.JSON; - } - } + export type JSON = PseudoElementSelector.JSON; + // Warning: (ae-incompatible-release-tags) The symbol "parse" is marked as @public, but its signature references "Absolute" which is marked as @internal + // // (undocumented) - export class NthLastOfType extends Pseudo.Class<"nth-last-of-type"> { - // (undocumented) - equals(value: NthLastOfType): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(index: Nth): NthLastOfType; - // (undocumented) - toJSON(): NthLastOfType.JSON; - // (undocumented) - toString(): string; - } + export function parse(parseSelector: Thunk>): Parser_2; +} + +// @public (undocumented) +export class Relative extends Selector_2<"relative"> { // (undocumented) - export namespace NthLastOfType { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"nth-last-of-type"> { - // (undocumented) - index: Nth.JSON; - } - } + [Symbol.iterator](): Iterator; // (undocumented) - export class NthOfType extends Pseudo.Class<"nth-of-type"> { - // (undocumented) - equals(value: NthOfType): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(index: Nth): NthOfType; - // (undocumented) - toJSON(): NthOfType.JSON; - // (undocumented) - toString(): string; - } + get combinator(): Combinator; // (undocumented) - export namespace NthOfType { - // (undocumented) - export interface JSON extends Pseudo.Class.JSON<"nth-of-type"> { - // (undocumented) - index: Nth.JSON; - } - } + equals(value: Relative): boolean; // (undocumented) - export class OnlyChild extends Pseudo.Class<"only-child"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): OnlyChild; - } + equals(value: unknown): value is this; // (undocumented) - export class OnlyOfType extends Pseudo.Class<"only-of-type"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): OnlyOfType; - } + matches(): boolean; // (undocumented) - export class Part extends Pseudo.Element<"part"> { - // (undocumented) - equals(value: Part): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - get idents(): Iterable_2; - // (undocumented) - static of(idents: Iterable_2): Part; - // (undocumented) - toJSON(): Part.JSON; - // (undocumented) - toString(): string; - } + static of(combinator: Combinator, selector: Simple | Compound | Complex): Relative; // (undocumented) - export namespace Part { - // (undocumented) - export interface JSON extends Pseudo.Element.JSON<"part"> { - // (undocumented) - idents: Array_2; - } - } + get selector(): Simple | Compound | Complex; // (undocumented) - export class Placeholder extends Pseudo.Element<"placeholder"> { - // (undocumented) - static of(): Placeholder; - } + toJSON(): Relative.JSON; // (undocumented) - export namespace Pseudo { - // (undocumented) - export abstract class Class extends Selector<"pseudo-class"> { - // (undocumented) - [Symbol.iterator](): Iterator; - protected constructor(name: N); - // (undocumented) - equals(value: Class): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: dom.Element, context?: Context): boolean; - // (undocumented) - get name(): N; - // (undocumented) - protected readonly _name: N; - // (undocumented) - toJSON(): Class.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "pseudo-class"; - } - // (undocumented) - export namespace Class { - // (undocumented) - export interface JSON extends Selector.JSON<"pseudo-class"> { - // (undocumented) - name: N; - } - } - // (undocumented) - export abstract class Element extends Selector<"pseudo-element"> { - // (undocumented) - [Symbol.iterator](): Iterator; - protected constructor(name: N); - // (undocumented) - equals(value: Element): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: dom.Element, context?: Context): boolean; - // (undocumented) - get name(): N; - // (undocumented) - protected readonly _name: N; - // (undocumented) - toJSON(): Element.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "pseudo-element"; - } - // (undocumented) - export namespace Element { - // (undocumented) - export interface JSON extends Selector.JSON<"pseudo-element"> { - // (undocumented) - name: N; - } - } - // (undocumented) - export function isClass(value: unknown): value is Class; + toString(): string; +} + +// @public (undocumented) +export namespace Relative { + // (undocumented) + export interface JSON extends Selector_2.JSON<"relative"> { // (undocumented) - export function isElement(value: unknown): value is Element; + combinator: string; // (undocumented) - export type JSON = Class.JSON | Element.JSON; + selector: Simple.JSON | Compound.JSON | Complex.JSON; } +} + +// @public (undocumented) +export type Selector = Simple | Compound | Complex | Relative | List; + +// @public (undocumented) +export namespace Selector { // (undocumented) - export type Pseudo = Pseudo.Class | Pseudo.Element; + export type JSON = Simple.JSON | Compound.JSON | Complex.JSON | Relative.JSON | List.JSON; + const // Warning: (ae-incompatible-release-tags) The symbol "parse" is marked as @public, but its signature references "Absolute" which is marked as @internal + // // (undocumented) - export class Relative extends Selector<"relative"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - get combinator(): Combinator; - // (undocumented) - equals(value: Relative): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(): boolean; - // (undocumented) - static of(combinator: Combinator, selector: Simple | Compound | Complex): Relative; - // (undocumented) - get selector(): Simple | Compound | Complex; - // (undocumented) - toJSON(): Relative.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "relative"; - } + parse: Parser_2; +} + +// @public (undocumented) +export type Simple = Type | Universal | Attribute | Class | Id | PseudoClass | PseudoElement; + +// @public (undocumented) +export namespace Simple { // (undocumented) - export namespace Relative { - // (undocumented) - export interface JSON extends Selector.JSON<"relative"> { - // (undocumented) - combinator: string; - // (undocumented) - selector: Simple.JSON | Compound.JSON | Complex.JSON; - } - } + export function isSimple(value: unknown): value is Simple; // (undocumented) - export class Root extends Pseudo.Class<"root"> { - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - static of(): Root; - } + export type JSON = Type.JSON | Universal.JSON | Attribute.JSON | Class.JSON | Id.JSON | PseudoClass.JSON | PseudoElement.JSON; + const // @internal (undocumented) + parse: (parseSelector: Thunk>) => Parser, Simple, string, []>; +} + +// @public (undocumented) +export class Type extends WithName<"type"> { // (undocumented) - export class Selection extends Pseudo.Element<"selection"> { - // (undocumented) - static of(): Selection; - } + [Symbol.iterator](): Iterator; // (undocumented) - export abstract class Selector implements Iterable_2, Equatable, Serializable { - // (undocumented) - abstract [Symbol.iterator](): Iterator; - // (undocumented) - abstract equals(value: Selector): boolean; - // (undocumented) - abstract equals(value: unknown): value is this; - // (undocumented) - abstract matches(element: Element, context?: Context): boolean; - // (undocumented) - abstract toJSON(): JSON; - // (undocumented) - abstract get type(): T; - } + equals(value: Type): boolean; // (undocumented) - export type Simple = Type | Universal | Attribute | Class | Id | Pseudo; + equals(value: unknown): value is this; // (undocumented) - export namespace Simple { - // (undocumented) - export type JSON = Type.JSON | Universal.JSON | Attribute.JSON | Class.JSON | Id.JSON | Pseudo.JSON; - } + matches(element: Element): boolean; // (undocumented) - export class Slotted extends Pseudo.Element<"slotted"> { - // (undocumented) - equals(value: Slotted): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - static of(selectors: Iterable_2): Slotted; - // (undocumented) - get selectors(): Iterable_2; - // (undocumented) - toJSON(): Slotted.JSON; - // (undocumented) - toString(): string; - } + get namespace(): Option; // (undocumented) - export namespace Slotted { - // (undocumented) - export interface JSON extends Pseudo.Element.JSON<"slotted"> { - // (undocumented) - selectors: Array_2; - } - } + static of(namespace: Option, name: string): Type; // (undocumented) - export class SpellingError extends Pseudo.Element<"spelling-error"> { - // (undocumented) - static of(): SpellingError; - } + toJSON(): Type.JSON; // (undocumented) - export class TargetText extends Pseudo.Element<"target-text"> { - // (undocumented) - static of(): TargetText; - } + toString(): string; +} + +// @public (undocumented) +export namespace Type { // (undocumented) - export class Type extends Selector<"type"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - equals(value: Type): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - get name(): string; - // (undocumented) - get namespace(): Option; - // (undocumented) - static of(namespace: Option, name: string): Type; - // (undocumented) - toJSON(): Type.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "type"; - } + export function isType(value: unknown): value is Type; // (undocumented) - export namespace Type { + export interface JSON extends WithName.JSON<"type"> { // (undocumented) - export interface JSON extends Selector.JSON<"type"> { - // (undocumented) - name: string; - // (undocumented) - namespace: string | null; - } + namespace: string | null; } + const // @internal (undocumented) + parse: Parser, Type, string, []>; +} + +// @public (undocumented) +export class Universal extends Selector_2<"universal"> { // (undocumented) - export class Universal extends Selector<"universal"> { - // (undocumented) - [Symbol.iterator](): Iterator; - // (undocumented) - static empty(): Universal; - // (undocumented) - equals(value: Universal): boolean; - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - matches(element: Element): boolean; - // (undocumented) - get namespace(): Option; - // (undocumented) - static of(namespace: Option): Universal; - // (undocumented) - toJSON(): Universal.JSON; - // (undocumented) - toString(): string; - // (undocumented) - get type(): "universal"; - } + [Symbol.iterator](): Iterator; // (undocumented) - export namespace Universal { - // (undocumented) - export interface JSON extends Selector.JSON<"universal"> { - // (undocumented) - namespace: string | null; - } - } + static empty(): Universal; // (undocumented) - export class Visited extends Pseudo.Class<"visited"> { - // (undocumented) - matches(element: Element, context?: Context): boolean; + equals(value: Universal): boolean; + // (undocumented) + equals(value: unknown): value is this; + // (undocumented) + matches(element: Element): boolean; + // (undocumented) + get namespace(): Option; + // (undocumented) + static of(namespace: Option): Universal; + // (undocumented) + toJSON(): Universal.JSON; + // (undocumented) + toString(): string; +} + +// @public (undocumented) +export namespace Universal { + // (undocumented) + export function isUniversal(value: unknown): value is Universal; + // (undocumented) + export interface JSON extends Selector_2.JSON<"universal"> { // (undocumented) - static of(): Visited; + namespace: string | null; } const // (undocumented) - parse: Parser, Simple | Compound | Complex | List, string>; - {}; + parse: Parser, Universal, string, []>; } // (No @packageDocumentation comment for this package) diff --git a/packages/alfa-cascade/src/ancestor-filter.ts b/packages/alfa-cascade/src/ancestor-filter.ts index 50a237ac97..19125a0693 100644 --- a/packages/alfa-cascade/src/ancestor-filter.ts +++ b/packages/alfa-cascade/src/ancestor-filter.ts @@ -1,5 +1,5 @@ import { Element } from "@siteimprove/alfa-dom"; -import { Selector } from "@siteimprove/alfa-selector"; +import { Class, Id, Selector, Type } from "@siteimprove/alfa-selector"; /** * The ancestor filter is a data structure used for optimising selector matching @@ -79,15 +79,15 @@ export class AncestorFilter { } public matches(selector: Selector): boolean { - if (selector instanceof Selector.Id) { + if (selector instanceof Id) { return this._ids.has(selector.name); } - if (selector instanceof Selector.Class) { + if (selector instanceof Class) { return this._classes.has(selector.name); } - if (selector instanceof Selector.Type) { + if (selector instanceof Type) { return this._types.has(selector.name); } diff --git a/packages/alfa-cascade/src/selector-map.ts b/packages/alfa-cascade/src/selector-map.ts index 21fdea882b..fcb9b86c32 100644 --- a/packages/alfa-cascade/src/selector-map.ts +++ b/packages/alfa-cascade/src/selector-map.ts @@ -15,7 +15,19 @@ import { Media } from "@siteimprove/alfa-media"; import { Option } from "@siteimprove/alfa-option"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; -import { Context, Selector } from "@siteimprove/alfa-selector"; +import { + Attribute, + Class, + Combinator, + Complex, + Compound, + Context, + Id, + PseudoClass, + PseudoElement, + Selector, + Type, +} from "@siteimprove/alfa-selector"; import * as json from "@siteimprove/alfa-json"; @@ -24,25 +36,21 @@ import { AncestorFilter } from "./ancestor-filter"; const { equals, property } = Predicate; const { and } = Refinement; -const { - isAttribute, - isClass, - isComplex, - isCompound, - isId, - isType, - isPseudoClass, - isPseudoElement, -} = Selector; + +const { isAttribute } = Attribute; +const { isClass } = Class; +const { isComplex } = Complex; +const { isCompound } = Compound; +const { isId } = Id; +const { isPseudoClass } = PseudoClass; +const { isPseudoElement } = PseudoElement; +const { isType } = Type; const isDescendantSelector = and( isComplex, property( "combinator", - equals( - Selector.Combinator.Descendant, - Selector.Combinator.DirectDescendant, - ), + equals(Combinator.Descendant, Combinator.DirectDescendant), ), ); @@ -430,15 +438,16 @@ export namespace SelectorMap { * key selector. If the right-most selector is a compound selector, then the * left-most ID, class, or type selector of the compound selector is returned. */ -function getKeySelector( - selector: Selector, -): Selector.Id | Selector.Class | Selector.Type | null { +function getKeySelector(selector: Selector): Id | Class | Type | null { if (isId(selector) || isClass(selector) || isType(selector)) { return selector; } if (isCompound(selector)) { - return getKeySelector(selector.left) ?? getKeySelector(selector.right); + return Iterable.find( + Iterable.map(selector.selectors, getKeySelector), + (selector) => selector !== null, + ).getOr(null); } if (isComplex(selector)) { @@ -471,11 +480,7 @@ function getSpecificity(selector: Selector): Specificity { const queue: Array = [selector]; while (queue.length > 0) { - const selector = queue.pop(); - - if (selector === undefined) { - break; - } + const selector = queue.pop()!; if (isId(selector)) { a++; @@ -487,8 +492,10 @@ function getSpecificity(selector: Selector): Specificity { b++; } else if (isType(selector) || isPseudoElement(selector)) { c++; - } else if (isCompound(selector) || isComplex(selector)) { + } else if (isComplex(selector)) { queue.push(selector.left, selector.right); + } else if (isCompound(selector)) { + queue.push(...selector.selectors); } } @@ -513,8 +520,8 @@ function canReject(selector: Selector, filter: AncestorFilter): boolean { if (isCompound(selector)) { // Compound selectors are right-leaning, so recurse to the left first as it // is likely the shortest branch. - return ( - canReject(selector.left, filter) || canReject(selector.right, filter) + return Iterable.some(selector.selectors, (selector) => + canReject(selector, filter), ); } @@ -522,8 +529,8 @@ function canReject(selector: Selector, filter: AncestorFilter): boolean { const { combinator } = selector; if ( - combinator === Selector.Combinator.Descendant || - combinator === Selector.Combinator.DirectDescendant + combinator === Combinator.Descendant || + combinator === Combinator.DirectDescendant ) { // Complex selectors are left-leaning, so recurse to the right first as it // is likely the shortest branch. diff --git a/packages/alfa-css/package.json b/packages/alfa-css/package.json index 02386f63e5..cafc73a9b5 100644 --- a/packages/alfa-css/package.json +++ b/packages/alfa-css/package.json @@ -33,7 +33,8 @@ "@siteimprove/alfa-refinement": "workspace:^0.69.0", "@siteimprove/alfa-result": "workspace:^0.69.0", "@siteimprove/alfa-selective": "workspace:^0.69.0", - "@siteimprove/alfa-slice": "workspace:^0.69.0" + "@siteimprove/alfa-slice": "workspace:^0.69.0", + "@siteimprove/alfa-thunk": "workspace:^0.69.0" }, "devDependencies": { "@siteimprove/alfa-test": "workspace:^0.69.0" diff --git a/packages/alfa-css/src/syntax/function.ts b/packages/alfa-css/src/syntax/function.ts index c8b6543854..43828f6e00 100644 --- a/packages/alfa-css/src/syntax/function.ts +++ b/packages/alfa-css/src/syntax/function.ts @@ -6,6 +6,7 @@ import { Err, Result } from "@siteimprove/alfa-result"; import { Slice } from "@siteimprove/alfa-slice"; import * as json from "@siteimprove/alfa-json"; +import { Thunk } from "@siteimprove/alfa-thunk"; import { Component } from "./component"; import type { Parser as CSSParser } from "./parser"; @@ -122,7 +123,7 @@ export namespace Function { export const parse = ( query?: string | Predicate, - body?: CSSParser, + body?: CSSParser | Thunk>, ) => flatMap( right(peek(Token.parseFunction(query)), Function.consume), @@ -131,10 +132,26 @@ export namespace Function { return Result.of([input, [fn, undefined as never] as const]); } + // Sadly, JS alone is not capable of differentiating one function from + // another. So, at run time we can't differentiate a parser from a + // thunk. + // We have to rely on exception to handle that. + let parse: CSSParser; + try { + parse = (body as Thunk>)(); + // In the off case where `body` is a parser that never looks at its + // input, the previous call might not throw. + if (Result.isResult(parse)) { + throw new Error("It was a parser after all"); + } + } catch (err) { + parse = body as CSSParser; + } + const result = delimited( // whitespace just inside the parentheses are OK. option(Token.parseWhitespace), - body, + parse, )(Slice.of(fn.value)); if (result.isErr()) { diff --git a/packages/alfa-css/tsconfig.json b/packages/alfa-css/tsconfig.json index 4c7b2f88c6..c26f14d013 100644 --- a/packages/alfa-css/tsconfig.json +++ b/packages/alfa-css/tsconfig.json @@ -163,6 +163,7 @@ { "path": "../alfa-result" }, { "path": "../alfa-selective" }, { "path": "../alfa-slice" }, - { "path": "../alfa-test" } + { "path": "../alfa-test" }, + { "path": "../alfa-thunk" } ] } diff --git a/packages/alfa-selector/package.json b/packages/alfa-selector/package.json index 2fe04bb4b5..1564c03494 100644 --- a/packages/alfa-selector/package.json +++ b/packages/alfa-selector/package.json @@ -29,9 +29,9 @@ "@siteimprove/alfa-option": "workspace:^0.69.0", "@siteimprove/alfa-parser": "workspace:^0.69.0", "@siteimprove/alfa-predicate": "workspace:^0.69.0", - "@siteimprove/alfa-result": "workspace:^0.69.0", "@siteimprove/alfa-sequence": "workspace:^0.69.0", - "@siteimprove/alfa-slice": "workspace:^0.69.0" + "@siteimprove/alfa-slice": "workspace:^0.69.0", + "@siteimprove/alfa-thunk": "workspace:^0.69.0" }, "devDependencies": { "@siteimprove/alfa-test": "workspace:^0.69.0" diff --git a/packages/alfa-selector/src/selector.ts b/packages/alfa-selector/src/selector.ts deleted file mode 100644 index 37610216b2..0000000000 --- a/packages/alfa-selector/src/selector.ts +++ /dev/null @@ -1,2755 +0,0 @@ -import { Array } from "@siteimprove/alfa-array"; -import { Cache } from "@siteimprove/alfa-cache/src/cache"; -import { Function, Nth, Token } from "@siteimprove/alfa-css"; -import * as dom from "@siteimprove/alfa-dom"; -import { Element, Node } from "@siteimprove/alfa-dom"; -import { Equatable } from "@siteimprove/alfa-equatable"; -import { Iterable } from "@siteimprove/alfa-iterable"; -import * as json from "@siteimprove/alfa-json"; -import { Serializable } from "@siteimprove/alfa-json"; -import { None, Option } from "@siteimprove/alfa-option"; -import { Parser } from "@siteimprove/alfa-parser"; -import { Predicate } from "@siteimprove/alfa-predicate"; -import { Err, Result } from "@siteimprove/alfa-result"; -import { Sequence } from "@siteimprove/alfa-sequence/src/sequence"; -import { Slice } from "@siteimprove/alfa-slice"; - -import { Context } from "./context"; -import State = Context.State; - -const { - delimited, - either, - end, - flatMap, - left, - map, - mapResult, - oneOrMore, - option, - pair, - peek, - right, - separatedList, - take, - takeBetween, - zeroOrMore, -} = Parser; - -const { and, equals, not, property, test } = Predicate; -const { isElement, hasName } = Element; - -/** - * {@link https://drafts.csswg.org/selectors/#selector} - * - * @public - */ -export type Selector = - | Selector.Simple - | Selector.Compound - | Selector.Complex - | Selector.Relative - | Selector.List; - -/** - * @public - */ -export namespace Selector { - export interface JSON { - [key: string]: json.JSON; - type: T; - } - - abstract class Selector - implements - Iterable, - Equatable, - Serializable - { - public abstract get type(): T; - - /** - * {@link https://drafts.csswg.org/selectors/#match} - */ - public abstract matches(element: Element, context?: Context): boolean; - - public abstract equals(value: Selector): boolean; - - public abstract equals(value: unknown): value is this; - - public abstract [Symbol.iterator](): Iterator< - Simple | Compound | Complex | Relative - >; - - public abstract toJSON(): JSON; - } - - /** - * @remarks - * The selector parser is forward-declared as it is needed within its - * subparsers. - */ - let parseSelector: Parser< - Slice, - Simple | Compound | Complex | List, - string - >; - - /** - * {@link https://drafts.csswg.org/selectors/#id-selector} - */ - export class Id extends Selector<"id"> { - public static of(name: string): Id { - return new Id(name); - } - - private readonly _name: string; - - private constructor(name: string) { - super(); - this._name = name; - } - - public get name(): string { - return this._name; - } - - public get type(): "id" { - return "id"; - } - - public matches(element: Element): boolean { - return element.id.includes(this._name); - } - - public equals(value: Id): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Id && value._name === this._name; - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Id.JSON { - return { - type: "id", - name: this._name, - }; - } - - public toString(): string { - return `#${this._name}`; - } - } - - export namespace Id { - export interface JSON extends Selector.JSON<"id"> { - name: string; - } - } - - export function isId(value: unknown): value is Id { - return value instanceof Id; - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-id-selector} - */ - const parseId = map( - Token.parseHash((hash) => hash.isIdentifier), - (hash) => Id.of(hash.value), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#class-selector} - */ - export class Class extends Selector<"class"> { - public static of(name: string): Class { - return new Class(name); - } - - private readonly _name: string; - - private constructor(name: string) { - super(); - this._name = name; - } - - public get name(): string { - return this._name; - } - - public get type(): "class" { - return "class"; - } - public matches(element: Element): boolean { - return Iterable.includes(element.classes, this._name); - } - - public equals(value: Class): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): value is boolean { - return value instanceof Class && value._name === this._name; - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Class.JSON { - return { - type: "class", - name: this._name, - }; - } - - public toString(): string { - return `.${this._name}`; - } - } - - export namespace Class { - export interface JSON extends Selector.JSON<"class"> { - name: string; - } - } - - export function isClass(value: unknown): value is Class { - return value instanceof Class; - } - - const parseClass = map( - right(Token.parseDelim("."), Token.parseIdent()), - (ident) => Class.of(ident.value), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-ns-prefix} - */ - const parseNamespace = map( - left( - option(either(Token.parseIdent(), Token.parseDelim("*"))), - Token.parseDelim("|"), - ), - (token) => token.map((token) => token.toString()).getOr(""), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-wq-name} - */ - const parseName = pair( - option(parseNamespace), - map(Token.parseIdent(), (ident) => ident.value), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#attribute-selector} - */ - export class Attribute extends Selector<"attribute"> { - public static of( - namespace: Option, - name: string, - value: Option = None, - matcher: Option = None, - modifier: Option = None, - ): Attribute { - return new Attribute(namespace, name, value, matcher, modifier); - } - - private readonly _namespace: Option; - private readonly _name: string; - private readonly _value: Option; - private readonly _matcher: Option; - private readonly _modifier: Option; - - private constructor( - namespace: Option, - name: string, - value: Option, - matcher: Option, - modifier: Option, - ) { - super(); - this._namespace = namespace; - this._name = name; - this._value = value; - this._matcher = matcher; - this._modifier = modifier; - } - - public get namespace(): Option { - return this._namespace; - } - - public get type(): "attribute" { - return "attribute"; - } - - public get name(): string { - return this._name; - } - - public get value(): Option { - return this._value; - } - - public get matcher(): Option { - return this._matcher; - } - - public get modifier(): Option { - return this._modifier; - } - - public matches(element: Element): boolean { - for (const namespace of this._namespace) { - let predicate: Predicate; - - switch (namespace) { - case "*": - predicate = property("name", equals(this._name)); - break; - - case "": - predicate = and( - property("name", equals(this._name)), - property("namespace", equals(None)), - ); - break; - - default: - predicate = and( - property("name", equals(this._name)), - property("namespace", equals(namespace)), - ); - } - - return Iterable.some( - element.attributes, - and(predicate, (attribute) => this.matchesValue(attribute.value)), - ); - } - - return element - .attribute(this._name) - .some((attribute) => this.matchesValue(attribute.value)); - } - - private matchesValue(value: string): boolean { - for (const modifier of this._modifier) { - switch (modifier) { - case Attribute.Modifier.CaseInsensitive: - value = value.toLowerCase(); - } - } - - for (const match of this._value) { - switch (this._matcher.getOr(Attribute.Matcher.Equal)) { - case Attribute.Matcher.Equal: - return value === match; - - case Attribute.Matcher.Prefix: - return value.startsWith(match); - - case Attribute.Matcher.Suffix: - return value.endsWith(match); - - case Attribute.Matcher.Substring: - return value.includes(match); - - case Attribute.Matcher.DashMatch: - return value === match || value.startsWith(`${match}-`); - - case Attribute.Matcher.Includes: - return value.split(/\s+/).some(equals(match)); - } - } - - return true; - } - - public equals(value: Attribute): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Attribute && - value._namespace.equals(this._namespace) && - value._name === this._name && - value._value.equals(this._value) && - value._matcher.equals(this._matcher) && - value._modifier.equals(this._modifier) - ); - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Attribute.JSON { - return { - type: "attribute", - namespace: this._namespace.getOr(null), - name: this._name, - value: this._value.getOr(null), - matcher: this._matcher.getOr(null), - modifier: this._modifier.getOr(null), - }; - } - - public toString(): string { - const namespace = this._namespace - .map((namespace) => `${namespace}|`) - .getOr(""); - - const value = this._value - .map((value) => `"${JSON.stringify(value)}"`) - .getOr(""); - - const matcher = this._matcher.getOr(""); - - const modifier = this._modifier - .map((modifier) => ` ${modifier}`) - .getOr(""); - - return `[${namespace}${this._name}${matcher}${value}${modifier}]`; - } - } - - export namespace Attribute { - export interface JSON extends Selector.JSON<"attribute"> { - namespace: string | null; - name: string; - value: string | null; - matcher: string | null; - modifier: string | null; - } - - export enum Matcher { - /** - * @example [foo=bar] - */ - Equal = "=", - - /** - * @example [foo~=bar] - */ - Includes = "~=", - - /** - * @example [foo|=bar] - */ - DashMatch = "|=", - - /** - * @example [foo^=bar] - */ - Prefix = "^=", - - /** - * @example [foo$=bar] - */ - Suffix = "$=", - - /** - * @example [foo*=bar] - */ - Substring = "*=", - } - - export enum Modifier { - /** - * @example [foo=bar i] - */ - CaseInsensitive = "i", - - /** - * @example [foo=Bar s] - */ - CaseSensitive = "s", - } - } - - export function isAttribute(value: unknown): value is Attribute { - return value instanceof Attribute; - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-attr-matcher} - */ - const parseMatcher = map( - left( - option( - either( - Token.parseDelim("~"), - either( - Token.parseDelim("|"), - either( - Token.parseDelim("^"), - either(Token.parseDelim("$"), Token.parseDelim("*")), - ), - ), - ), - ), - Token.parseDelim("="), - ), - (delim) => - delim.isSome() - ? (`${delim.get()}=` as Attribute.Matcher) - : Attribute.Matcher.Equal, - ); - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-attr-modifier} - */ - const parseModifier = either( - map(Token.parseIdent("i"), () => Attribute.Modifier.CaseInsensitive), - map(Token.parseIdent("s"), () => Attribute.Modifier.CaseSensitive), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-attribute-selector} - */ - const parseAttribute = map( - delimited( - Token.parseOpenSquareBracket, - pair( - parseName, - option( - pair( - pair(parseMatcher, either(Token.parseString(), Token.parseIdent())), - delimited(option(Token.parseWhitespace), option(parseModifier)), - ), - ), - ), - Token.parseCloseSquareBracket, - ), - (result) => { - const [[namespace, name], rest] = result; - - if (rest.isSome()) { - const [[matcher, value], modifier] = rest.get(); - - return Attribute.of( - namespace, - name, - Option.of(value.value), - Option.of(matcher), - modifier, - ); - } - - return Attribute.of(namespace, name); - }, - ); - - /** - * {@link https://drafts.csswg.org/selectors/#type-selector} - */ - export class Type extends Selector<"type"> { - public static of(namespace: Option, name: string): Type { - return new Type(namespace, name); - } - - private readonly _namespace: Option; - private readonly _name: string; - - private constructor(namespace: Option, name: string) { - super(); - this._namespace = namespace; - this._name = name; - } - - public get namespace(): Option { - return this._namespace; - } - - public get name(): string { - return this._name; - } - - public get type(): "type" { - return "type"; - } - - public matches(element: Element): boolean { - if (this._name !== element.name) { - return false; - } - - if (this._namespace.isNone() || this._namespace.includes("*")) { - return true; - } - - return element.namespace.equals(this._namespace); - } - - public equals(value: Type): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Type && - value._namespace.equals(this._namespace) && - value._name === this._name - ); - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Type.JSON { - return { - type: "type", - namespace: this._namespace.getOr(null), - name: this._name, - }; - } - - public toString(): string { - const namespace = this._namespace - .map((namespace) => `${namespace}|`) - .getOr(""); - - return `${namespace}${this._name}`; - } - } - - export namespace Type { - export interface JSON extends Selector.JSON<"type"> { - namespace: string | null; - name: string; - } - } - - export function isType(value: unknown): value is Type { - return value instanceof Type; - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-type-selector} - */ - const parseType = map(parseName, ([namespace, name]) => - Type.of(namespace, name), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#universal-selector} - */ - export class Universal extends Selector<"universal"> { - public static of(namespace: Option): Universal { - return new Universal(namespace); - } - - private static readonly _empty = new Universal(None); - - public static empty(): Universal { - return this._empty; - } - - private readonly _namespace: Option; - - private constructor(namespace: Option) { - super(); - this._namespace = namespace; - } - - public get namespace(): Option { - return this._namespace; - } - - public get type(): "universal" { - return "universal"; - } - - public matches(element: Element): boolean { - if (this._namespace.isNone() || this._namespace.includes("*")) { - return true; - } - - return element.namespace.equals(this._namespace); - } - - public equals(value: Universal): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Universal && value._namespace.equals(this._namespace) - ); - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Universal.JSON { - return { - type: "universal", - namespace: this._namespace.getOr(null), - }; - } - - public toString(): string { - const namespace = this._namespace - .map((namespace) => `${namespace}|`) - .getOr(""); - - return `${namespace}*`; - } - } - - export namespace Universal { - export interface JSON extends Selector.JSON<"universal"> { - namespace: string | null; - } - } - - function isUniversal(value: unknown): value is Universal { - return value instanceof Universal; - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-type-selector} - */ - const parseUniversal = map( - left(option(parseNamespace), Token.parseDelim("*")), - (namespace) => Universal.of(namespace), - ); - - export namespace Pseudo { - export type JSON = Class.JSON | Element.JSON; - - export abstract class Class< - N extends string = string, - > extends Selector<"pseudo-class"> { - protected readonly _name: N; - - protected constructor(name: N) { - super(); - this._name = name; - } - - public get name(): N { - return this._name; - } - - public get type(): "pseudo-class" { - return "pseudo-class"; - } - - public matches(element: dom.Element, context?: Context): boolean { - return false; - } - - public equals(value: Class): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Class && value._name === this._name; - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Class.JSON { - return { - type: "pseudo-class", - name: this._name, - }; - } - - public toString(): string { - return `:${this._name}`; - } - } - - export namespace Class { - export interface JSON - extends Selector.JSON<"pseudo-class"> { - name: N; - } - } - - export function isClass(value: unknown): value is Class { - return value instanceof Class; - } - - export abstract class Element< - N extends string = string, - > extends Selector<"pseudo-element"> { - protected readonly _name: N; - - protected constructor(name: N) { - super(); - this._name = name; - } - - public get name(): N { - return this._name; - } - - public get type(): "pseudo-element" { - return "pseudo-element"; - } - - public matches(element: dom.Element, context?: Context): boolean { - return false; - } - - public equals(value: Element): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Element && value._name === this._name; - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Element.JSON { - return { - type: "pseudo-element", - name: this._name, - }; - } - - public toString(): string { - return `::${this._name}`; - } - } - - export namespace Element { - export interface JSON - extends Selector.JSON<"pseudo-element"> { - name: N; - } - } - - export function isElement(value: unknown): value is Element { - return value instanceof Element; - } - } - - export type Pseudo = Pseudo.Class | Pseudo.Element; - - export const { isClass: isPseudoClass, isElement: isPseudoElement } = Pseudo; - - export function isPseudo(value: unknown): value is Pseudo { - return isPseudoClass(value) || isPseudoElement(value); - } - - const parseNth = left( - Nth.parse, - end((token) => `Unexpected token ${token}`), - ); - - const parsePseudoClass = right( - Token.parseColon, - either( - // Non-functional pseudo-classes - mapResult(Token.parseIdent(), (ident) => { - switch (ident.value) { - case "hover": - return Result.of(Hover.of() as Pseudo.Class); - case "active": - return Result.of(Active.of()); - case "focus": - return Result.of(Focus.of()); - case "focus-within": - return Result.of(FocusWithin.of()); - case "focus-visible": - return Result.of(FocusVisible.of()); - case "link": - return Result.of(Link.of()); - case "visited": - return Result.of(Visited.of()); - case "disabled": - return Result.of(Disabled.of()); - case "enabled": - return Result.of(Enabled.of()); - case "root": - return Result.of(Root.of()); - case "host": - return Result.of(Host.of()); - case "empty": - return Result.of(Empty.of()); - case "first-child": - return Result.of(FirstChild.of()); - case "last-child": - return Result.of(LastChild.of()); - case "only-child": - return Result.of(OnlyChild.of()); - case "first-of-type": - return Result.of(FirstOfType.of()); - case "last-of-type": - return Result.of(LastOfType.of()); - case "only-of-type": - return Result.of(OnlyOfType.of()); - } - - return Err.of(`Unknown pseudo-class :${ident.value}`); - }), - - // Funtional pseudo-classes - mapResult(right(peek(Token.parseFunction()), Function.consume), (fn) => { - const { name } = fn; - const tokens = Slice.of(fn.value); - - switch (name) { - // :() - // :has() normally only accepts relative selectors, we currently - // accept all. - case "is": - case "not": - case "has": - return parseSelector(tokens).map(([, selector]) => { - switch (name) { - case "is": - return Is.of(selector) as Pseudo.Class; - case "not": - return Not.of(selector); - case "has": - return Has.of(selector); - } - }); - - // :() - case "nth-child": - case "nth-last-child": - case "nth-of-type": - case "nth-last-of-type": - return parseNth(tokens).map(([, nth]) => { - switch (name) { - case "nth-child": - return NthChild.of(nth); - case "nth-last-child": - return NthLastChild.of(nth); - case "nth-of-type": - return NthOfType.of(nth); - case "nth-last-of-type": - return NthLastOfType.of(nth); - } - }); - } - - return Err.of(`Unknown pseudo-class :${fn.name}()`); - }), - ), - ); - - const parsePseudoElement = either( - // Functional pseudo-elements need to be first because ::cue and - // ::cue-region can be both functional and non-functional, so we want to - // fail them as functional before testing them as non-functional. - right( - take(Token.parseColon, 2), - mapResult(right(peek(Token.parseFunction()), Function.consume), (fn) => { - const { name } = fn; - const tokens = Slice.of(fn.value); - - switch (name) { - case "cue": - case "cue-region": - return parseSelector(tokens).map(([, selector]) => - name === "cue" - ? (Cue.of(selector) as Pseudo.Element) - : CueRegion.of(selector), - ); - - case "part": - return separatedList( - Token.parseIdent(), - Token.parseWhitespace, - )(tokens).map(([, idents]) => Part.of(idents)); - - case "slotted": - return separatedList( - parseCompound, - Token.parseWhitespace, - )(tokens).map(([, selectors]) => Slotted.of(selectors)); - } - - return Err.of(`Unknown pseudo-element ::${name}()`); - }), - ), - // Non-functional pseudo-elements - flatMap( - map(takeBetween(Token.parseColon, 1, 2), (colons) => colons.length), - (colons) => - mapResult(Token.parseIdent(), (ident) => { - if (colons === 1) { - switch (ident.value) { - // Legacy pseudo-elements must be accepted with both a single and - // double colon. - case "after": - case "before": - case "first-letter": - case "first-line": - break; - - default: - return Err.of( - `This pseudo-element is not allowed with single colon: ::${ident.value}`, - ); - } - } - - switch (ident.value) { - case "after": - return Result.of(After.of() as Pseudo.Element); - case "backdrop": - return Result.of(Backdrop.of()); - case "before": - return Result.of(Before.of()); - case "cue": - return Result.of(Cue.of()); - case "cue-region": - return Result.of(CueRegion.of()); - case "file-selector-button": - return Result.of(FileSelectorButton.of()); - case "first-letter": - return Result.of(FirstLetter.of()); - case "first-line": - return Result.of(FirstLine.of()); - case "grammar-error": - return Result.of(GrammarError.of()); - case "marker": - return Result.of(Marker.of()); - case "placeholder": - return Result.of(Placeholder.of()); - case "selection": - return Result.of(Selection.of()); - case "spelling-error": - return Result.of(SpellingError.of()); - case "target-text": - return Result.of(TargetText.of()); - } - - return Err.of(`Unknown pseudo-element ::${ident.value}`); - }), - ), - ); - - const parsePseudo = either(parsePseudoClass, parsePseudoElement); - - /** - * {@link https://drafts.csswg.org/selectors/#matches-pseudo} - */ - export class Is extends Pseudo.Class<"is"> { - public static of( - selector: Simple | Compound | Complex | List, - ): Is { - return new Is(selector); - } - - private readonly _selector: - | Simple - | Compound - | Complex - | List; - - private constructor( - selector: Simple | Compound | Complex | List, - ) { - super("is"); - this._selector = selector; - } - - public get selector(): - | Simple - | Compound - | Complex - | List { - return this._selector; - } - - public matches(element: Element, context?: Context): boolean { - return this._selector.matches(element, context); - } - - public equals(value: Is): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Is && value._selector.equals(this._selector); - } - - public toJSON(): Is.JSON { - return { - ...super.toJSON(), - selector: this._selector.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._selector})`; - } - } - - export namespace Is { - export interface JSON extends Pseudo.Class.JSON<"is"> { - selector: Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#negation-pseudo} - */ - export class Not extends Pseudo.Class<"not"> { - public static of( - selector: Simple | Compound | Complex | List, - ): Not { - return new Not(selector); - } - - private readonly _selector: - | Simple - | Compound - | Complex - | List; - - private constructor( - selector: Simple | Compound | Complex | List, - ) { - super("not"); - this._selector = selector; - } - - public get selector(): - | Simple - | Compound - | Complex - | List { - return this._selector; - } - - public matches(element: Element, context?: Context): boolean { - return !this._selector.matches(element, context); - } - - public equals(value: Not): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Not && value._selector.equals(this._selector); - } - - public toJSON(): Not.JSON { - return { - ...super.toJSON(), - selector: this._selector.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._selector})`; - } - } - - export namespace Not { - export interface JSON extends Pseudo.Class.JSON<"not"> { - selector: Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#has-pseudo} - */ - export class Has extends Pseudo.Class<"has"> { - public static of( - selector: Simple | Compound | Complex | List, - ): Has { - return new Has(selector); - } - - private readonly _selector: - | Simple - | Compound - | Complex - | List; - - private constructor( - selector: Simple | Compound | Complex | List, - ) { - super("has"); - this._selector = selector; - } - - public get selector(): - | Simple - | Compound - | Complex - | List { - return this._selector; - } - - public equals(value: Has): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Has && value._selector.equals(this._selector); - } - - public toJSON(): Has.JSON { - return { - ...super.toJSON(), - selector: this._selector.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._selector})`; - } - } - - export namespace Has { - export interface JSON extends Pseudo.Class.JSON<"has"> { - selector: Simple.JSON | Compound.JSON | Complex.JSON | List.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#hover-pseudo} - */ - export class Hover extends Pseudo.Class<"hover"> { - public static of(): Hover { - return new Hover(); - } - - private constructor() { - super("hover"); - } - - private static _cache = Cache.empty>(); - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - return Hover._cache.get(element, Cache.empty).get(context, () => { - // We assume that most of the time the context is near empty and thus it - // is inexpensive to check if something is in it. - const hovered = Sequence.from(context.withState(State.Hover)); - - return ( - hovered.size !== 0 && - element - .inclusiveDescendants(Node.fullTree) - .some((descendant) => hovered.includes(descendant)) - ); - }); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#active-pseudo} - */ - export class Active extends Pseudo.Class<"active"> { - public static of(): Active { - return new Active(); - } - - private constructor() { - super("active"); - } - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - return context.isActive(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#focus-pseudo} - */ - export class Focus extends Pseudo.Class<"focus"> { - public static of(): Focus { - return new Focus(); - } - - private constructor() { - super("focus"); - } - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - return context.isFocused(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#focus-within-pseudo} - */ - export class FocusWithin extends Pseudo.Class<"focus-within"> { - public static of(): FocusWithin { - return new FocusWithin(); - } - - private constructor() { - super("focus-within"); - } - - private static _cache = Cache.empty>(); - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - return FocusWithin._cache.get(element, Cache.empty).get(context, () => { - // We assume that most of the time the context is near empty and thus it - // is inexpensive to check if something is in it. - const focused = Sequence.from(context.withState(State.Focus)); - - return ( - focused.size !== 0 && - element - .inclusiveDescendants(Node.fullTree) - .some((descendant) => focused.includes(descendant)) - ); - }); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#the-focus-visible-pseudo} - */ - export class FocusVisible extends Pseudo.Class<"focus-visible"> { - public static of(): FocusVisible { - return new FocusVisible(); - } - - private constructor() { - super("focus-visible"); - } - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - // :focus-visible matches elements that are focused and where UA decides - // focus should be shown. That is notably text fields and keyboard-focused - // elements (some UA don't show focus indicator on mouse-focused elements) - // For the purposes of accessibility testing, we currently assume that - // we always want to look at a mode where the focus is visible. This is - // notably due to the fact that it is a UA decision, and therefore not - // a problem for the authors to fix if done incorrectly. - return context.isFocused(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#link-pseudo} - */ - export class Link extends Pseudo.Class<"link"> { - public static of(): Link { - return new Link(); - } - - private constructor() { - super("link"); - } - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - switch (element.name) { - case "a": - case "area": - case "link": - return element - .attribute("href") - .some(() => !context.hasState(element, Context.State.Visited)); - } - - return false; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#visited-pseudo} - */ - export class Visited extends Pseudo.Class<"visited"> { - public static of(): Visited { - return new Visited(); - } - - private constructor() { - super("visited"); - } - - public matches( - element: Element, - context: Context = Context.empty(), - ): boolean { - switch (element.name) { - case "a": - case "area": - case "link": - return element - .attribute("href") - .some(() => context.hasState(element, Context.State.Visited)); - } - - return false; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#enableddisabled} - * {@link https://html.spec.whatwg.org/multipage#selector-disabled} - */ - export class Disabled extends Pseudo.Class<"disabled"> { - public static of(): Disabled { - return new Disabled(); - } - - private constructor() { - super("disabled"); - } - - public matches( - element: dom.Element, - context: Context = Context.empty(), - ): boolean { - return Element.isActuallyDisabled(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#enableddisabled} - * {@link https://html.spec.whatwg.org/multipage#selector-enabled} - */ - export class Enabled extends Pseudo.Class<"enabled"> { - public static of(): Enabled { - return new Enabled(); - } - - private constructor() { - super("enabled"); - } - - public matches( - element: dom.Element, - context: Context = Context.empty(), - ): boolean { - return test( - and( - hasName( - "button", - "input", - "select", - "textarea", - "optgroup", - "option", - "fieldset", - ), - not(Element.isActuallyDisabled), - ), - element, - ); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#root-pseudo} - */ - export class Root extends Pseudo.Class<"root"> { - public static of(): Root { - return new Root(); - } - - private constructor() { - super("root"); - } - - public matches(element: Element): boolean { - // The root element is the element whose parent is NOT itself an element. - return element.parent().every(not(isElement)); - } - } - - /** - * {@link https://drafts.csswg.org/css-scoping-1/#selectordef-host} - */ - export class Host extends Pseudo.Class<"host"> { - public static of(): Host { - return new Host(); - } - - private constructor() { - super("host"); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#empty-pseudo} - */ - export class Empty extends Pseudo.Class<"empty"> { - public static of(): Empty { - return new Empty(); - } - - private constructor() { - super("empty"); - } - - public matches(element: Element): boolean { - return element.children().isEmpty(); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#nth-child-pseudo} - */ - export class NthChild extends Pseudo.Class<"nth-child"> { - public static of(index: Nth): NthChild { - return new NthChild(index); - } - - private static readonly _indices = new WeakMap(); - - private readonly _index: Nth; - - private constructor(index: Nth) { - super("nth-child"); - - this._index = index; - } - - public matches(element: Element): boolean { - const indices = NthChild._indices; - - if (!indices.has(element)) { - element - .inclusiveSiblings() - .filter(isElement) - .forEach((element, i) => { - indices.set(element, i + 1); - }); - } - - return this._index.matches(indices.get(element)!); - } - - public equals(value: NthChild): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof NthChild && value._index.equals(this._index); - } - - public toJSON(): NthChild.JSON { - return { - ...super.toJSON(), - index: this._index.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._index})`; - } - } - - export namespace NthChild { - export interface JSON extends Pseudo.Class.JSON<"nth-child"> { - index: Nth.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#nth-last-child-pseudo} - */ - export class NthLastChild extends Pseudo.Class<"nth-last-child"> { - public static of(index: Nth): NthLastChild { - return new NthLastChild(index); - } - - private static readonly _indices = new WeakMap(); - - private readonly _index: Nth; - - private constructor(nth: Nth) { - super("nth-last-child"); - - this._index = nth; - } - - public matches(element: Element): boolean { - const indices = NthLastChild._indices; - - if (!indices.has(element)) { - element - .inclusiveSiblings() - .filter(isElement) - .reverse() - .forEach((element, i) => { - indices.set(element, i + 1); - }); - } - - return this._index.matches(indices.get(element)!); - } - - public equals(value: NthLastChild): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof NthLastChild && value._index.equals(this._index); - } - - public toJSON(): NthLastChild.JSON { - return { - ...super.toJSON(), - index: this._index.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._index})`; - } - } - - export namespace NthLastChild { - export interface JSON extends Pseudo.Class.JSON<"nth-last-child"> { - index: Nth.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#first-child-pseudo} - */ - export class FirstChild extends Pseudo.Class<"first-child"> { - public static of(): FirstChild { - return new FirstChild(); - } - - private constructor() { - super("first-child"); - } - - public matches(element: Element): boolean { - return element - .inclusiveSiblings() - .filter(isElement) - .first() - .includes(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#last-child-pseudo} - */ - export class LastChild extends Pseudo.Class<"last-child"> { - public static of(): LastChild { - return new LastChild(); - } - - private constructor() { - super("last-child"); - } - - public matches(element: Element): boolean { - return element - .inclusiveSiblings() - .filter(isElement) - .last() - .includes(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#only-child-pseudo} - */ - export class OnlyChild extends Pseudo.Class<"only-child"> { - public static of(): OnlyChild { - return new OnlyChild(); - } - - private constructor() { - super("only-child"); - } - - public matches(element: Element): boolean { - return element.inclusiveSiblings().filter(isElement).size === 1; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#nth-of-type-pseudo} - */ - export class NthOfType extends Pseudo.Class<"nth-of-type"> { - public static of(index: Nth): NthOfType { - return new NthOfType(index); - } - - private static readonly _indices = new WeakMap(); - - private readonly _index: Nth; - - private constructor(index: Nth) { - super("nth-of-type"); - - this._index = index; - } - - public matches(element: Element): boolean { - const indices = NthOfType._indices; - - if (!indices.has(element)) { - element - .inclusiveSiblings() - .filter(isElement) - .filter(hasName(element.name)) - .forEach((element, i) => { - indices.set(element, i + 1); - }); - } - - return this._index.matches(indices.get(element)!); - } - - public equals(value: NthOfType): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof NthOfType && value._index.equals(this._index); - } - - public toJSON(): NthOfType.JSON { - return { - ...super.toJSON(), - index: this._index.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._index})`; - } - } - - export namespace NthOfType { - export interface JSON extends Pseudo.Class.JSON<"nth-of-type"> { - index: Nth.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#nth-last-of-type-pseudo} - */ - export class NthLastOfType extends Pseudo.Class<"nth-last-of-type"> { - public static of(index: Nth): NthLastOfType { - return new NthLastOfType(index); - } - - private static readonly _indices = new WeakMap(); - - private readonly _index: Nth; - - private constructor(index: Nth) { - super("nth-last-of-type"); - - this._index = index; - } - - public matches(element: Element): boolean { - const indices = NthLastOfType._indices; - - if (!indices.has(element)) { - element - .inclusiveSiblings() - .filter(isElement) - .filter(hasName(element.name)) - .reverse() - .forEach((element, i) => { - indices.set(element, i + 1); - }); - } - - return this._index.matches(indices.get(element)!); - } - - public equals(value: NthLastOfType): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof NthLastOfType && value._index.equals(this._index); - } - - public toJSON(): NthLastOfType.JSON { - return { - ...super.toJSON(), - index: this._index.toJSON(), - }; - } - - public toString(): string { - return `:${this.name}(${this._index})`; - } - } - - export namespace NthLastOfType { - export interface JSON extends Pseudo.Class.JSON<"nth-last-of-type"> { - index: Nth.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#first-of-type-pseudo} - */ - export class FirstOfType extends Pseudo.Class<"first-of-type"> { - public static of(): FirstOfType { - return new FirstOfType(); - } - - private constructor() { - super("first-of-type"); - } - - public matches(element: Element): boolean { - return element - .inclusiveSiblings() - .filter(isElement) - .filter(hasName(element.name)) - .first() - .includes(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#last-of-type-pseudo} - */ - export class LastOfType extends Pseudo.Class<"last-of-type"> { - public static of(): LastOfType { - return new LastOfType(); - } - - private constructor() { - super("last-of-type"); - } - - public matches(element: Element): boolean { - return element - .inclusiveSiblings() - .filter(isElement) - .filter(hasName(element.name)) - .last() - .includes(element); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#only-of-type-pseudo} - */ - export class OnlyOfType extends Pseudo.Class<"only-of-type"> { - public static of(): OnlyOfType { - return new OnlyOfType(); - } - - private constructor() { - super("only-of-type"); - } - - public matches(element: Element): boolean { - return ( - element - .inclusiveSiblings() - .filter(isElement) - .filter(hasName(element.name)).size === 1 - ); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo/#selectordef-after} - */ - export class After extends Pseudo.Element<"after"> { - public static of(): After { - return new After(); - } - - private constructor() { - super("after"); - } - } - - /** - * {@link https://fullscreen.spec.whatwg.org/#::backdrop-pseudo-element} - */ - export class Backdrop extends Pseudo.Element<"backdrop"> { - public static of(): Backdrop { - return new Backdrop(); - } - - private constructor() { - super("backdrop"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo/#selectordef-before} - */ - export class Before extends Pseudo.Element<"before"> { - public static of(): Before { - return new Before(); - } - - private constructor() { - super("before"); - } - } - - /** - * {@link https://w3c.github.io/webvtt/#the-cue-pseudo-element} - */ - class Cue extends Pseudo.Element<"cue"> { - public static of(selector?: Selector): Cue { - return new Cue(Option.from(selector)); - } - - private readonly _selector: Option; - - private constructor(selector: Option) { - super("cue"); - this._selector = selector; - } - - public get selector(): Option { - return this._selector; - } - - public equals(value: Cue): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Cue && value.selector.equals(this.selector); - } - - public toJSON(): Cue.JSON { - return { - ...super.toJSON(), - selector: this._selector.toJSON(), - }; - } - - public toString(): string { - return `::${this.name}` + this._selector.isSome() - ? `(${this._selector})` - : ""; - } - } - - export namespace Cue { - export interface JSON extends Pseudo.Element.JSON<"cue"> { - selector: Option.JSON; - } - } - - /** - * {@link https://w3c.github.io/webvtt/#the-cue-region-pseudo-element} - */ - class CueRegion extends Pseudo.Element<"cue-region"> { - public static of(selector?: Selector): CueRegion { - return new CueRegion(Option.from(selector)); - } - - private readonly _selector: Option; - - private constructor(selector: Option) { - super("cue-region"); - this._selector = selector; - } - - public get selector(): Option { - return this._selector; - } - - public equals(value: CueRegion): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof CueRegion && value.selector.equals(this.selector); - } - - public toJSON(): CueRegion.JSON { - return { - ...super.toJSON(), - selector: this._selector.toJSON(), - }; - } - - public toString(): string { - return `::${this.name}` + this._selector.isSome() - ? `(${this._selector})` - : ""; - } - } - - export namespace CueRegion { - export interface JSON extends Pseudo.Element.JSON<"cue-region"> { - selector: Option.JSON; - } - } - - /** - *{@link https://drafts.csswg.org/css-pseudo-4/#file-selector-button-pseudo} - */ - export class FileSelectorButton extends Pseudo.Element<"file-selector-button"> { - public static of(): FileSelectorButton { - return new FileSelectorButton(); - } - - private constructor() { - super("file-selector-button"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#first-letter-pseudo} - */ - export class FirstLetter extends Pseudo.Element<"first-letter"> { - public static of(): FirstLetter { - return new FirstLetter(); - } - - private constructor() { - super("first-letter"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#first-line-pseudo} - */ - export class FirstLine extends Pseudo.Element<"first-line"> { - public static of(): FirstLine { - return new FirstLine(); - } - - private constructor() { - super("first-line"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-grammar-error} - */ - export class GrammarError extends Pseudo.Element<"grammar-error"> { - public static of(): GrammarError { - return new GrammarError(); - } - - private constructor() { - super("grammar-error"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#marker-pseudo} - */ - export class Marker extends Pseudo.Element<"marker"> { - public static of(): Marker { - return new Marker(); - } - - private constructor() { - super("marker"); - } - } - - /** - * {@link https://drafts.csswg.org/css-shadow-parts-1/#part} - */ - export class Part extends Pseudo.Element<"part"> { - public static of(idents: Iterable): Part { - return new Part(Array.from(idents)); - } - - private readonly _idents: ReadonlyArray; - - private constructor(idents: Array) { - super("part"); - this._idents = idents; - } - - public get idents(): Iterable { - return this._idents; - } - - public equals(value: Part): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return value instanceof Part && Array.equals(value._idents, this._idents); - } - - public toJSON(): Part.JSON { - return { - ...super.toJSON(), - idents: Array.toJSON(this._idents), - }; - } - - public toString(): string { - return `::${this.name}(${this._idents})`; - } - } - - export namespace Part { - export interface JSON extends Pseudo.Element.JSON<"part"> { - idents: Array; - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#placeholder-pseudo} - */ - export class Placeholder extends Pseudo.Element<"placeholder"> { - public static of(): Placeholder { - return new Placeholder(); - } - - private constructor() { - super("placeholder"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-selection} - */ - export class Selection extends Pseudo.Element<"selection"> { - public static of(): Selection { - return new Selection(); - } - - private constructor() { - super("selection"); - } - } - - /** - * {@link https://drafts.csswg.org/css-scoping/#slotted-pseudo} - */ - export class Slotted extends Pseudo.Element<"slotted"> { - public static of(selectors: Iterable): Slotted { - return new Slotted(Array.from(selectors)); - } - - private readonly _selectors: ReadonlyArray; - - private constructor(selectors: Array) { - super("slotted"); - this._selectors = selectors; - } - - public get selectors(): Iterable { - return this._selectors; - } - - public equals(value: Slotted): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Slotted && - Array.equals(value._selectors, this._selectors) - ); - } - - public toJSON(): Slotted.JSON { - return { - ...super.toJSON(), - selectors: Array.toJSON(this._selectors), - }; - } - - public toString(): string { - return `::${this.name}(${this._selectors})`; - } - } - - export namespace Slotted { - export interface JSON extends Pseudo.Element.JSON<"slotted"> { - selectors: Array; - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-spelling-error} - */ - export class SpellingError extends Pseudo.Element<"spelling-error"> { - public static of(): SpellingError { - return new SpellingError(); - } - - private constructor() { - super("spelling-error"); - } - } - - /** - * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-target-text} - */ - export class TargetText extends Pseudo.Element<"target-text"> { - public static of(): TargetText { - return new TargetText(); - } - - private constructor() { - super("target-text"); - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#simple} - */ - export type Simple = Type | Universal | Attribute | Class | Id | Pseudo; - - export namespace Simple { - export type JSON = - | Type.JSON - | Universal.JSON - | Attribute.JSON - | Class.JSON - | Id.JSON - | Pseudo.JSON; - } - - export function isSimple(value: unknown): value is Simple { - return ( - isType(value) || - isUniversal(value) || - isAttribute(value) || - isClass(value) || - isId(value) || - isPseudo(value) - ); - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-simple-selector} - */ - const parseSimple = either( - parseClass, - either( - parseType, - either( - parseAttribute, - either(parseId, either(parseUniversal, parsePseudo)), - ), - ), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#compound} - */ - export class Compound extends Selector<"compound"> { - public static of(left: Simple, right: Simple | Compound): Compound { - return new Compound(left, right); - } - - private readonly _left: Simple; - private readonly _right: Simple | Compound; - - private constructor(left: Simple, right: Simple | Compound) { - super(); - this._left = left; - this._right = right; - } - - public get left(): Simple { - return this._left; - } - - public get right(): Simple | Compound { - return this._right; - } - - public get type(): "compound" { - return "compound"; - } - - public matches(element: Element, context?: Context): boolean { - return ( - this._left.matches(element, context) && - this._right.matches(element, context) - ); - } - - public equals(value: Compound): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Compound && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Compound.JSON { - return { - type: "compound", - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - return `${this._left}${this._right}`; - } - } - - export namespace Compound { - export interface JSON extends Selector.JSON<"compound"> { - left: Simple.JSON; - right: Simple.JSON | JSON; - } - } - - export function isCompound(value: unknown): value is Compound { - return value instanceof Compound; - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-compound-selector} - */ - const parseCompound: Parser, Simple | Compound, string> = map( - oneOrMore(parseSimple), - (result) => { - const [left, ...selectors] = Iterable.reverse(result); - - return Iterable.reduce( - selectors, - (right, left) => Compound.of(left, right), - left as Simple | Compound, - ); - }, - ); - - /** - * {@link https://drafts.csswg.org/selectors/#selector-combinator} - */ - export enum Combinator { - /** - * @example div span - */ - Descendant = " ", - - /** - * @example div \> span - */ - DirectDescendant = ">", - - /** - * @example div ~ span - */ - Sibling = "~", - - /** - * @example div + span - */ - DirectSibling = "+", - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-combinator} - */ - const parseCombinator = either( - delimited( - option(Token.parseWhitespace), - either( - map(Token.parseDelim(">"), () => Combinator.DirectDescendant), - either( - map(Token.parseDelim("~"), () => Combinator.Sibling), - map(Token.parseDelim("+"), () => Combinator.DirectSibling), - ), - ), - ), - map(Token.parseWhitespace, () => Combinator.Descendant), - ); - - /** - * {@link https://drafts.csswg.org/selectors/#complex} - */ - export class Complex extends Selector<"complex"> { - public static of( - combinator: Combinator, - left: Simple | Compound | Complex, - right: Simple | Compound, - ): Complex { - return new Complex(combinator, left, right); - } - - private readonly _combinator: Combinator; - private readonly _left: Simple | Compound | Complex; - private readonly _right: Simple | Compound; - - private constructor( - combinator: Combinator, - left: Simple | Compound | Complex, - right: Simple | Compound, - ) { - super(); - this._combinator = combinator; - this._left = left; - this._right = right; - } - - public get combinator(): Combinator { - return this._combinator; - } - - public get left(): Simple | Compound | Complex { - return this._left; - } - - public get right(): Simple | Compound { - return this._right; - } - - public get type(): "complex" { - return "complex"; - } - - public matches(element: Element, context?: Context): boolean { - // First, make sure that the right side of the selector, i.e. the part - // that relates to the current element, matches. - if (this._right.matches(element, context)) { - // If it does, move on to the heavy part of the work: Looking either up - // the tree for a descendant match or looking to the side of the tree - // for a sibling match. - switch (this._combinator) { - case Combinator.Descendant: - return element - .ancestors() - .filter(isElement) - .some((element) => this._left.matches(element, context)); - - case Combinator.DirectDescendant: - return element - .parent() - .filter(isElement) - .some((element) => this._left.matches(element, context)); - - case Combinator.Sibling: - return element - .preceding() - .filter(isElement) - .some((element) => this._left.matches(element, context)); - - case Combinator.DirectSibling: - return element - .preceding() - .find(isElement) - .some((element) => this._left.matches(element, context)); - } - } - - return false; - } - - public equals(value: Complex): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Complex && - value._combinator === this._combinator && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Complex.JSON { - return { - type: "complex", - combinator: this._combinator, - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - const combinator = - this._combinator === Combinator.Descendant - ? " " - : ` ${this._combinator} `; - - return `${this._left}${combinator}${this._right}`; - } - } - - export namespace Complex { - export interface JSON extends Selector.JSON<"complex"> { - combinator: string; - left: Simple.JSON | Compound.JSON | JSON; - right: Simple.JSON | Compound.JSON; - } - } - - export function isComplex(value: unknown): value is Complex { - return value instanceof Complex; - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-complex-selector} - */ - const parseComplex = map( - pair(parseCompound, zeroOrMore(pair(parseCombinator, parseCompound))), - (result) => { - const [left, selectors] = result; - - return Iterable.reduce( - selectors, - (left, [combinator, right]) => Complex.of(combinator, left, right), - left as Simple | Compound | Complex, - ); - }, - ); - - /** - * {@link https://drafts.csswg.org/selectors/#relative-selector} - */ - export class Relative extends Selector<"relative"> { - public static of( - combinator: Combinator, - selector: Simple | Compound | Complex, - ): Relative { - return new Relative(combinator, selector); - } - - private readonly _combinator: Combinator; - private readonly _selector: Simple | Compound | Complex; - - private constructor( - combinator: Combinator, - selector: Simple | Compound | Complex, - ) { - super(); - this._combinator = combinator; - this._selector = selector; - } - - public get combinator(): Combinator { - return this._combinator; - } - - public get selector(): Simple | Compound | Complex { - return this._selector; - } - - public get type(): "relative" { - return "relative"; - } - - public matches(): boolean { - return false; - } - - public equals(value: Relative): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof Relative && - value._combinator === this._combinator && - value._selector.equals(this._selector) - ); - } - - public *[Symbol.iterator](): Iterator { - yield this; - } - - public toJSON(): Relative.JSON { - return { - type: "relative", - combinator: this._combinator, - selector: this._selector.toJSON(), - }; - } - - public toString(): string { - const combinator = - this._combinator === Combinator.Descendant - ? "" - : `${this._combinator} `; - - return `${combinator}${this._selector}`; - } - } - - export namespace Relative { - export interface JSON extends Selector.JSON<"relative"> { - combinator: string; - selector: Simple.JSON | Compound.JSON | Complex.JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-relative-selector} - */ - // const parseRelative = map(pair(parseCombinator, parseComplex), (result) => { - // const [combinator, selector] = result; - - // return Relative.of(combinator, selector); - // }); - - /** - * {@link https://drafts.csswg.org/selectors/#selector-list} - */ - export class List< - T extends Simple | Compound | Complex | Relative = - | Simple - | Compound - | Complex - | Relative, - > extends Selector<"list"> { - public static of( - left: T, - right: T | List, - ): List { - return new List(left, right); - } - - private readonly _left: T; - private readonly _right: T | List; - - private constructor(left: T, right: T | List) { - super(); - this._left = left; - this._right = right; - } - - public get left(): T { - return this._left; - } - - public get right(): T | List { - return this._right; - } - - public get type(): "list" { - return "list"; - } - - public matches(element: Element, context?: Context): boolean { - return ( - this._left.matches(element, context) || - this._right.matches(element, context) - ); - } - - public equals(value: List): boolean; - - public equals(value: unknown): value is this; - - public equals(value: unknown): boolean { - return ( - value instanceof List && - value._left.equals(this._left) && - value._right.equals(this._right) - ); - } - - public *[Symbol.iterator](): Iterator< - Simple | Compound | Complex | Relative - > { - yield this._left; - yield* this._right; - } - - public toJSON(): List.JSON { - return { - type: "list", - left: this._left.toJSON(), - right: this._right.toJSON(), - }; - } - - public toString(): string { - return `${this._left}, ${this._right}`; - } - } - - export namespace List { - export interface JSON extends Selector.JSON<"list"> { - left: Simple.JSON | Compound.JSON | Complex.JSON | Relative.JSON; - right: Simple.JSON | Compound.JSON | Complex.JSON | Relative.JSON | JSON; - } - } - - /** - * {@link https://drafts.csswg.org/selectors/#typedef-selector-list} - */ - const parseList = map( - pair( - parseComplex, - zeroOrMore( - right( - delimited(option(Token.parseWhitespace), Token.parseComma), - parseComplex, - ), - ), - ), - (result) => { - let [left, selectors] = result; - - [left, ...selectors] = [...Iterable.reverse(selectors), left]; - - return Iterable.reduce( - selectors, - (right, left) => List.of(left, right), - left as Simple | Compound | Complex | List, - ); - }, - ); - - parseSelector = left( - parseList, - end((token) => `Unexpected token ${token}`), - ); - - export const parse = parseSelector; -} diff --git a/packages/alfa-selector/src/selector/combinator.ts b/packages/alfa-selector/src/selector/combinator.ts new file mode 100644 index 0000000000..ce967d8dec --- /dev/null +++ b/packages/alfa-selector/src/selector/combinator.ts @@ -0,0 +1,53 @@ +import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +const { delimited, either, map, option } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#selector-combinator} + * + * @public + */ +export enum Combinator { + /** + * @example div span + */ + Descendant = " ", + + /** + * @example div \> span + */ + DirectDescendant = ">", + + /** + * @example div ~ span + */ + Sibling = "~", + + /** + * @example div + span + */ + DirectSibling = "+", +} + +/** + * @public + */ +export namespace Combinator { + /** + * {@link https://drafts.csswg.org/selectors/#typedef-combinator} + * + * @internal + */ + export const parseCombinator: CSSParser = either( + delimited( + option(Token.parseWhitespace), + either( + map(Token.parseDelim(">"), () => Combinator.DirectDescendant), + map(Token.parseDelim("~"), () => Combinator.Sibling), + map(Token.parseDelim("+"), () => Combinator.DirectSibling), + ), + ), + map(Token.parseWhitespace, () => Combinator.Descendant), + ); +} diff --git a/packages/alfa-selector/src/selector/complex.ts b/packages/alfa-selector/src/selector/complex.ts new file mode 100644 index 0000000000..4345756e35 --- /dev/null +++ b/packages/alfa-selector/src/selector/complex.ts @@ -0,0 +1,172 @@ +import type { Parser as CSSParser } from "@siteimprove/alfa-css"; +import { Element } from "@siteimprove/alfa-dom"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import { Context } from "../context"; +import type { Absolute } from "../selector"; + +import { Combinator } from "./combinator"; +import { Compound } from "./compound"; +import { Selector } from "./selector"; +import type { Simple } from "./simple"; + +const { isElement } = Element; +const { map, pair, zeroOrMore } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#complex} + * + * @public + */ +export class Complex extends Selector<"complex"> { + public static of( + combinator: Combinator, + left: Simple | Compound | Complex, + right: Simple | Compound, + ): Complex { + return new Complex(combinator, left, right); + } + + private readonly _combinator: Combinator; + private readonly _left: Simple | Compound | Complex; + private readonly _right: Simple | Compound; + + private constructor( + combinator: Combinator, + left: Simple | Compound | Complex, + right: Simple | Compound, + ) { + super("complex"); + this._combinator = combinator; + this._left = left; + this._right = right; + } + + public get combinator(): Combinator { + return this._combinator; + } + + public get left(): Simple | Compound | Complex { + return this._left; + } + + public get right(): Simple | Compound { + return this._right; + } + + public matches(element: Element, context?: Context): boolean { + // First, make sure that the right side of the selector, i.e. the part + // that relates to the current element, matches. + if (this._right.matches(element, context)) { + // If it does, move on to the heavy part of the work: Looking either up + // the tree for a descendant match or looking to the side of the tree + // for a sibling match. + switch (this._combinator) { + case Combinator.Descendant: + return element + .ancestors() + .filter(isElement) + .some((element) => this._left.matches(element, context)); + + case Combinator.DirectDescendant: + return element + .parent() + .filter(isElement) + .some((element) => this._left.matches(element, context)); + + case Combinator.Sibling: + return element + .preceding() + .filter(isElement) + .some((element) => this._left.matches(element, context)); + + case Combinator.DirectSibling: + return element + .preceding() + .find(isElement) + .some((element) => this._left.matches(element, context)); + } + } + + return false; + } + + public equals(value: Complex): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Complex && + value._combinator === this._combinator && + value._left.equals(this._left) && + value._right.equals(this._right) + ); + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Complex.JSON { + return { + ...super.toJSON(), + combinator: this._combinator, + left: this._left.toJSON(), + right: this._right.toJSON(), + }; + } + + public toString(): string { + const combinator = + this._combinator === Combinator.Descendant + ? " " + : ` ${this._combinator} `; + + return `${this._left}${combinator}${this._right}`; + } +} + +/** + * @public + */ +export namespace Complex { + export interface JSON extends Selector.JSON<"complex"> { + combinator: Combinator; + left: Simple.JSON | Compound.JSON | Complex.JSON; + right: Simple.JSON | Compound.JSON; + } + + export function isComplex(value: unknown): value is Complex { + return value instanceof Complex; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-complex-selector} + * + * @internal + */ + export const parseComplex = (parseSelector: Thunk>) => + map( + pair( + Compound.parseCompound(parseSelector), + zeroOrMore( + pair( + Combinator.parseCombinator, + Compound.parseCompound(parseSelector), + ), + ), + ), + (result) => { + const [left, selectors] = result; + + return Iterable.reduce( + selectors, + (left, [combinator, right]) => Complex.of(combinator, left, right), + left as Simple | Compound | Complex, + ); + }, + ); +} diff --git a/packages/alfa-selector/src/selector/compound.ts b/packages/alfa-selector/src/selector/compound.ts new file mode 100644 index 0000000000..2da60637e4 --- /dev/null +++ b/packages/alfa-selector/src/selector/compound.ts @@ -0,0 +1,99 @@ +import { Array } from "@siteimprove/alfa-array"; +import { Token } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Slice } from "@siteimprove/alfa-slice"; + +import type { Context } from "../context"; +import type { Absolute } from "./index"; + +import { Selector } from "./selector"; +import { Simple } from "./simple"; + +const { map, oneOrMore } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#compound} + * + * @public + */ +export class Compound extends Selector<"compound"> { + public static of(...selectors: Array): Compound { + return new Compound(selectors); + } + + private readonly _selectors: Array; + private readonly _length: number; + + private constructor(selectors: Array) { + super("compound"); + this._selectors = selectors; + this._length = selectors.length; + } + + public get selectors(): Iterable { + return this._selectors; + } + + public get length(): number { + return this._length; + } + + public matches(element: Element, context?: Context): boolean { + return this._selectors.every((selector) => + selector.matches(element, context), + ); + } + + public equals(value: Compound): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Compound && + Array.equals(value._selectors, this._selectors) + ); + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Compound.JSON { + return { + ...super.toJSON(), + selectors: this._selectors.map((selector) => selector.toJSON()), + }; + } + + public toString(): string { + return this._selectors.map((selector) => selector.toString()).join(""); + } +} + +/** + * @public + */ +export namespace Compound { + export interface JSON extends Selector.JSON<"compound"> { + selectors: Array; + } + + export function isCompound(value: unknown): value is Compound { + return value instanceof Compound; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-compound-selector} + * + * @internal + */ + export const parseCompound = ( + parseSelector: () => Parser, Absolute, string>, + ) => + map(oneOrMore(Simple.parse(parseSelector)), (result) => + result.length === 1 ? result[0] : Compound.of(...result), + ); +} diff --git a/packages/alfa-selector/src/selector/index.ts b/packages/alfa-selector/src/selector/index.ts new file mode 100644 index 0000000000..d0380edcca --- /dev/null +++ b/packages/alfa-selector/src/selector/index.ts @@ -0,0 +1,86 @@ +import type { Parser as CSSParser } from "@siteimprove/alfa-css"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Parser } from "@siteimprove/alfa-parser"; + +import type { Complex } from "./complex"; +import type { Compound } from "./compound"; +import { List } from "./list"; +import type { Relative } from "./relative"; +import type { Simple } from "./simple/index"; + +// Re-export for further users +export * from "./combinator"; +export * from "./complex"; +export * from "./compound"; +export * from "./list"; +export * from "./relative"; +export * from "./simple"; + +const { end, left, map } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#selector} + * + * @public + */ +export type Selector = Simple | Compound | Complex | Relative | List; + +/** + * Non-relative selectors for contexts that do not allow them + * + * @internal + */ +export type Absolute = + | Simple + | Compound + | Complex + | List; + +/** + * @internal + */ +export namespace Absolute { + export type JSON = + | Simple.JSON + | Compound.JSON + | Complex.JSON + | List.JSON; +} + +/** + * @public + */ +export namespace Selector { + export type JSON = + | Simple.JSON + | Compound.JSON + | Complex.JSON + | Relative.JSON + | List.JSON; + + /** + * Parsers for Selectors + * + * @remarks + * Even simple selectors like `:is()` can include any other selector. + * This creates circular dependencies, especially in the parsers. + * To break it, we use dependency injection and inject the top-level + * selector parser into each of the individual ones. + * + * In order to avoid an infinite recursion, this means that we must actually + * inject a continuation wrapping the parser, and only resolve it to an + * actual parser upon need. + * + * That is, the extra `()` "parameter" is needed! + */ + function parseSelector(): CSSParser { + return left( + map(List.parseList(parseSelector), (list) => + list.length === 1 ? Iterable.first(list.selectors).getUnsafe() : list, + ), + end((token) => `Unexpected token ${token}`), + ); + } + + export const parse = parseSelector(); +} diff --git a/packages/alfa-selector/src/selector/list.ts b/packages/alfa-selector/src/selector/list.ts new file mode 100644 index 0000000000..a5c0cdd6f3 --- /dev/null +++ b/packages/alfa-selector/src/selector/list.ts @@ -0,0 +1,100 @@ +import { Array } from "@siteimprove/alfa-array"; +import { Comma, type Parser as CSSParser } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import type { Serializable } from "@siteimprove/alfa-json"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Context } from "../context"; + +import type { Absolute } from "./index"; + +import { Complex } from "./complex"; +import type { Compound } from "./compound"; +import type { Relative } from "./relative"; +import { Selector } from "./selector"; +import type { Simple } from "./simple"; + +const { map, separatedList } = Parser; + +type Item = Simple | Compound | Complex | Relative; + +/** + * {@link https://drafts.csswg.org/selectors/#selector-list} + * + * @public + */ +export class List extends Selector<"list"> { + public static of(...selectors: Array): List { + return new List(selectors); + } + + private readonly _selectors: Array; + private readonly _length: number; + + private constructor(selectors: Array) { + super("list"); + this._selectors = selectors; + this._length = selectors.length; + } + + public get selectors(): Iterable { + return this._selectors; + } + + public get length(): number { + return this._length; + } + + public matches(element: Element, context?: Context): boolean { + return this._selectors.some((selector) => + selector.matches(element, context), + ); + } + + public equals(value: List): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof List && Array.equals(value._selectors, this._selectors) + ); + } + + public *[Symbol.iterator](): Iterator { + yield* this._selectors; + } + + public toJSON(): List.JSON { + return { + ...super.toJSON(), + selectors: Array.toJSON(this._selectors), + }; + } + + public toString(): string { + return this._selectors.map((selector) => selector.toString()).join(", "); + } +} + +/** + * @public + */ +export namespace List { + export interface JSON extends Selector.JSON<"list"> { + selectors: Array>; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-selector-list} + * + * @internal + */ + export const parseList = (parseSelector: Thunk>) => + map( + separatedList(Complex.parseComplex(parseSelector), Comma.parse), + (result) => List.of(...result), + ); +} diff --git a/packages/alfa-selector/src/selector/relative.ts b/packages/alfa-selector/src/selector/relative.ts new file mode 100644 index 0000000000..084f92b9e3 --- /dev/null +++ b/packages/alfa-selector/src/selector/relative.ts @@ -0,0 +1,93 @@ +import { Combinator } from "./combinator"; +import type { Complex } from "./complex"; +import type { Compound } from "./compound"; +import { Selector } from "./selector"; +import type { Simple } from "./simple"; + +/** + * {@link https://drafts.csswg.org/selectors/#relative-selector} + * + * @public + */ +export class Relative extends Selector<"relative"> { + public static of( + combinator: Combinator, + selector: Simple | Compound | Complex, + ): Relative { + return new Relative(combinator, selector); + } + + private readonly _combinator: Combinator; + private readonly _selector: Simple | Compound | Complex; + + private constructor( + combinator: Combinator, + selector: Simple | Compound | Complex, + ) { + super("relative"); + this._combinator = combinator; + this._selector = selector; + } + + public get combinator(): Combinator { + return this._combinator; + } + + public get selector(): Simple | Compound | Complex { + return this._selector; + } + + public matches(): boolean { + return false; + } + + public equals(value: Relative): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Relative && + value._combinator === this._combinator && + value._selector.equals(this._selector) + ); + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Relative.JSON { + return { + ...super.toJSON(), + combinator: this._combinator, + selector: this._selector.toJSON(), + }; + } + + public toString(): string { + const combinator = + this._combinator === Combinator.Descendant ? "" : `${this._combinator} `; + + return `${combinator}${this._selector}`; + } +} + +/** + * @public + */ +export namespace Relative { + export interface JSON extends Selector.JSON<"relative"> { + combinator: string; + selector: Simple.JSON | Compound.JSON | Complex.JSON; + } +} + +/** + * {@link https://drafts.csswg.org/selectors/#typedef-relative-selector} + */ +// const parseRelative = map(pair(parseCombinator, parseComplex), (result) => { +// const [combinator, selector] = result; + +// return Relative.of(combinator, selector); +// }); diff --git a/packages/alfa-selector/src/selector/selector.ts b/packages/alfa-selector/src/selector/selector.ts new file mode 100644 index 0000000000..efa66bce19 --- /dev/null +++ b/packages/alfa-selector/src/selector/selector.ts @@ -0,0 +1,120 @@ +import type { Element } from "@siteimprove/alfa-dom"; +import { Equatable } from "@siteimprove/alfa-equatable"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Serializable } from "@siteimprove/alfa-json"; + +import * as json from "@siteimprove/alfa-json"; + +import type { Context } from "../context"; + +import type { Complex } from "./complex"; +import type { Compound } from "./compound"; +import type { Relative } from "./relative"; +import type { Simple } from "./simple"; + +/** + * @internal + */ +export abstract class Selector + implements + Iterable, + Equatable, + Serializable +{ + private readonly _type: T; + + protected constructor(type: T) { + this._type = type; + } + + /** @public (knip) */ + public get type(): T { + return this._type; + } + + /** + * {@link https://drafts.csswg.org/selectors/#match} + */ + public abstract matches(element: Element, context?: Context): boolean; + + public equals(value: Selector): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Selector && value._type === this._type; + } + + /** @public (knip) */ + public abstract [Symbol.iterator](): Iterator< + Simple | Compound | Complex | Relative + >; + + public toJSON(): Selector.JSON { + return { + type: this._type, + }; + } +} + +export namespace Selector { + export interface JSON { + [key: string]: json.JSON; + + type: T; + } +} + +/** + * Selectors who also contain a name. + * + * @remarks + * This can be either specific (e.g., the id is the name of a #foo selector), + * or generic (e.g., "focus" is the name of the `:focus` pseudo-class). + * + * @internal + */ +export abstract class WithName< + T extends string = string, + N extends string = string, +> extends Selector { + protected readonly _name: N; + protected constructor(type: T, name: N) { + super(type); + this._name = name; + } + + public get name(): N { + return this._name; + } + + public matches(element: Element, context?: Context): boolean { + return false; + } + + public equals(value: WithName): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof WithName && + super.equals(value) && + value._name === this._name + ); + } + + public toJSON(): WithName.JSON { + return { + ...super.toJSON(), + name: this._name, + }; + } +} + +export namespace WithName { + export interface JSON + extends Selector.JSON { + name: N; + } +} diff --git a/packages/alfa-selector/src/selector/simple/attribute.ts b/packages/alfa-selector/src/selector/simple/attribute.ts new file mode 100644 index 0000000000..f11de78580 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/attribute.ts @@ -0,0 +1,306 @@ +import { Token } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import * as dom from "@siteimprove/alfa-dom"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Predicate } from "@siteimprove/alfa-predicate"; + +import { WithName } from "../selector"; + +import { parseName } from "./parser"; + +const { delimited, either, left, map, option, pair } = Parser; +const { and, equals, property } = Predicate; + +/** + * {@link https://drafts.csswg.org/selectors/#attribute-selector} + * + * @public + */ +export class Attribute extends WithName<"attribute"> { + public static of( + namespace: Option, + name: string, + value: Option = None, + matcher: Option = None, + modifier: Option = None, + ): Attribute { + return new Attribute(namespace, name, value, matcher, modifier); + } + + private readonly _namespace: Option; + private readonly _value: Option; + private readonly _matcher: Option; + private readonly _modifier: Option; + + private constructor( + namespace: Option, + name: string, + value: Option, + matcher: Option, + modifier: Option, + ) { + super("attribute", name); + this._namespace = namespace; + this._value = value; + this._matcher = matcher; + this._modifier = modifier; + } + + public get namespace(): Option { + return this._namespace; + } + + public get value(): Option { + return this._value; + } + + public get matcher(): Option { + return this._matcher; + } + + public get modifier(): Option { + return this._modifier; + } + + public matches(element: Element): boolean { + for (const namespace of this._namespace) { + let predicate: Predicate; + + switch (namespace) { + case "*": + predicate = property("name", equals(this._name)); + break; + + case "": + predicate = and( + property("name", equals(this._name)), + property("namespace", equals(None)), + ); + break; + + default: + predicate = and( + property("name", equals(this._name)), + property("namespace", equals(namespace)), + ); + } + + return Iterable.some( + element.attributes, + and(predicate, (attribute) => this.matchesValue(attribute.value)), + ); + } + + return element + .attribute(this._name) + .some((attribute) => this.matchesValue(attribute.value)); + } + + private matchesValue(value: string): boolean { + for (const modifier of this._modifier) { + switch (modifier) { + case Attribute.Modifier.CaseInsensitive: + value = value.toLowerCase(); + } + } + + for (const match of this._value) { + switch (this._matcher.getOr(Attribute.Matcher.Equal)) { + case Attribute.Matcher.Equal: + return value === match; + + case Attribute.Matcher.Prefix: + return value.startsWith(match); + + case Attribute.Matcher.Suffix: + return value.endsWith(match); + + case Attribute.Matcher.Substring: + return value.includes(match); + + case Attribute.Matcher.DashMatch: + return value === match || value.startsWith(`${match}-`); + + case Attribute.Matcher.Includes: + return value.split(/\s+/).some(equals(match)); + } + } + + return true; + } + + public equals(value: Attribute): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Attribute && + value._namespace.equals(this._namespace) && + value._name === this._name && + value._value.equals(this._value) && + value._matcher.equals(this._matcher) && + value._modifier.equals(this._modifier) + ); + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Attribute.JSON { + return { + ...super.toJSON(), + namespace: this._namespace.getOr(null), + value: this._value.getOr(null), + matcher: this._matcher.getOr(null), + modifier: this._modifier.getOr(null), + }; + } + + public toString(): string { + const namespace = this._namespace + .map((namespace) => `${namespace}|`) + .getOr(""); + + const value = this._value + .map((value) => `"${JSON.stringify(value)}"`) + .getOr(""); + + const matcher = this._matcher.getOr(""); + + const modifier = this._modifier.map((modifier) => ` ${modifier}`).getOr(""); + + return `[${namespace}${this._name}${matcher}${value}${modifier}]`; + } +} + +/** + * @public + */ +export namespace Attribute { + export interface JSON extends WithName.JSON<"attribute"> { + namespace: string | null; + value: string | null; + matcher: string | null; + modifier: string | null; + } + + export enum Matcher { + /** + * @example [foo=bar] + */ + Equal = "=", + + /** + * @example [foo~=bar] + */ + Includes = "~=", + + /** + * @example [foo|=bar] + */ + DashMatch = "|=", + + /** + * @example [foo^=bar] + */ + Prefix = "^=", + + /** + * @example [foo$=bar] + */ + Suffix = "$=", + + /** + * @example [foo*=bar] + */ + Substring = "*=", + } + + export enum Modifier { + /** + * @example [foo=bar i] + */ + CaseInsensitive = "i", + + /** + * @example [foo=Bar s] + */ + CaseSensitive = "s", + } + + export function isAttribute(value: unknown): value is Attribute { + return value instanceof Attribute; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-attr-matcher} + */ + const parseMatcher = map( + left( + option( + either( + Token.parseDelim("~"), + Token.parseDelim("|"), + Token.parseDelim("^"), + Token.parseDelim("$"), + Token.parseDelim("*"), + ), + ), + Token.parseDelim("="), + ), + (delim) => + delim.isSome() + ? (`${delim.get()}=` as Attribute.Matcher) + : Attribute.Matcher.Equal, + ); + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-attr-modifier} + */ + const parseModifier = either( + map(Token.parseIdent("i"), () => Attribute.Modifier.CaseInsensitive), + map(Token.parseIdent("s"), () => Attribute.Modifier.CaseSensitive), + ); + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-attribute-selector} + * + * @internal + */ + export const parse = map( + delimited( + Token.parseOpenSquareBracket, + pair( + parseName, + option( + pair( + pair(parseMatcher, either(Token.parseString(), Token.parseIdent())), + delimited(option(Token.parseWhitespace), option(parseModifier)), + ), + ), + ), + Token.parseCloseSquareBracket, + ), + (result) => { + const [[namespace, name], rest] = result; + + if (rest.isSome()) { + const [[matcher, value], modifier] = rest.get(); + + return Attribute.of( + namespace, + name, + Option.of(value.value), + Option.of(matcher), + modifier, + ); + } + + return Attribute.of(namespace, name); + }, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/class.ts b/packages/alfa-selector/src/selector/simple/class.ts new file mode 100644 index 0000000000..2684e548e8 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/class.ts @@ -0,0 +1,67 @@ +import { Token } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Iterable } from "@siteimprove/alfa-iterable"; +import { Parser } from "@siteimprove/alfa-parser"; + +import { WithName } from "../selector"; + +const { map, right } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#class-selector} + * + * @public + */ +export class Class extends WithName<"class"> { + public static of(name: string): Class { + return new Class(name); + } + private constructor(name: string) { + super("class", name); + } + + public matches(element: Element): boolean { + return Iterable.includes(element.classes, this._name); + } + + public equals(value: Class): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): value is boolean { + return value instanceof Class && value._name === this._name; + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Class.JSON { + return { + ...super.toJSON(), + }; + } + + public toString(): string { + return `.${this._name}`; + } +} + +/** + * @public + */ +export namespace Class { + export interface JSON extends WithName.JSON<"class"> {} + + export function isClass(value: unknown): value is Class { + return value instanceof Class; + } + + /** + * @internal + */ + export const parse = map( + right(Token.parseDelim("."), Token.parseIdent()), + (ident) => Class.of(ident.value), + ); +} diff --git a/packages/alfa-selector/src/selector/simple/id.ts b/packages/alfa-selector/src/selector/simple/id.ts new file mode 100644 index 0000000000..610ca8f612 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/id.ts @@ -0,0 +1,69 @@ +import { Token } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Parser } from "@siteimprove/alfa-parser"; + +import { WithName } from "../selector"; + +const { map } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#id-selector} + * + * @public + */ +export class Id extends WithName<"id"> { + public static of(name: string): Id { + return new Id(name); + } + + private constructor(name: string) { + super("id", name); + } + + public matches(element: Element): boolean { + return element.id.includes(this._name); + } + + public equals(value: Id): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Id && value._name === this._name; + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Id.JSON { + return { + ...super.toJSON(), + }; + } + + public toString(): string { + return `#${this._name}`; + } +} + +/** + * @public + */ +export namespace Id { + export interface JSON extends WithName.JSON<"id"> {} + + export function isId(value: unknown): value is Id { + return value instanceof Id; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-id-selector} + * + * @internal + */ + export const parse = map( + Token.parseHash((hash) => hash.isIdentifier), + (hash) => Id.of(hash.value), + ); +} diff --git a/packages/alfa-selector/src/selector/simple/index.ts b/packages/alfa-selector/src/selector/simple/index.ts new file mode 100644 index 0000000000..cf66a33c6a --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/index.ts @@ -0,0 +1,82 @@ +import type { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Slice } from "@siteimprove/alfa-slice"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Absolute } from "../../selector"; + +// Import the various simple selectors for use in that file. +import { Attribute } from "./attribute"; +import { Class } from "./class"; +import { Id } from "./id"; +import { PseudoClass } from "./pseudo-class"; +import { PseudoElement } from "./pseudo-element/index"; +import { Type } from "./type"; +import { Universal } from "./universal"; + +// Re-export the various selectors for use by others +export * from "./attribute"; +export * from "./class"; +export * from "./id"; +export * from "./pseudo-class"; +export * from "./pseudo-element"; +export * from "./type"; +export * from "./universal"; + +const { either } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#simple} + * + * @public + */ +export type Simple = + | Type + | Universal + | Attribute + | Class + | Id + | PseudoClass + | PseudoElement; + +/** + * @public + */ +export namespace Simple { + export type JSON = + | Type.JSON + | Universal.JSON + | Attribute.JSON + | Class.JSON + | Id.JSON + | PseudoClass.JSON + | PseudoElement.JSON; + + export function isSimple(value: unknown): value is Simple { + return ( + Type.isType(value) || + Universal.isUniversal(value) || + Attribute.isAttribute(value) || + Class.isClass(value) || + Id.isId(value) || + PseudoClass.isPseudoClass(value) || + PseudoElement.isPseudoElement(value) + ); + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-simple-selector} + * + * @internal + */ + export const parse = (parseSelector: Thunk>) => + either, Simple, string>( + Class.parse, + Type.parse, + Attribute.parse, + Id.parse, + Universal.parse, + PseudoClass.parse(parseSelector), + PseudoElement.parse(parseSelector), + ); +} diff --git a/packages/alfa-selector/src/selector/simple/parser.ts b/packages/alfa-selector/src/selector/simple/parser.ts new file mode 100644 index 0000000000..d8b496d185 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/parser.ts @@ -0,0 +1,27 @@ +import { Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +const { either, left, map, option, pair } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#typedef-ns-prefix} + * + * @internal + */ +export const parseNamespace = map( + left( + option(either(Token.parseIdent(), Token.parseDelim("*"))), + Token.parseDelim("|"), + ), + (token) => token.map((token) => token.toString()).getOr(""), +); + +/** + * {@link https://drafts.csswg.org/selectors/#typedef-wq-name} + * + * @internal + */ +export const parseName = pair( + option(parseNamespace), + map(Token.parseIdent(), (ident) => ident.value), +); diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/active.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/active.ts new file mode 100644 index 0000000000..d4438f75c4 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/active.ts @@ -0,0 +1,43 @@ +import type { Element } from "@siteimprove/alfa-dom"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#active-pseudo} + */ +export class Active extends PseudoClassSelector<"active"> { + public static of(): Active { + return new Active(); + } + + private constructor() { + super("active"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + return context.isActive(element); + } + + public toJSON(): Active.JSON { + return super.toJSON(); + } +} + +export namespace Active { + export interface JSON extends PseudoClassSelector.JSON<"active"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "active", + Active.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/disabled.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/disabled.ts new file mode 100644 index 0000000000..e064bf4ee3 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/disabled.ts @@ -0,0 +1,44 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#enableddisabled} + * {@link https://html.spec.whatwg.org/multipage#selector-disabled} + */ +export class Disabled extends PseudoClassSelector<"disabled"> { + public static of(): Disabled { + return new Disabled(); + } + + private constructor() { + super("disabled"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + return Element.isActuallyDisabled(element); + } + + public toJSON(): Disabled.JSON { + return super.toJSON(); + } +} + +export namespace Disabled { + export interface JSON extends PseudoClassSelector.JSON<"disabled"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "disabled", + Disabled.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/empty.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/empty.ts new file mode 100644 index 0000000000..2793510954 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/empty.ts @@ -0,0 +1,37 @@ +import type { Element } from "@siteimprove/alfa-dom"; +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#empty-pseudo} + */ +export class Empty extends PseudoClassSelector<"empty"> { + public static of(): Empty { + return new Empty(); + } + + private constructor() { + super("empty"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return element.children().isEmpty(); + } + + public toJSON(): Empty.JSON { + return super.toJSON(); + } +} + +export namespace Empty { + export interface JSON extends PseudoClassSelector.JSON<"empty"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "empty", + Empty.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/enabled.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/enabled.ts new file mode 100644 index 0000000000..a4baca3e96 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/enabled.ts @@ -0,0 +1,60 @@ +import { Element } from "@siteimprove/alfa-dom"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Context } from "../../../context"; +import { PseudoClassSelector } from "./pseudo-class"; + +const { hasName } = Element; +const { and, not, test } = Predicate; + +/** + * {@link https://drafts.csswg.org/selectors/#enableddisabled} + * {@link https://html.spec.whatwg.org/multipage#selector-enabled} + */ +export class Enabled extends PseudoClassSelector<"enabled"> { + public static of(): Enabled { + return new Enabled(); + } + + private constructor() { + super("enabled"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + return test( + and( + hasName( + "button", + "input", + "select", + "textarea", + "optgroup", + "option", + "fieldset", + ), + not(Element.isActuallyDisabled), + ), + element, + ); + } + + public toJSON(): Enabled.JSON { + return super.toJSON(); + } +} + +export namespace Enabled { + export interface JSON extends PseudoClassSelector.JSON<"enabled"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "enabled", + Enabled.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/first-child.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/first-child.ts new file mode 100644 index 0000000000..e43e8d4e75 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/first-child.ts @@ -0,0 +1,44 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#first-child-pseudo} + */ +export class FirstChild extends PseudoClassSelector<"first-child"> { + public static of(): FirstChild { + return new FirstChild(); + } + + private constructor() { + super("first-child"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return element + .inclusiveSiblings() + .filter(isElement) + .first() + .includes(element); + } + + public toJSON(): FirstChild.JSON { + return super.toJSON(); + } +} + +export namespace FirstChild { + export interface JSON extends PseudoClassSelector.JSON<"first-child"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "first-child", + FirstChild.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/first-of-type.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/first-of-type.ts new file mode 100644 index 0000000000..701559b309 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/first-of-type.ts @@ -0,0 +1,45 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { hasName, isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#first-of-type-pseudo} + */ +export class FirstOfType extends PseudoClassSelector<"first-of-type"> { + public static of(): FirstOfType { + return new FirstOfType(); + } + + private constructor() { + super("first-of-type"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return element + .inclusiveSiblings() + .filter(isElement) + .filter(hasName(element.name)) + .first() + .includes(element); + } + + public toJSON(): FirstOfType.JSON { + return super.toJSON(); + } +} + +export namespace FirstOfType { + export interface JSON extends PseudoClassSelector.JSON<"first-of-type"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "first-of-type", + FirstOfType.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/focus-visible.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/focus-visible.ts new file mode 100644 index 0000000000..25462b8d48 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/focus-visible.ts @@ -0,0 +1,46 @@ +import type { Element } from "@siteimprove/alfa-dom"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#the-focus-visible-pseudo} + */ +export class FocusVisible extends PseudoClassSelector<"focus-visible"> { + public static of(): FocusVisible { + return new FocusVisible(); + } + + private constructor() { + super("focus-visible"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + // :focus-visible matches elements that are focused and where UA decides + // focus should be shown. That is notably text fields and keyboard-focused + // elements (some UA don't show focus indicator on mouse-focused elements) + // For the purposes of accessibility testing, we currently assume that + // we always want to look at a mode where the focus is visible. This is + // notably due to the fact that it is a UA decision, and therefore not + // a problem for the authors to fix if done incorrectly. + return context.isFocused(element); + } +} + +export namespace FocusVisible { + export interface JSON extends PseudoClassSelector.JSON<"focus-visible"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "focus-visible", + FocusVisible.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/focus-within.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/focus-within.ts new file mode 100644 index 0000000000..6eca1930d2 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/focus-within.ts @@ -0,0 +1,60 @@ +import { Cache } from "@siteimprove/alfa-cache"; +import { type Element, Node } from "@siteimprove/alfa-dom"; +import { Sequence } from "@siteimprove/alfa-sequence"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { State } = Context; + +/** + * {@link https://drafts.csswg.org/selectors/#focus-within-pseudo} + */ +export class FocusWithin extends PseudoClassSelector<"focus-within"> { + public static of(): FocusWithin { + return new FocusWithin(); + } + + private constructor() { + super("focus-within"); + } + + private static _cache = Cache.empty>(); + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + return FocusWithin._cache.get(element, Cache.empty).get(context, () => { + // We assume that most of the time the context is near empty and thus it + // is inexpensive to check if something is in it. + const focused = Sequence.from(context.withState(State.Focus)); + + return ( + focused.size !== 0 && + element + .inclusiveDescendants(Node.fullTree) + .some((descendant) => focused.includes(descendant)) + ); + }); + } + + public toJSON(): FocusWithin.JSON { + return super.toJSON(); + } +} + +export namespace FocusWithin { + export interface JSON extends PseudoClassSelector.JSON<"focus-within"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "focus-within", + FocusWithin.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/focus.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/focus.ts new file mode 100644 index 0000000000..92347e2f00 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/focus.ts @@ -0,0 +1,43 @@ +import type { Element } from "@siteimprove/alfa-dom"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#focus-pseudo} + */ +export class Focus extends PseudoClassSelector<"focus"> { + public static of(): Focus { + return new Focus(); + } + + private constructor() { + super("focus"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + return context.isFocused(element); + } + + public toJSON(): Focus.JSON { + return super.toJSON(); + } +} + +export namespace Focus { + export interface JSON extends PseudoClassSelector.JSON<"focus"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "focus", + Focus.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts new file mode 100644 index 0000000000..79b53db1f8 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/has.ts @@ -0,0 +1,47 @@ +import type { Parser as CSSParser } from "@siteimprove/alfa-css"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Absolute } from "../../../selector"; + +import { WithSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#has-pseudo} + */ +export class Has extends WithSelector<"has"> { + public static of(selector: Absolute): Has { + return new Has(selector); + } + + private constructor(selector: Absolute) { + super("has", selector); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public equals(value: Has): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Has && value._selector.equals(this._selector); + } + + public toJSON(): Has.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace Has { + export interface JSON extends WithSelector.JSON<"has"> {} + + // :has() normally only accepts relative selectors, we currently + // accept only non-relative ones… + export const parse = (parseSelector: Thunk>) => + WithSelector.parseWithSelector("has", parseSelector, Has.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/host.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/host.ts new file mode 100644 index 0000000000..ac746e59c6 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/host.ts @@ -0,0 +1,29 @@ +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/css-scoping-1/#selectordef-host} + */ +export class Host extends PseudoClassSelector<"host"> { + public static of(): Host { + return new Host(); + } + + private constructor() { + super("host"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Host.JSON { + return super.toJSON(); + } +} + +export namespace Host { + export interface JSON extends PseudoClassSelector.JSON<"host"> {} + + export const parse = PseudoClassSelector.parseNonFunctional("host", Host.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/hover.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/hover.ts new file mode 100644 index 0000000000..9db9263239 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/hover.ts @@ -0,0 +1,60 @@ +import { Cache } from "@siteimprove/alfa-cache"; +import { type Element, Node } from "@siteimprove/alfa-dom"; +import { Sequence } from "@siteimprove/alfa-sequence"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { State } = Context; + +/** + * {@link https://drafts.csswg.org/selectors/#hover-pseudo} + */ +export class Hover extends PseudoClassSelector<"hover"> { + public static of(): Hover { + return new Hover(); + } + + private constructor() { + super("hover"); + } + + private static _cache = Cache.empty>(); + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + return Hover._cache.get(element, Cache.empty).get(context, () => { + // We assume that most of the time the context is near empty and thus it + // is inexpensive to check if something is in it. + const hovered = Sequence.from(context.withState(State.Hover)); + + return ( + hovered.size !== 0 && + element + .inclusiveDescendants(Node.fullTree) + .some((descendant) => hovered.includes(descendant)) + ); + }); + } + + public toJSON(): Hover.JSON { + return super.toJSON(); + } +} + +export namespace Hover { + export interface JSON extends PseudoClassSelector.JSON<"hover"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "hover", + Hover.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts new file mode 100644 index 0000000000..91d75c288d --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/index.ts @@ -0,0 +1,137 @@ +import type { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Slice } from "@siteimprove/alfa-slice"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Absolute } from "../../../selector"; + +import { Active } from "./active"; +import { Disabled } from "./disabled"; +import { Empty } from "./empty"; +import { Enabled } from "./enabled"; +import { FirstChild } from "./first-child"; +import { FirstOfType } from "./first-of-type"; +import { Focus } from "./focus"; +import { FocusVisible } from "./focus-visible"; +import { FocusWithin } from "./focus-within"; +import { Has } from "./has"; +import { Host } from "./host"; +import { Hover } from "./hover"; +import { Is } from "./is"; +import { LastChild } from "./last-child"; +import { LastOfType } from "./last-of-type"; +import { Link } from "./link"; +import { Not } from "./not"; +import { NthChild } from "./nth-child"; +import { NthLastChild } from "./nth-last-child"; +import { NthLastOfType } from "./nth-last-of-type"; +import { NthOfType } from "./nth-of-type"; +import { OnlyChild } from "./only-child"; +import { OnlyOfType } from "./only-of-type"; +import { Root } from "./root"; +import { Visited } from "./visited"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { either } = Parser; + +/** + * @public + */ +export type PseudoClass = + | Active + | Disabled + | Empty + | Enabled + | FirstChild + | FirstOfType + | Focus + | FocusVisible + | FocusWithin + | Has + | Host + | Hover + | Is + | LastChild + | LastOfType + | Link + | Not + | NthChild + | NthLastChild + | NthLastOfType + | NthOfType + | OnlyChild + | OnlyOfType + | Root + | Visited; + +/** + * @public + */ +export namespace PseudoClass { + export type JSON = + | Active.JSON + | Disabled.JSON + | Empty.JSON + | Enabled.JSON + | FirstChild.JSON + | FirstOfType.JSON + | Focus.JSON + | FocusVisible.JSON + | FocusWithin.JSON + | Has.JSON + | Host.JSON + | Hover.JSON + | Is.JSON + | LastChild.JSON + | LastOfType.JSON + | Link.JSON + | Not.JSON + | NthChild.JSON + | NthLastChild.JSON + | NthLastOfType.JSON + | NthOfType.JSON + | OnlyChild.JSON + | OnlyOfType.JSON + | Root.JSON + | Visited.JSON; + + export function isPseudoClass(value: unknown): value is PseudoClass { + // Note: this is not totally true as we could extend PseudoClassSelector + // without making it a PseudoClass. We're likely having other problems in + // that case… + return value instanceof PseudoClassSelector; + } + + export function parse( + parseSelector: Thunk>, + ): CSSParser { + return either, PseudoClass, string>( + Active.parse, + Disabled.parse, + Empty.parse, + Enabled.parse, + FirstChild.parse, + FirstOfType.parse, + Focus.parse, + FocusVisible.parse, + FocusWithin.parse, + Host.parse, + Hover.parse, + LastChild.parse, + LastOfType.parse, + Link.parse, + OnlyChild.parse, + OnlyOfType.parse, + Root.parse, + Visited.parse, + NthChild.parse, + NthLastChild.parse, + NthLastOfType.parse, + NthOfType.parse, + Has.parse(parseSelector), + Is.parse(parseSelector), + Not.parse(parseSelector), + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts new file mode 100644 index 0000000000..ad6ef0ba31 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/is.ts @@ -0,0 +1,51 @@ +import { Parser as CSSParser } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Context } from "../../../context"; +import type { Absolute } from "../../../selector"; + +import { WithSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#matches-pseudo} + */ +export class Is extends WithSelector<"is"> { + public static of(selector: Absolute): Is { + return new Is(selector); + } + + private constructor(selector: Absolute) { + super("is", selector); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element, context?: Context): boolean { + return this._selector.matches(element, context); + } + + public equals(value: Is): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Is && value._selector.equals(this._selector); + } + + public toJSON(): Is.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace Is { + export interface JSON extends WithSelector.JSON<"is"> {} + + export const parse = (parseSelector: Thunk>) => + WithSelector.parseWithSelector("is", parseSelector, Is.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/last-child.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/last-child.ts new file mode 100644 index 0000000000..5a21b40a73 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/last-child.ts @@ -0,0 +1,44 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#last-child-pseudo} + */ +export class LastChild extends PseudoClassSelector<"last-child"> { + public static of(): LastChild { + return new LastChild(); + } + + private constructor() { + super("last-child"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return element + .inclusiveSiblings() + .filter(isElement) + .last() + .includes(element); + } + + public toJSON(): LastChild.JSON { + return super.toJSON(); + } +} + +export namespace LastChild { + export interface JSON extends PseudoClassSelector.JSON<"last-child"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "last-child", + LastChild.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/last-of-type.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/last-of-type.ts new file mode 100644 index 0000000000..fa93218915 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/last-of-type.ts @@ -0,0 +1,45 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { hasName, isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#last-of-type-pseudo} + */ +export class LastOfType extends PseudoClassSelector<"last-of-type"> { + public static of(): LastOfType { + return new LastOfType(); + } + + private constructor() { + super("last-of-type"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return element + .inclusiveSiblings() + .filter(isElement) + .filter(hasName(element.name)) + .last() + .includes(element); + } + + public toJSON(): LastOfType.JSON { + return super.toJSON(); + } +} + +export namespace LastOfType { + export interface JSON extends PseudoClassSelector.JSON<"last-of-type"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "last-of-type", + LastOfType.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/link.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/link.ts new file mode 100644 index 0000000000..a77e27d0c8 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/link.ts @@ -0,0 +1,49 @@ +import type { Element } from "@siteimprove/alfa-dom"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#link-pseudo} + */ +export class Link extends PseudoClassSelector<"link"> { + public static of(): Link { + return new Link(); + } + + private constructor() { + super("link"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + switch (element.name) { + case "a": + case "area": + case "link": + return element + .attribute("href") + .some(() => !context.hasState(element, Context.State.Visited)); + } + + return false; + } + + public toJSON(): Link.JSON { + return super.toJSON(); + } +} + +export namespace Link { + export interface JSON extends PseudoClassSelector.JSON<"link"> {} + + export const parse = PseudoClassSelector.parseNonFunctional("link", Link.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts new file mode 100644 index 0000000000..7887a972f1 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/not.ts @@ -0,0 +1,51 @@ +import { Parser as CSSParser } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Context } from "../../../context"; +import type { Absolute } from "../../../selector"; + +import { WithSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#negation-pseudo} + */ +export class Not extends WithSelector<"not"> { + public static of(selector: Absolute): Not { + return new Not(selector); + } + + private constructor(selector: Absolute) { + super("not", selector); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element, context?: Context): boolean { + return !this._selector.matches(element, context); + } + + public equals(value: Not): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Not && value._selector.equals(this._selector); + } + + public toJSON(): Not.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace Not { + export interface JSON extends WithSelector.JSON<"not"> {} + + export const parse = (parseSelector: Thunk>) => + WithSelector.parseWithSelector("not", parseSelector, Not.of); +} 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 new file mode 100644 index 0000000000..2e2c2c69f8 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-child.ts @@ -0,0 +1,59 @@ +import type { Nth } from "@siteimprove/alfa-css"; +import { Element } from "@siteimprove/alfa-dom"; + +import { WithIndex } 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); + } + + private constructor(index: Nth) { + super("nth-child", index); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + const indices = NthChild._indices; + + if (!indices.has(element)) { + element + .inclusiveSiblings() + .filter(isElement) + .forEach((element, i) => { + indices.set(element, i + 1); + }); + } + + return this._index.matches(indices.get(element)!); + } + + public equals(value: NthChild): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof NthChild && value._index.equals(this._index); + } + + public toJSON(): NthChild.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace NthChild { + export interface JSON extends WithIndex.JSON<"nth-child"> {} + + export const parse = WithIndex.parseWithIndex("nth-child", 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 new file mode 100644 index 0000000000..d9b5d03e5c --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-child.ts @@ -0,0 +1,63 @@ +import type { Nth } from "@siteimprove/alfa-css"; +import { Element } from "@siteimprove/alfa-dom"; + +import { WithIndex } 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); + } + + private constructor(nth: Nth) { + super("nth-last-child", nth); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + const indices = NthLastChild._indices; + + if (!indices.has(element)) { + element + .inclusiveSiblings() + .filter(isElement) + .reverse() + .forEach((element, i) => { + indices.set(element, i + 1); + }); + } + + return this._index.matches(indices.get(element)!); + } + + public equals(value: NthLastChild): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof NthLastChild && value._index.equals(this._index); + } + + public toJSON(): NthLastChild.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace NthLastChild { + export interface JSON extends WithIndex.JSON<"nth-last-child"> {} + + export const parse = WithIndex.parseWithIndex( + "nth-last-child", + NthLastChild.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-of-type.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-of-type.ts new file mode 100644 index 0000000000..e288de810f --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-last-of-type.ts @@ -0,0 +1,64 @@ +import type { Nth } from "@siteimprove/alfa-css"; +import { Element } from "@siteimprove/alfa-dom"; + +import { WithIndex } from "./pseudo-class"; + +const { hasName, isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#nth-last-of-type-pseudo} + */ +export class NthLastOfType extends WithIndex<"nth-last-of-type"> { + public static of(index: Nth): NthLastOfType { + return new NthLastOfType(index); + } + + private constructor(index: Nth) { + super("nth-last-of-type", index); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + const indices = NthLastOfType._indices; + + if (!indices.has(element)) { + element + .inclusiveSiblings() + .filter(isElement) + .filter(hasName(element.name)) + .reverse() + .forEach((element, i) => { + indices.set(element, i + 1); + }); + } + + return this._index.matches(indices.get(element)!); + } + + public equals(value: NthLastOfType): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof NthLastOfType && value._index.equals(this._index); + } + + public toJSON(): NthLastOfType.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace NthLastOfType { + export interface JSON extends WithIndex.JSON<"nth-last-of-type"> {} + + export const parse = WithIndex.parseWithIndex( + "nth-last-of-type", + NthLastOfType.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/nth-of-type.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-of-type.ts new file mode 100644 index 0000000000..d2354a47d3 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/nth-of-type.ts @@ -0,0 +1,60 @@ +import type { Nth } from "@siteimprove/alfa-css"; +import { Element } from "@siteimprove/alfa-dom"; + +import { WithIndex } from "./pseudo-class"; + +const { hasName, isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#nth-of-type-pseudo} + */ +export class NthOfType extends WithIndex<"nth-of-type"> { + public static of(index: Nth): NthOfType { + return new NthOfType(index); + } + + private constructor(index: Nth) { + super("nth-of-type", index); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + const indices = NthOfType._indices; + + if (!indices.has(element)) { + element + .inclusiveSiblings() + .filter(isElement) + .filter(hasName(element.name)) + .forEach((element, i) => { + indices.set(element, i + 1); + }); + } + + return this._index.matches(indices.get(element)!); + } + + public equals(value: NthOfType): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof NthOfType && value._index.equals(this._index); + } + + public toJSON(): NthOfType.JSON { + return { + ...super.toJSON(), + }; + } +} + +export namespace NthOfType { + export interface JSON extends WithIndex.JSON<"nth-of-type"> {} + + export const parse = WithIndex.parseWithIndex("nth-of-type", NthOfType.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/only-child.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/only-child.ts new file mode 100644 index 0000000000..486004f350 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/only-child.ts @@ -0,0 +1,40 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#only-child-pseudo} + */ +export class OnlyChild extends PseudoClassSelector<"only-child"> { + public static of(): OnlyChild { + return new OnlyChild(); + } + + private constructor() { + super("only-child"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return element.inclusiveSiblings().filter(isElement).size === 1; + } + + public toJSON(): OnlyChild.JSON { + return super.toJSON(); + } +} + +export namespace OnlyChild { + export interface JSON extends PseudoClassSelector.JSON<"only-child"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "only-child", + OnlyChild.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/only-of-type.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/only-of-type.ts new file mode 100644 index 0000000000..02362d3504 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/only-of-type.ts @@ -0,0 +1,45 @@ +import { Element } from "@siteimprove/alfa-dom"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { hasName, isElement } = Element; + +/** + * {@link https://drafts.csswg.org/selectors/#only-of-type-pseudo} + */ +export class OnlyOfType extends PseudoClassSelector<"only-of-type"> { + public static of(): OnlyOfType { + return new OnlyOfType(); + } + + private constructor() { + super("only-of-type"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + return ( + element + .inclusiveSiblings() + .filter(isElement) + .filter(hasName(element.name)).size === 1 + ); + } + + public toJSON(): OnlyOfType.JSON { + return super.toJSON(); + } +} + +export namespace OnlyOfType { + export interface JSON extends PseudoClassSelector.JSON<"only-of-type"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "only-of-type", + OnlyOfType.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 new file mode 100644 index 0000000000..f14b65e654 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/pseudo-class.ts @@ -0,0 +1,190 @@ +import { + Function, + Nth, + type Parser as CSSParser, + Token, +} from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Absolute } from "../../../selector"; + +import { WithName } from "../../selector"; + +const { end, left, map, right } = Parser; +const { parseColon } = Token; + +/** + * @internal + */ +export abstract class PseudoClassSelector< + N extends string = string, +> extends WithName<"pseudo-class", N> { + protected constructor(name: N) { + super("pseudo-class", name); + } + + public equals(value: PseudoClassSelector): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof PseudoClassSelector && super.equals(value); + } + + public toJSON(): PseudoClassSelector.JSON { + return { + ...super.toJSON(), + }; + } + + public toString(): string { + return `:${this._name}`; + } +} + +/** + * @internal + */ +export namespace PseudoClassSelector { + export interface JSON + extends WithName.JSON<"pseudo-class", N> {} + + /** + * Parses a non-functional pseudo-class (`:`) + */ + export function parseNonFunctional( + name: string, + of: Thunk, + ): CSSParser { + return map(right(parseColon, Token.parseIdent(name)), of); + } +} + +/** + * @internal + */ +export abstract class WithIndex< + N extends string = string, +> extends PseudoClassSelector { + protected static readonly _indices = new WeakMap(); + + protected readonly _index: Nth; + + protected constructor(name: N, nth: Nth) { + super(name); + + this._index = nth; + } + + public equals(value: WithIndex): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof WithIndex && value._index.equals(this._index); + } + + public toJSON(): WithIndex.JSON { + return { + ...super.toJSON(), + index: this._index.toJSON(), + }; + } + + public toString(): string { + return `:${this.name}(${this._index})`; + } +} + +/** + * @internal + */ +export namespace WithIndex { + export interface JSON + extends PseudoClassSelector.JSON { + index: Nth.JSON; + } + + const parseNth = left( + Nth.parse, + end((token) => `Unexpected token ${token}`), + ); + + /** + * Parses a functional pseudo-class accepting a nth argument (an+b) + */ + export function parseWithIndex( + name: string, + of: (nth: Nth) => T, + ): CSSParser { + return map(right(parseColon, Function.parse(name, parseNth)), ([, nth]) => + of(nth), + ); + } +} + +/** + * @internal + */ +export abstract class WithSelector< + N extends string = string, +> extends PseudoClassSelector { + protected readonly _selector: Absolute; + + protected constructor(name: N, selector: Absolute) { + super(name); + this._selector = selector; + } + + /** @public (knip) */ + public get selector(): Absolute { + return this._selector; + } + + public equals(value: WithSelector): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof WithSelector && value._selector.equals(this._selector) + ); + } + + public toJSON(): WithSelector.JSON { + return { + ...super.toJSON(), + selector: this._selector.toJSON(), + }; + } + + public toString(): string { + return `:${this.name}(${this._selector})`; + } +} + +/** + * @internal + */ +export namespace WithSelector { + export interface JSON + extends PseudoClassSelector.JSON { + selector: Absolute.JSON; + } + + /** + * Parses a functional pseudo-class accepting a selector argument + */ + export function parseWithSelector( + name: string, + parseSelector: Thunk>, + of: (selector: Absolute) => T, + ): CSSParser { + return map( + right(parseColon, Function.parse(name, parseSelector)), + ([, selector]) => of(selector), + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/root.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/root.ts new file mode 100644 index 0000000000..9fe6cc00ea --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/root.ts @@ -0,0 +1,40 @@ +import { Element } from "@siteimprove/alfa-dom"; +import { Predicate } from "@siteimprove/alfa-predicate"; + +import { PseudoClassSelector } from "./pseudo-class"; + +const { isElement } = Element; +const { not } = Predicate; + +/** + * {@link https://drafts.csswg.org/selectors/#root-pseudo} + */ +export class Root extends PseudoClassSelector<"root"> { + public static of(): Root { + return new Root(); + } + + private constructor() { + super("root"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches(element: Element): boolean { + // The root element is the element whose parent is NOT itself an element. + return element.parent().every(not(isElement)); + } + + public toJSON(): Root.JSON { + return super.toJSON(); + } +} + +export namespace Root { + export interface JSON extends PseudoClassSelector.JSON<"root"> {} + + export const parse = PseudoClassSelector.parseNonFunctional("root", Root.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-class/visited.ts b/packages/alfa-selector/src/selector/simple/pseudo-class/visited.ts new file mode 100644 index 0000000000..87a0d5c128 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-class/visited.ts @@ -0,0 +1,52 @@ +import type { Element } from "@siteimprove/alfa-dom"; + +import { Context } from "../../../context"; + +import { PseudoClassSelector } from "./pseudo-class"; + +/** + * {@link https://drafts.csswg.org/selectors/#visited-pseudo} + */ +export class Visited extends PseudoClassSelector<"visited"> { + public static of(): Visited { + return new Visited(); + } + + private constructor() { + super("visited"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public matches( + element: Element, + context: Context = Context.empty(), + ): boolean { + switch (element.name) { + case "a": + case "area": + case "link": + return element + .attribute("href") + .some(() => context.hasState(element, Context.State.Visited)); + } + + return false; + } + + public toJSON(): Visited.JSON { + return super.toJSON(); + } +} + +export namespace Visited { + export interface JSON extends PseudoClassSelector.JSON<"visited"> {} + + export const parse = PseudoClassSelector.parseNonFunctional( + "visited", + Visited.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/after.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/after.ts new file mode 100644 index 0000000000..be09199c74 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/after.ts @@ -0,0 +1,22 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo/#selectordef-after} + */ +export class After extends PseudoElementSelector<"after"> { + public static of(): After { + return new After(); + } + private constructor() { + super("after"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace After { + export const parse = PseudoElementSelector.parseLegacy("after", After.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/backdrop.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/backdrop.ts new file mode 100644 index 0000000000..0813284205 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/backdrop.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://fullscreen.spec.whatwg.org/#::backdrop-pseudo-element} + */ +export class Backdrop extends PseudoElementSelector<"backdrop"> { + public static of(): Backdrop { + return new Backdrop(); + } + + private constructor() { + super("backdrop"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace Backdrop { + export const parse = PseudoElementSelector.parseNonLegacy( + "backdrop", + Backdrop.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/before.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/before.ts new file mode 100644 index 0000000000..651e191b86 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/before.ts @@ -0,0 +1,23 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo/#selectordef-before} + */ +export class Before extends PseudoElementSelector<"before"> { + public static of(): Before { + return new Before(); + } + + private constructor() { + super("before"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace Before { + export const parse = PseudoElementSelector.parseLegacy("before", Before.of); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/cue-region.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/cue-region.ts new file mode 100644 index 0000000000..2d576db902 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/cue-region.ts @@ -0,0 +1,78 @@ +import { Function, Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import { Absolute, Selector } from "../../../selector"; +import { PseudoElementSelector } from "./pseudo-element"; + +const { either, map, right, take } = Parser; + +/** + * {@link https://w3c.github.io/webvtt/#the-cue-region-pseudo-element} + */ +export class CueRegion extends PseudoElementSelector<"cue-region"> { + public static of(selector?: Selector): CueRegion { + return new CueRegion(Option.from(selector)); + } + + private readonly _selector: Option; + + private constructor(selector: Option) { + super("cue-region"); + this._selector = selector; + } + + public get selector(): Option { + return this._selector; + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public equals(value: CueRegion): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof CueRegion && value.selector.equals(this.selector); + } + + public toJSON(): CueRegion.JSON { + return { + ...super.toJSON(), + selector: this._selector.toJSON(), + }; + } + + public toString(): string { + return `::${this.name}` + this._selector.isSome() + ? `(${this._selector})` + : ""; + } +} + +export namespace CueRegion { + export interface JSON extends PseudoElementSelector.JSON<"cue-region"> { + selector: Option.JSON; + } + + export function parse( + parseSelector: Thunk>, + ): CSSParser { + return right( + take(Token.parseColon, 2), + // We need to try and fail the functional notation first to avoid accepting + // the `::cue-region` prefix of a `::cue-region(selector)`. + either( + map(Function.parse("cue-region", parseSelector), ([_, selector]) => + CueRegion.of(selector), + ), + // We need to eta-expand in order to discard the result of parseIdent. + map(Token.parseIdent("cue-region"), () => CueRegion.of()), + ), + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/cue.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/cue.ts new file mode 100644 index 0000000000..7c95304f5b --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/cue.ts @@ -0,0 +1,83 @@ +import { + Function, + type Parser as CSSParser, + Token, +} from "@siteimprove/alfa-css"; +import { Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Thunk } from "@siteimprove/alfa-thunk"; + +import { Absolute, Selector } from "../../../selector"; + +import { PseudoElementSelector } from "./pseudo-element"; + +const { either, map, right, take } = Parser; + +/** + * {@link https://w3c.github.io/webvtt/#the-cue-pseudo-element} + */ +export class Cue extends PseudoElementSelector<"cue"> { + public static of(selector?: Selector): Cue { + return new Cue(Option.from(selector)); + } + + private readonly _selector: Option; + + private constructor(selector: Option) { + super("cue"); + this._selector = selector; + } + + public get selector(): Option { + return this._selector; + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public equals(value: Cue): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Cue && value.selector.equals(this.selector); + } + + public toJSON(): Cue.JSON { + return { + ...super.toJSON(), + selector: this._selector.toJSON(), + }; + } + + public toString(): string { + return `::${this.name}` + this._selector.isSome() + ? `(${this._selector})` + : ""; + } +} + +export namespace Cue { + export interface JSON extends PseudoElementSelector.JSON<"cue"> { + selector: Option.JSON; + } + + export function parse( + parseSelector: Thunk>, + ): CSSParser { + return right( + take(Token.parseColon, 2), + // We need to try and fail the functional notation first to avoid accepting + // the `::cue` prefix of a `::cue(selector)`. + either( + map(Function.parse("cue", parseSelector), ([_, selector]) => + Cue.of(selector), + ), + // We need to eta-expand in order to discard the result of parseIdent. + map(Token.parseIdent("cue"), () => Cue.of()), + ), + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/file-selector-button.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/file-selector-button.ts new file mode 100644 index 0000000000..9827ee1519 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/file-selector-button.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + *{@link https://drafts.csswg.org/css-pseudo-4/#file-selector-button-pseudo} + */ +export class FileSelectorButton extends PseudoElementSelector<"file-selector-button"> { + public static of(): FileSelectorButton { + return new FileSelectorButton(); + } + + private constructor() { + super("file-selector-button"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace FileSelectorButton { + export const parse = PseudoElementSelector.parseNonLegacy( + "file-selector-button", + FileSelectorButton.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/first-letter.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/first-letter.ts new file mode 100644 index 0000000000..f09ebf006c --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/first-letter.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#first-letter-pseudo} + */ +export class FirstLetter extends PseudoElementSelector<"first-letter"> { + public static of(): FirstLetter { + return new FirstLetter(); + } + + private constructor() { + super("first-letter"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace FirstLetter { + export const parse = PseudoElementSelector.parseLegacy( + "first-letter", + FirstLetter.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/first-line.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/first-line.ts new file mode 100644 index 0000000000..5314fc083e --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/first-line.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#first-line-pseudo} + */ +export class FirstLine extends PseudoElementSelector<"first-line"> { + public static of(): FirstLine { + return new FirstLine(); + } + + private constructor() { + super("first-line"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace FirstLine { + export const parse = PseudoElementSelector.parseLegacy( + "first-line", + FirstLine.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/grammar-error.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/grammar-error.ts new file mode 100644 index 0000000000..480d39ef2a --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/grammar-error.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-grammar-error} + */ +export class GrammarError extends PseudoElementSelector<"grammar-error"> { + public static of(): GrammarError { + return new GrammarError(); + } + + private constructor() { + super("grammar-error"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace GrammarError { + export const parse = PseudoElementSelector.parseNonLegacy( + "grammar-error", + GrammarError.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/index.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/index.ts new file mode 100644 index 0000000000..a4fa58d1dd --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/index.ts @@ -0,0 +1,86 @@ +import type { Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Slice } from "@siteimprove/alfa-slice"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import { Absolute } from "../../../selector"; +import { After } from "./after"; +import { Backdrop } from "./backdrop"; +import { Before } from "./before"; +import { Cue } from "./cue"; +import { CueRegion } from "./cue-region"; +import { FileSelectorButton } from "./file-selector-button"; +import { FirstLetter } from "./first-letter"; +import { FirstLine } from "./first-line"; +import { GrammarError } from "./grammar-error"; +import { Marker } from "./marker"; +import { Part } from "./part"; +import { Placeholder } from "./placeholder"; +import { Selection } from "./selection"; +import { Slotted } from "./slotted"; +import { SpellingError } from "./spelling-error"; +import { TargetText } from "./target-text"; + +import { PseudoElementSelector } from "./pseudo-element"; + +const { either } = Parser; + +/** + * @public + */ +export type PseudoElement = + | After + | Backdrop + | Before + | Cue + | CueRegion + | FileSelectorButton + | FirstLetter + | FirstLine + | GrammarError + | Marker + | Part + | Placeholder + | Selection + | Slotted + | SpellingError + | TargetText; + +/** + * @public + */ +export namespace PseudoElement { + export type JSON = PseudoElementSelector.JSON; + + export function isPseudoElement( + value: unknown, + ): value is PseudoElementSelector { + // Note: this is not totally true as we could extend PseudoElementSelector + // without making it a PseudoElement. We're likely having other problems in + // that case… + return value instanceof PseudoElementSelector; + } + + export function parse( + parseSelector: Thunk>, + ): CSSParser { + return either, PseudoElement, string>( + After.parse, + Before.parse, + Cue.parse(parseSelector), + CueRegion.parse(parseSelector), + FirstLetter.parse, + FirstLine.parse, + Backdrop.parse, + FileSelectorButton.parse, + GrammarError.parse, + Marker.parse, + Part.parse, + Placeholder.parse, + Selection.parse, + Slotted.parse(parseSelector), + SpellingError.parse, + TargetText.parse, + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/marker.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/marker.ts new file mode 100644 index 0000000000..1a7971d187 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/marker.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#marker-pseudo} + */ +export class Marker extends PseudoElementSelector<"marker"> { + public static of(): Marker { + return new Marker(); + } + + private constructor() { + super("marker"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace Marker { + export const parse = PseudoElementSelector.parseNonLegacy( + "marker", + Marker.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/part.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/part.ts new file mode 100644 index 0000000000..eb0fc18e65 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/part.ts @@ -0,0 +1,66 @@ +import { Array } from "@siteimprove/alfa-array"; +import { Function, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; + +import { PseudoElementSelector } from "./pseudo-element"; + +const { map, separatedList } = Parser; + +/** + * {@link https://drafts.csswg.org/css-shadow-parts-1/#part} + */ +export class Part extends PseudoElementSelector<"part"> { + public static of(idents: Iterable): Part { + return new Part(Array.from(idents)); + } + + private readonly _idents: ReadonlyArray; + + private constructor(idents: Array) { + super("part"); + this._idents = idents; + } + + /** @public (knip) */ + public get idents(): Iterable { + return this._idents; + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public equals(value: Part): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof Part && Array.equals(value._idents, this._idents); + } + + public toJSON(): Part.JSON { + return { + ...super.toJSON(), + idents: Array.toJSON(this._idents), + }; + } + + public toString(): string { + return `::${this.name}(${this._idents})`; + } +} + +export namespace Part { + export interface JSON extends PseudoElementSelector.JSON<"part"> { + idents: Array; + } + + export const parse = map( + Function.parse( + "part", + separatedList(Token.parseIdent(), Token.parseWhitespace), + ), + ([_, idents]) => Part.of(idents), + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/placeholder.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/placeholder.ts new file mode 100644 index 0000000000..588413d133 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/placeholder.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#placeholder-pseudo} + */ +export class Placeholder extends PseudoElementSelector<"placeholder"> { + public static of(): Placeholder { + return new Placeholder(); + } + + private constructor() { + super("placeholder"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace Placeholder { + export const parse = PseudoElementSelector.parseNonLegacy( + "placeholder", + Placeholder.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts new file mode 100644 index 0000000000..76987b114b --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/pseudo-element.ts @@ -0,0 +1,62 @@ +import { type Parser as CSSParser, Token } from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import { WithName } from "../../selector"; + +const { map, right, take, takeBetween } = Parser; +const { parseColon, parseIdent } = Token; + +export abstract class PseudoElementSelector< + N extends string = string, +> extends WithName<"pseudo-element", N> { + protected constructor(name: N) { + super("pseudo-element", name); + } + + public equals(value: PseudoElementSelector): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return value instanceof PseudoElementSelector && super.equals(value); + } + + public toJSON(): PseudoElementSelector.JSON { + return { + ...super.toJSON(), + }; + } + + public toString(): string { + return `::${this._name}`; + } +} + +export namespace PseudoElementSelector { + export interface JSON + extends WithName.JSON<"pseudo-element", N> {} + + /** + * Parses a non-functional, non-legacy pseudo-element (`::`) + */ + export function parseNonLegacy( + name: string, + of: Thunk, + ): CSSParser { + return map(right(take(parseColon, 2), parseIdent(name)), of); + } + + /** + * Parses a non-functional, legacy pseudo-element (`::` or `:`) + */ + export function parseLegacy( + name: string, + of: Thunk, + ): CSSParser { + return map( + right(takeBetween(Token.parseColon, 1, 2), Token.parseIdent(name)), + of, + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/selection.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/selection.ts new file mode 100644 index 0000000000..2e6d4c07b6 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/selection.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-selection} + */ +export class Selection extends PseudoElementSelector<"selection"> { + public static of(): Selection { + return new Selection(); + } + + private constructor() { + super("selection"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace Selection { + export const parse = PseudoElementSelector.parseNonLegacy( + "selection", + Selection.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/slotted.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/slotted.ts new file mode 100644 index 0000000000..7bd32117d0 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/slotted.ts @@ -0,0 +1,85 @@ +import { Array } from "@siteimprove/alfa-array"; +import { + Function, + type Parser as CSSParser, + Token, +} from "@siteimprove/alfa-css"; +import { Parser } from "@siteimprove/alfa-parser"; +import type { Thunk } from "@siteimprove/alfa-thunk"; + +import type { Absolute } from "../../../selector"; + +import { Compound } from "../../compound"; +import { Simple } from "../../simple"; + +import { PseudoElementSelector } from "./pseudo-element"; + +const { map, separatedList } = Parser; + +/** + * {@link https://drafts.csswg.org/css-scoping/#slotted-pseudo} + */ +export class Slotted extends PseudoElementSelector<"slotted"> { + public static of(selectors: Iterable): Slotted { + return new Slotted(Array.from(selectors)); + } + + private readonly _selectors: ReadonlyArray; + + private constructor(selectors: Array) { + super("slotted"); + this._selectors = selectors; + } + + /** @public (knip) */ + public get selectors(): Iterable { + return this._selectors; + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } + + public equals(value: Slotted): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Slotted && + Array.equals(value._selectors, this._selectors) + ); + } + + public toJSON(): Slotted.JSON { + return { + ...super.toJSON(), + selectors: Array.toJSON(this._selectors), + }; + } + + public toString(): string { + return `::${this.name}(${this._selectors})`; + } +} + +export namespace Slotted { + export interface JSON extends PseudoElementSelector.JSON<"slotted"> { + selectors: Array; + } + + export function parse( + parseSelector: Thunk>, + ): CSSParser { + return map( + Function.parse("slotted", () => + separatedList( + Compound.parseCompound(parseSelector), + Token.parseWhitespace, + ), + ), + ([_, selectors]) => Slotted.of(selectors), + ); + } +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/spelling-error.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/spelling-error.ts new file mode 100644 index 0000000000..368b576254 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/spelling-error.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-spelling-error} + */ +export class SpellingError extends PseudoElementSelector<"spelling-error"> { + public static of(): SpellingError { + return new SpellingError(); + } + + private constructor() { + super("spelling-error"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace SpellingError { + export const parse = PseudoElementSelector.parseNonLegacy( + "spelling-error", + SpellingError.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/pseudo-element/target-text.ts b/packages/alfa-selector/src/selector/simple/pseudo-element/target-text.ts new file mode 100644 index 0000000000..c13bb58c93 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/pseudo-element/target-text.ts @@ -0,0 +1,26 @@ +import { PseudoElementSelector } from "./pseudo-element"; + +/** + * {@link https://drafts.csswg.org/css-pseudo-4/#selectordef-target-text} + */ +export class TargetText extends PseudoElementSelector<"target-text"> { + public static of(): TargetText { + return new TargetText(); + } + + private constructor() { + super("target-text"); + } + + /** @public (knip) */ + public *[Symbol.iterator](): Iterator { + yield this; + } +} + +export namespace TargetText { + export const parse = PseudoElementSelector.parseNonLegacy( + "target-text", + TargetText.of, + ); +} diff --git a/packages/alfa-selector/src/selector/simple/type.ts b/packages/alfa-selector/src/selector/simple/type.ts new file mode 100644 index 0000000000..5d9989648d --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/type.ts @@ -0,0 +1,96 @@ +import type { Element } from "@siteimprove/alfa-dom"; +import { Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; + +import { WithName } from "../selector"; + +import { parseName } from "./parser"; + +const { map } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#type-selector} + * + * @public + */ +export class Type extends WithName<"type"> { + public static of(namespace: Option, name: string): Type { + return new Type(namespace, name); + } + + private readonly _namespace: Option; + + private constructor(namespace: Option, name: string) { + super("type", name); + this._namespace = namespace; + } + + public get namespace(): Option { + return this._namespace; + } + + public matches(element: Element): boolean { + if (this._name !== element.name) { + return false; + } + + if (this._namespace.isNone() || this._namespace.includes("*")) { + return true; + } + + return element.namespace.equals(this._namespace); + } + + public equals(value: Type): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Type && + value._namespace.equals(this._namespace) && + value._name === this._name + ); + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Type.JSON { + return { + ...super.toJSON(), + namespace: this._namespace.getOr(null), + }; + } + + public toString(): string { + const namespace = this._namespace + .map((namespace) => `${namespace}|`) + .getOr(""); + + return `${namespace}${this._name}`; + } +} + +/** + * @public + */ +export namespace Type { + export interface JSON extends WithName.JSON<"type"> { + namespace: string | null; + } + + export function isType(value: unknown): value is Type { + return value instanceof Type; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-type-selector} + * + * @internal + */ + export const parse = map(parseName, ([namespace, name]) => + Type.of(namespace, name), + ); +} diff --git a/packages/alfa-selector/src/selector/simple/universal.ts b/packages/alfa-selector/src/selector/simple/universal.ts new file mode 100644 index 0000000000..58d07e4b88 --- /dev/null +++ b/packages/alfa-selector/src/selector/simple/universal.ts @@ -0,0 +1,96 @@ +import { Token } from "@siteimprove/alfa-css"; +import type { Element } from "@siteimprove/alfa-dom"; +import { None, type Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; + +import { Selector } from "../selector"; + +import { parseNamespace } from "./parser"; + +const { left, map, option } = Parser; + +/** + * {@link https://drafts.csswg.org/selectors/#universal-selector} + * + * @public + */ +export class Universal extends Selector<"universal"> { + public static of(namespace: Option): Universal { + return new Universal(namespace); + } + + private static readonly _empty = new Universal(None); + + public static empty(): Universal { + return this._empty; + } + + private readonly _namespace: Option; + + private constructor(namespace: Option) { + super("universal"); + this._namespace = namespace; + } + + public get namespace(): Option { + return this._namespace; + } + + public matches(element: Element): boolean { + if (this._namespace.isNone() || this._namespace.includes("*")) { + return true; + } + + return element.namespace.equals(this._namespace); + } + + public equals(value: Universal): boolean; + + public equals(value: unknown): value is this; + + public equals(value: unknown): boolean { + return ( + value instanceof Universal && value._namespace.equals(this._namespace) + ); + } + + public *[Symbol.iterator](): Iterator { + yield this; + } + + public toJSON(): Universal.JSON { + return { + ...super.toJSON(), + namespace: this._namespace.getOr(null), + }; + } + + public toString(): string { + const namespace = this._namespace + .map((namespace) => `${namespace}|`) + .getOr(""); + + return `${namespace}*`; + } +} + +/** + * @public + */ +export namespace Universal { + export interface JSON extends Selector.JSON<"universal"> { + namespace: string | null; + } + + export function isUniversal(value: unknown): value is Universal { + return value instanceof Universal; + } + + /** + * {@link https://drafts.csswg.org/selectors/#typedef-type-selector} + */ + export const parse = map( + left(option(parseNamespace), Token.parseDelim("*")), + (namespace) => Universal.of(namespace), + ); +} diff --git a/packages/alfa-selector/test/basic.spec.ts b/packages/alfa-selector/test/basic.spec.ts new file mode 100644 index 0000000000..4d399b6383 --- /dev/null +++ b/packages/alfa-selector/test/basic.spec.ts @@ -0,0 +1,189 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Attribute } from "../src"; +import { serialize } from "./parser"; + +test(".parse() parses a type selector", (t) => { + t.deepEqual(serialize("div"), { + type: "type", + name: "div", + namespace: null, + }); +}); + +test(".parse() parses an uppercase type selector", (t) => { + t.deepEqual(serialize("DIV"), { + type: "type", + name: "DIV", + namespace: null, + }); +}); + +test(".parse() parses a type selector with a namespace", (t) => { + t.deepEqual(serialize("svg|a"), { + type: "type", + name: "a", + namespace: "svg", + }); +}); + +test(".parse() parses a type selector with an empty namespace", (t) => { + t.deepEqual(serialize("|a"), { + type: "type", + name: "a", + namespace: "", + }); +}); + +test(".parse() parses a type selector with the universal namespace", (t) => { + t.deepEqual(serialize("*|a"), { + type: "type", + name: "a", + namespace: "*", + }); +}); + +test(".parse() parses the universal selector", (t) => { + t.deepEqual(serialize("*"), { + type: "universal", + namespace: null, + }); +}); + +test(".parse() parses the universal selector with an empty namespace", (t) => { + t.deepEqual(serialize("|*"), { + type: "universal", + namespace: "", + }); +}); + +test(".parse() parses the universal selector with the universal namespace", (t) => { + t.deepEqual(serialize("*|*"), { + type: "universal", + namespace: "*", + }); +}); + +test(".parse() parses a class selector", (t) => { + t.deepEqual(serialize(".foo"), { + type: "class", + name: "foo", + }); +}); + +test(".parse() parses an ID selector", (t) => { + t.deepEqual(serialize("#foo"), { + type: "id", + name: "foo", + }); +}); + +test(".parse() parses an attribute selector without a value", (t) => { + t.deepEqual(serialize("[foo]"), { + type: "attribute", + name: "foo", + namespace: null, + value: null, + matcher: null, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with an ident value", (t) => { + t.deepEqual(serialize("[foo=bar]"), { + type: "attribute", + name: "foo", + namespace: null, + value: "bar", + matcher: Attribute.Matcher.Equal, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a string value", (t) => { + t.deepEqual(serialize('[foo="bar"]'), { + type: "attribute", + name: "foo", + namespace: null, + value: "bar", + matcher: Attribute.Matcher.Equal, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a matcher", (t) => { + t.deepEqual(serialize("[foo*=bar]"), { + type: "attribute", + name: "foo", + namespace: null, + value: "bar", + matcher: Attribute.Matcher.Substring, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a casing modifier", (t) => { + t.deepEqual(serialize("[foo=bar i]"), { + type: "attribute", + name: "foo", + namespace: null, + value: "bar", + matcher: Attribute.Matcher.Equal, + modifier: "i", + }); +}); + +test(".parse() parses an attribute selector with a namespace", (t) => { + t.deepEqual(serialize("[foo|bar]"), { + type: "attribute", + name: "bar", + namespace: "foo", + value: null, + matcher: null, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a namespace", (t) => { + t.deepEqual(serialize("[*|foo]"), { + type: "attribute", + name: "foo", + namespace: "*", + value: null, + matcher: null, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a namespace", (t) => { + t.deepEqual(serialize("[|foo]"), { + type: "attribute", + name: "foo", + namespace: "", + value: null, + matcher: null, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a namespace", (t) => { + t.deepEqual(serialize("[foo|bar=baz]"), { + type: "attribute", + name: "bar", + namespace: "foo", + value: "baz", + matcher: Attribute.Matcher.Equal, + modifier: null, + }); +}); + +test(".parse() parses an attribute selector with a namespace", (t) => { + t.deepEqual(serialize("[foo|bar|=baz]"), { + type: "attribute", + name: "bar", + namespace: "foo", + value: "baz", + matcher: Attribute.Matcher.DashMatch, + modifier: null, + }); +}); diff --git a/packages/alfa-selector/test/complex.spec.ts b/packages/alfa-selector/test/complex.spec.ts new file mode 100644 index 0000000000..c74fc17538 --- /dev/null +++ b/packages/alfa-selector/test/complex.spec.ts @@ -0,0 +1,244 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Combinator } from "../src"; +import { serialize } from "./parser"; + +test(".parse() parses a single descendant selector", (t) => { + t.deepEqual(serialize("div .foo"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }); +}); + +test(".parse() parses a single descendant selector with a right-hand type selector", (t) => { + t.deepEqual(serialize("div span"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "div", namespace: null }, + right: { type: "type", name: "span", namespace: null }, + }); +}); + +test(".parse() parses a double descendant selector", (t) => { + t.deepEqual(serialize("div .foo #bar"), { + type: "complex", + combinator: Combinator.Descendant, + left: { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }, + right: { type: "id", name: "bar" }, + }); +}); + +test(".parse() parses a direct descendant selector", (t) => { + t.deepEqual(serialize("div > .foo"), { + type: "complex", + combinator: Combinator.DirectDescendant, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }); +}); + +test(".parse() parses a sibling selector", (t) => { + t.deepEqual(serialize("div ~ .foo"), { + type: "complex", + combinator: Combinator.Sibling, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }); +}); + +test(".parse() parses a direct sibling selector", (t) => { + t.deepEqual(serialize("div + .foo"), { + type: "complex", + combinator: Combinator.DirectSibling, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }); +}); + +test(".parse() parses a compound selector relative to a class selector", (t) => { + t.deepEqual(serialize(".foo div.bar"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "class", name: "foo" }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "class", name: "bar" }, + ], + }, + }); +}); + +test(".parse() parses a compound selector relative to a compound selector", (t) => { + t.deepEqual(serialize("span.foo div.bar"), { + type: "complex", + combinator: Combinator.Descendant, + left: { + type: "compound", + selectors: [ + { type: "type", name: "span", namespace: null }, + { type: "class", name: "foo" }, + ], + }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "class", name: "bar" }, + ], + }, + }); +}); + +test(".parse() parses a descendant selector relative to a sibling selector", (t) => { + t.deepEqual(serialize("div ~ span .foo"), { + type: "complex", + combinator: Combinator.Descendant, + left: { + type: "complex", + combinator: Combinator.Sibling, + left: { type: "type", name: "div", namespace: null }, + right: { type: "type", name: "span", namespace: null }, + }, + right: { type: "class", name: "foo" }, + }); +}); + +test(".parse() parses an attribute selector when part of a descendant selector", (t) => { + t.deepEqual(serialize("div [foo]"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "div", namespace: null }, + right: { + type: "attribute", + name: "foo", + namespace: null, + value: null, + matcher: null, + modifier: null, + }, + }); +}); + +test(".parse() parses an attribute selector when part of a compound selector relative to a class selector", (t) => { + t.deepEqual(serialize(".foo div[foo]"), { + type: "complex", + combinator: Combinator.Descendant, + left: { + type: "class", + name: "foo", + }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { + type: "attribute", + name: "foo", + namespace: null, + value: null, + matcher: null, + modifier: null, + }, + ], + }, + }); +}); + +test(".parse() parses a pseudo-element selector when part of a descendant selector", (t) => { + t.deepEqual(serialize("div ::before"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "div", namespace: null }, + right: { type: "pseudo-element", name: "before" }, + }); +}); + +test(".parse() parses a pseudo-element selector when part of a compound selector relative to a class selector", (t) => { + t.deepEqual(serialize(".foo div::before"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "class", name: "foo" }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "pseudo-element", name: "before" }, + ], + }, + }); +}); + +test(".parse() parses a pseudo-class selector when part of a compound selector relative to a class selector", (t) => { + t.deepEqual(serialize(".foo div:hover"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "class", name: "foo" }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "pseudo-class", name: "hover" }, + ], + }, + }); +}); + +test(".parse() parses a compound type, class, and pseudo-class selector relative to a class selector", (t) => { + t.deepEqual(serialize(".foo div.bar:hover"), { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "class", name: "foo" }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "class", name: "bar" }, + { type: "pseudo-class", name: "hover" }, + ], + }, + }); +}); + +test(".parse() parses a simple selector relative to a compound selector", (t) => { + t.deepEqual(serialize(".foo > div.bar"), { + type: "complex", + combinator: Combinator.DirectDescendant, + left: { type: "class", name: "foo" }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "class", name: "bar" }, + ], + }, + }); +}); + +test(".parse() parses a relative selector relative to a compound selector", (t) => { + t.deepEqual(serialize(".foo > .bar + div.baz"), { + type: "complex", + combinator: Combinator.DirectSibling, + left: { + type: "complex", + combinator: Combinator.DirectDescendant, + left: { type: "class", name: "foo" }, + right: { type: "class", name: "bar" }, + }, + right: { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "class", name: "baz" }, + ], + }, + }); +}); diff --git a/packages/alfa-selector/test/compound.spec.ts b/packages/alfa-selector/test/compound.spec.ts new file mode 100644 index 0000000000..6926546cc2 --- /dev/null +++ b/packages/alfa-selector/test/compound.spec.ts @@ -0,0 +1,60 @@ +import { test } from "@siteimprove/alfa-test"; + +import { serialize } from "./parser"; + +test(".parse() parses a compound selector", (t) => { + t.deepEqual(serialize("#foo.bar"), { + type: "compound", + selectors: [ + { type: "id", name: "foo" }, + { type: "class", name: "bar" }, + ], + }); +}); + +test(".parse() parses a compound selector with a type in prefix position", (t) => { + t.deepEqual(serialize("div.foo"), { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "class", name: "foo" }, + ], + }); +}); + +test(".parse() parses an attribute selector when part of a compound selector", (t) => { + t.deepEqual(serialize(".foo[foo]"), { + type: "compound", + selectors: [ + { type: "class", name: "foo" }, + { + type: "attribute", + name: "foo", + namespace: null, + value: null, + matcher: null, + modifier: null, + }, + ], + }); +}); + +test(".parse() parses a pseudo-element selector when part of a compound selector", (t) => { + t.deepEqual(serialize(".foo::before"), { + type: "compound", + selectors: [ + { type: "class", name: "foo" }, + { type: "pseudo-element", name: "before" }, + ], + }); +}); + +test(".parse() parses a pseudo-class selector when part of a compound selector", (t) => { + t.deepEqual(serialize("div:hover"), { + type: "compound", + selectors: [ + { type: "type", name: "div", namespace: null }, + { type: "pseudo-class", name: "hover" }, + ], + }); +}); diff --git a/packages/alfa-selector/test/list.spec.ts b/packages/alfa-selector/test/list.spec.ts new file mode 100644 index 0000000000..c80cf94d49 --- /dev/null +++ b/packages/alfa-selector/test/list.spec.ts @@ -0,0 +1,81 @@ +import { test } from "@siteimprove/alfa-test"; + +import { Combinator } from "../src"; +import { serialize } from "./parser"; + +test(".parse() parses a list of simple selectors", (t) => { + t.deepEqual(serialize(".foo, .bar, .baz"), { + type: "list", + selectors: [ + { type: "class", name: "foo" }, + { type: "class", name: "bar" }, + { type: "class", name: "baz" }, + ], + }); +}); + +test(".parse() parses a list of simple and compound selectors", (t) => { + t.deepEqual(serialize(".foo, #bar.baz"), { + type: "list", + selectors: [ + { type: "class", name: "foo" }, + { + type: "compound", + selectors: [ + { type: "id", name: "bar" }, + { type: "class", name: "baz" }, + ], + }, + ], + }); +}); + +test(".parse() parses a list of descendant selectors", (t) => { + t.deepEqual(serialize("div .foo, span .baz"), { + type: "list", + selectors: [ + { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }, + { + type: "complex", + combinator: Combinator.Descendant, + left: { type: "type", name: "span", namespace: null }, + right: { type: "class", name: "baz" }, + }, + ], + }); +}); + +test(".parse() parses a list of sibling selectors", (t) => { + t.deepEqual(serialize("div ~ .foo, span ~ .baz"), { + type: "list", + selectors: [ + { + type: "complex", + combinator: Combinator.Sibling, + left: { type: "type", name: "div", namespace: null }, + right: { type: "class", name: "foo" }, + }, + { + type: "complex", + combinator: Combinator.Sibling, + left: { type: "type", name: "span", namespace: null }, + right: { type: "class", name: "baz" }, + }, + ], + }); +}); + +test(".parse() parses a list of selectors with no whitespace", (t) => { + t.deepEqual(serialize(".foo,.bar"), { + type: "list", + selectors: [ + { type: "class", name: "foo" }, + { type: "class", name: "bar" }, + ], + }); +}); diff --git a/packages/alfa-selector/test/parser.ts b/packages/alfa-selector/test/parser.ts new file mode 100644 index 0000000000..a58ad51a10 --- /dev/null +++ b/packages/alfa-selector/test/parser.ts @@ -0,0 +1,18 @@ +import { Lexer } from "@siteimprove/alfa-css"; + +import { Selector } from "../src"; + +/** + * @internal + */ +export function parseErr(input: string) { + return Selector.parse(Lexer.lex(input)).map(([, selector]) => selector); +} + +export function parse(input: string) { + return Selector.parse(Lexer.lex(input)).getUnsafe()[1]; +} + +export function serialize(input: string) { + return parse(input).toJSON(); +} diff --git a/packages/alfa-selector/test/pseudo-class.spec.tsx b/packages/alfa-selector/test/pseudo-class.spec.tsx new file mode 100644 index 0000000000..715d66eee5 --- /dev/null +++ b/packages/alfa-selector/test/pseudo-class.spec.tsx @@ -0,0 +1,338 @@ +import { test } from "@siteimprove/alfa-test"; +import { Context } from "../src"; + +import { parse, serialize } from "./parser"; + +test(".parse() parses a named pseudo-class selector", (t) => { + t.deepEqual(serialize(":hover"), { + type: "pseudo-class", + name: "hover", + }); +}); + +test(".parse() parses :host pseudo-class selector", (t) => { + t.deepEqual(serialize(":host"), { + type: "pseudo-class", + name: "host", + }); +}); + +test(".parse() parses a functional pseudo-class selector", (t) => { + t.deepEqual(serialize(":not(.foo)"), { + type: "pseudo-class", + name: "not", + selector: { + type: "class", + name: "foo", + }, + }); +}); + +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"); + + const a =

; + const b =

; + +

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

; + const b =

; + +

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

; + const b =

; + +

+ {a} + Hello +
; + +
+ {b} +

+ Hello +

; + + t.equal(selector.matches(a), true); + t.equal(selector.matches(b), false); +}); + +test("#matches() checks if an element matches an :nth-of-type selector", (t) => { + const selector = parse(":nth-of-type(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-of-type selector", (t) => { + const selector = parse(":nth-last-of-type(odd)"); + + const a =

; + const b =

; + const c =

; + const d =

; + +

+ {a} + {b} + {c} +
+ {d} + Hello + +
; + + 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-of-type selector", (t) => { + const selector = parse(":first-of-type"); + + const a =

; + const b =

; + +

+
+ Hello + {a} + {b} +
; + + t.equal(selector.matches(a), true); + t.equal(selector.matches(b), false); +}); + +test("#matches() checks if an element matches a :last-of-type selector", (t) => { + const selector = parse(":last-of-type"); + + const a =

; + const b =

; + +

+ {a} + {b} + Hello +
+
; + + t.equal(selector.matches(a), false); + t.equal(selector.matches(b), true); +}); + +test("#matches() checks if an element matches a :only-of-type selector", (t) => { + const selector = parse(":only-of-type"); + + const a =

; + const b =

; + +

+ {a} + Hello +
+
; + +
+ {b} +

+ Hello +

+
; + + t.equal(selector.matches(a), true); + t.equal(selector.matches(b), false); +}); + +test("#matches() checks if an element matches a :hover selector", (t) => { + const selector = parse(":hover"); + + const p =

; + + t.equal(selector.matches(p), false); + t.equal(selector.matches(p, Context.hover(p)), true); +}); + +test("#matches() checks if an element matches a :hover selector when its descendant is hovered", (t) => { + const selector = parse(":hover"); + + const target = Hello ; + const p =

{target}
; + + t.equal(selector.matches(p, Context.hover(target)), true); +}); + +test("#matches() checks if an element matches an :active selector", (t) => { + const selector = parse(":active"); + + const p =

; + + t.equal(selector.matches(p), false); + t.equal(selector.matches(p, Context.active(p)), true); +}); + +test("#matches() checks if an element matches a :focus selector", (t) => { + const selector = parse(":focus"); + + const p =

; + + t.equal(selector.matches(p), false); + t.equal(selector.matches(p, Context.focus(p)), true); +}); + +test("#matches() checks if an element matches a :focus-within selector", (t) => { + const selector = parse(":focus-within"); + + const button =