diff --git a/.changeset/clever-falcons-press.md b/.changeset/clever-falcons-press.md new file mode 100644 index 0000000000..8d57e08ffe --- /dev/null +++ b/.changeset/clever-falcons-press.md @@ -0,0 +1,8 @@ +--- +"@siteimprove/alfa-css": minor +--- + +**Added:** `Position` now accept calculations in any of their components. + +To fully resolve a `Position`, the resolver needs both a length resolver, and two percentage bases, one for each dimension. +To partially resolve a `Position`, only a length resolver is needed. diff --git a/.changeset/cuddly-items-repeat.md b/.changeset/cuddly-items-repeat.md new file mode 100644 index 0000000000..66ebdf7740 --- /dev/null +++ b/.changeset/cuddly-items-repeat.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-css": minor +--- + +**Breaking:** `Position.Component` cannot be raw `LengthPercentage` anymore. + +Instead, they must always be a full `Position.Side` (or the "center" keyword) i.e. include an explicit side to count from. This side is automatically added when parsing raw `LengthPercentage`. diff --git a/.changeset/shy-schools-flash.md b/.changeset/shy-schools-flash.md new file mode 100644 index 0000000000..9a40209bcb --- /dev/null +++ b/.changeset/shy-schools-flash.md @@ -0,0 +1,9 @@ +--- +"@siteimprove/alfa-css": minor +--- + +**Changed:** The `Position` type requires more type paramters. + +Instead of just accepting the horizontal and vertical components, the type now also requires the horizontal and vertical keywords list (as first and second paramter). The components parameter default to `Position.Component` (reps. `V`) for keywords `H` (resp. `V`). + +The type also accepts a `CALC` paramter indicating whether it may have calculations. diff --git a/.changeset/silver-papayas-develop.md b/.changeset/silver-papayas-develop.md new file mode 100644 index 0000000000..43b8927b4b --- /dev/null +++ b/.changeset/silver-papayas-develop.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-css": minor +--- + +**Removed:** The unused `Side.isCenter()` predicate is no longer available. diff --git a/.changeset/smart-mayflies-jog.md b/.changeset/smart-mayflies-jog.md new file mode 100644 index 0000000000..ae439db082 --- /dev/null +++ b/.changeset/smart-mayflies-jog.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-css": patch +--- + +**Added:** `Position.Side.of` now also accepts an optional offset, as well as an `Option`. diff --git a/.changeset/thick-ways-clap.md b/.changeset/thick-ways-clap.md new file mode 100644 index 0000000000..8e40b2dc61 --- /dev/null +++ b/.changeset/thick-ways-clap.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-css": patch +--- + +**Added:** The `LengthPercentage` type now accepts an optional `CALC` boolean parameter to indicate whether it contains calculations. diff --git a/docs/review/api/alfa-css.api.md b/docs/review/api/alfa-css.api.md index 40b17c4232..ca5014162e 100644 --- a/docs/review/api/alfa-css.api.md +++ b/docs/review/api/alfa-css.api.md @@ -259,7 +259,7 @@ export namespace Box { // Warning: (ae-forgotten-export) The symbol "BasicShape" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export class Circle extends BasicShape<"circle"> { +export class Circle extends BasicShape<"circle"> { // (undocumented) get center(): P; // (undocumented) @@ -269,7 +269,7 @@ export class Circle ex // (undocumented) hash(hash: Hash): void; // (undocumented) - static of(radius: R, center: P): Circle; + static of(radius: R, center: P): Circle; // (undocumented) get radius(): R; // (undocumented) @@ -526,7 +526,7 @@ namespace Dimension_2 { } // @public (undocumented) -export class Ellipse extends BasicShape<"ellipse"> { +export class Ellipse extends BasicShape<"ellipse"> { // (undocumented) get center(): P; // (undocumented) @@ -536,7 +536,7 @@ export class Ellipse e // (undocumented) hash(hash: Hash): void; // (undocumented) - static of(rx: R, ry: R, center: P): Ellipse; + static of(rx: R, ry: R, center: P): Ellipse; // (undocumented) resolve(): Ellipse; // (undocumented) @@ -1128,7 +1128,7 @@ namespace Length_2 { } // @public (undocumented) -export type LengthPercentage = LengthPercentage.Calculated | Length.Calculated | Length.Fixed | Percentage.Calculated | Percentage.Fixed; +export type LengthPercentage = CALC extends true ? LengthPercentage.Calculated | Length.Calculated | Percentage.Calculated : CALC extends false ? Length.Fixed | Percentage.Fixed : LengthPercentage.Calculated | Length.Calculated | Percentage.Calculated | Length.Fixed | Percentage.Fixed; // @public (undocumented) export namespace LengthPercentage { @@ -1189,7 +1189,9 @@ export namespace LengthPercentage { // (undocumented) export type Resolver = Length.Resolver & Percentage.Resolver<"length", Canonical>; const // (undocumented) - parse: Parser_2, LengthPercentage, string, []>; + parse: Parser_2, Length.Calculated | Length.Fixed | Calculated | Percentage.Calculated | Percentage.Fixed, string, []>; + const // @internal (undocumented) + parseBase: Parser>; {}; } @@ -2016,129 +2018,63 @@ export namespace Polygon { } // @public (undocumented) -export class Position = Position.Component, V extends Position.Component = Position.Component> extends Value<"position", false> { +export class Position = Position.Component, VC extends Position.Component = Position.Component, CALC extends boolean = boolean> extends Value<"position", CALC> implements Resolvable, Position.Resolver> { // (undocumented) equals(value: unknown): value is this; // (undocumented) hash(hash: Hash): void; // (undocumented) - get horizontal(): H; + get horizontal(): HC; // (undocumented) - static of, V extends Position.Component>(horizontal: H, vertical: V): Position; + static of = Position.Component, VC extends Position.Component = Position.Component>(horizontal: HC, vertical: VC): Position>; // (undocumented) - resolve(): Position; + resolve(resolver: Position.Resolver): Position.Canonical; // (undocumented) toJSON(): Position.JSON; // (undocumented) toString(): string; // (undocumented) - get vertical(): V; + get vertical(): VC; } // @public (undocumented) export namespace Position { + // Warning: (ae-forgotten-export) The symbol "Keywords" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "Component" needs to be exported by the entry point index.d.ts + // // (undocumented) - export type Canonical = Position, Component.Canonical>; - // (undocumented) - export type Component = Keywords.Center | Offset | Side>; - // (undocumented) - export namespace Component { - // (undocumented) - export type Canonical = Percentage.Canonical | Keywords.Center | Length.Canonical | Side.Canonical; - // (undocumented) - export type JSON = Keyword.JSON | Length.Fixed.JSON | Percentage.Fixed.JSON | Side.JSON; - const // Warning: (ae-incompatible-release-tags) The symbol "parseHorizontal" is marked as @public, but its signature references "Type" which is marked as @internal - // - // (undocumented) - parseHorizontal: Parser_2, Percentage.Fixed | Length.Fixed | Keyword<"center"> | Side, Offset>, string, []>; - const // Warning: (ae-incompatible-release-tags) The symbol "parseVertical" is marked as @public, but its signature references "Type" which is marked as @internal - // - // (undocumented) - parseVertical: Parser_2, Percentage.Fixed | Length.Fixed | Keyword<"center"> | Side, Offset>, string, []>; - } + export type Canonical = Position, Component_2.Canonical, false>; + // @internal (undocumented) + export type Fixed = Position, Component_2.Fixed, false>; // (undocumented) export interface JSON extends Value.JSON<"position"> { // (undocumented) - horizontal: Component.JSON; + horizontal: Component_2.JSON; // (undocumented) - vertical: Component.JSON; + vertical: Component_2.JSON; } - // (undocumented) - export namespace Keywords { - // (undocumented) - export type Center = Keyword<"center">; - const // @internal (undocumented) - parseCenter: Parser>; - // (undocumented) - export type Horizontal = Keyword<"left"> | Keyword<"right">; - const // @internal (undocumented) - parseVertical: Parser>; - // (undocumented) - export type Vertical = Keyword<"top"> | Keyword<"bottom">; - const // @internal (undocumented) - parseHorizontal: Parser>; - } - // (undocumented) - export type Offset = Length.Fixed | Percentage.Fixed; - // (undocumented) - export namespace Offset { - const // Warning: (ae-incompatible-release-tags) The symbol "parse" is marked as @public, but its signature references "Type" which is marked as @internal - // - // (undocumented) - parse: Parser_2, Percentage.Fixed | Length.Fixed, string, []>; - } - // (undocumented) export function parse(legacySyntax?: boolean): Parser; + import Keywords = keywords.Keywords; + import Side = side.Side; + import Component = component.Component; + // @internal (undocumented) + export function parseBase(legacySyntax?: boolean): Parser; // (undocumented) - export class Side extends Value<"side", false> { - // (undocumented) - equals(value: unknown): value is this; - // (undocumented) - hash(hash: Hash): void; - // (undocumented) - isCenter(): boolean; - // (undocumented) - static of(side: S, offset?: Option): Side; - // (undocumented) - get offset(): Option; - // (undocumented) - resolve(): Side; - // (undocumented) - get side(): S; - // (undocumented) - toJSON(): Side.JSON; - // (undocumented) - toString(): string; - } + export function partiallyResolve(resolver: PartialResolver): (value: Position) => PartiallyResolved; // (undocumented) - export namespace Side { + export type PartiallyResolved = Position, Component_2.PartiallyResolved>; + // (undocumented) + export type PartialResolver = Component_2.PartialResolver; + export interface Resolver extends Length.Resolver { // (undocumented) - export type Canonical = Side; + percentageHBase: Length.Canonical; // (undocumented) - export interface JSON extends Value.JSON<"side"> { - // (undocumented) - offset: Length.Fixed.JSON | Percentage.Fixed.JSON | null; - // (undocumented) - side: Keyword.JSON; - } - const // (undocumented) - parseHorizontalKeywordValue: Parser, Offset>>; - const // (undocumented) - parseHorizontalKeyword: Parser | Side, Offset>>; - const // (undocumented) - parseVerticalKeywordValue: Parser, Offset>>; - const // (undocumented) - parseVerticalKeyword: Parser | Side, Offset>>; - const // (undocumented) - parseHorizontal: Parser_2, Keyword<"center"> | Side, Offset>, string, []>; - const // (undocumented) - parseVertical: Parser_2, Keyword<"center"> | Side, Offset>, string, []>; + percentageVBase: Length.Canonical; } - {}; } // @public (undocumented) -export class Radial extends Value<"gradient", false> { +export class Radial extends Value<"gradient", false> { // (undocumented) equals(value: Radial): boolean; // (undocumented) @@ -2150,7 +2086,7 @@ export class Radial(shape: S, position: P, items: Iterable, repeats: boolean): Radial; + static of(shape: S, position: P, items: Iterable, repeats: boolean): Radial; // (undocumented) get position(): P; // (undocumented) @@ -2168,7 +2104,7 @@ export class Radial; + export type Canonical = Radial; // (undocumented) export class Circle implements Equatable, Hashable, Serializable { // (undocumented) diff --git a/docs/review/api/alfa-style.api.md b/docs/review/api/alfa-style.api.md index e1bab902b1..38897abc0c 100644 --- a/docs/review/api/alfa-style.api.md +++ b/docs/review/api/alfa-style.api.md @@ -19,6 +19,7 @@ import { Image } from '@siteimprove/alfa-css'; import { Iterable as Iterable_2 } from '@siteimprove/alfa-iterable'; import * as json from '@siteimprove/alfa-json'; import { Keyword } from '@siteimprove/alfa-css'; +import { Keywords } from '@siteimprove/alfa-css/src/value/position/keywords'; import { Length } from '@siteimprove/alfa-css'; import { LengthPercentage } from '@siteimprove/alfa-css'; import { List } from '@siteimprove/alfa-css'; @@ -40,6 +41,7 @@ import { Rotate } from '@siteimprove/alfa-css'; import { Serializable } from '@siteimprove/alfa-json'; import { Shadow } from '@siteimprove/alfa-css'; import { Shape } from '@siteimprove/alfa-css'; +import { Side } from '@siteimprove/alfa-css/src/value/position/side'; import { Slice } from '@siteimprove/alfa-slice'; import { String as String_2 } from '@siteimprove/alfa-css'; import { Text } from '@siteimprove/alfa-dom'; @@ -180,8 +182,8 @@ export namespace Longhands { readonly "background-color": Longhand; readonly "background-image": Longhand, List, boolean>>; readonly "background-origin": Longhand, List>; - readonly "background-position-x": Longhand, List | Percentage.Fixed) | Position | Percentage.Fixed>, boolean>>; - readonly "background-position-y": Longhand, List | Percentage.Fixed) | Position | Percentage.Fixed>, boolean>>; + readonly "background-position-x": Longhand, List, boolean>>; + readonly "background-position-y": Longhand, List, boolean>>; readonly "background-repeat-x": Longhand, List>; readonly "background-repeat-y": Longhand, List>; readonly "background-size": Longhand, List, LengthPercentage | Keyword<"auto">], boolean> | Keyword<"cover"> | Keyword<"contain">, boolean>>; @@ -301,9 +303,9 @@ export namespace Resolver { // (undocumented) export function lengthPercentage(base: Length.Canonical, style: Style): LengthPercentage.Resolver; // (undocumented) - export function position(position: Position, style: Style): Position.Canonical; + export function position(position: Position.Fixed, style: Style): Position.Fixed; // (undocumented) - export function positionComponent(position: Position.Component, style: Style): Position.Component.Canonical; + export function positionComponent(position: Position.Component.Fixed, style: Style): Position.Component.Fixed; } // Warning: (ae-forgotten-export) The symbol "Name" needs to be exported by the entry point index.d.ts diff --git a/packages/alfa-css/src/value/image/gradient-radial.ts b/packages/alfa-css/src/value/image/gradient-radial.ts index 1e7f4b6fec..4c8de84824 100644 --- a/packages/alfa-css/src/value/image/gradient-radial.ts +++ b/packages/alfa-css/src/value/image/gradient-radial.ts @@ -26,12 +26,12 @@ const { map, either, pair, option, left, right, delimited, take } = Parser; export class Radial< I extends Gradient.Item = Gradient.Item, S extends Radial.Shape = Radial.Shape, - P extends Position = Position + P extends Position.Fixed = Position.Fixed > extends Value<"gradient", false> { public static of< I extends Gradient.Item = Gradient.Item, S extends Radial.Shape = Radial.Shape, - P extends Position = Position + P extends Position.Fixed = Position.Fixed >( shape: S, position: P, @@ -135,7 +135,7 @@ export namespace Radial { export type Canonical = Radial< Gradient.Hint.Canonical | Gradient.Stop.Canonical, Radial.Circle.Canonical | Radial.Ellipse.Canonical | Radial.Extent, - Position.Canonical + Position.Fixed >; export interface JSON extends Value.JSON<"gradient"> { @@ -365,7 +365,7 @@ export namespace Radial { const parsePosition = right( delimited(option(Token.parseWhitespace), Keyword.parse("at")), - Position.parse(false /* legacySyntax */) + Position.parseBase(false /* legacySyntax */) ); const parseCircleShape = Keyword.parse("circle"); diff --git a/packages/alfa-css/src/value/numeric/length-percentage.ts b/packages/alfa-css/src/value/numeric/length-percentage.ts index 6cd00a912d..a45d538cbd 100644 --- a/packages/alfa-css/src/value/numeric/length-percentage.ts +++ b/packages/alfa-css/src/value/numeric/length-percentage.ts @@ -4,7 +4,7 @@ import { Slice } from "@siteimprove/alfa-slice"; import { Math } from "../../calculation"; import * as Base from "../../calculation/numeric"; -import { Token } from "../../syntax"; +import { Parser as CSSParser, Token } from "../../syntax"; import { Unit } from "../../unit"; import type { Resolvable } from "../resolvable"; @@ -19,12 +19,19 @@ const { either, map } = Parser; /** * @public */ -export type LengthPercentage = - | LengthPercentage.Calculated - | Length.Calculated - | Length.Fixed - | Percentage.Calculated - | Percentage.Fixed; +export type LengthPercentage< + U extends Unit.Length = Unit.Length, + CALC extends boolean = boolean +> = CALC extends true + ? LengthPercentage.Calculated | Length.Calculated | Percentage.Calculated + : CALC extends false + ? Length.Fixed | Percentage.Fixed + : + | LengthPercentage.Calculated + | Length.Calculated + | Percentage.Calculated + | Length.Fixed + | Percentage.Fixed; /** * @public @@ -250,4 +257,10 @@ export namespace LengthPercentage { of ) ); + + /** + * @internal + */ + export const parseBase: CSSParser> = + either(Length.parseBase, Percentage.parseBase); } diff --git a/packages/alfa-css/src/value/position.ts b/packages/alfa-css/src/value/position.ts deleted file mode 100644 index 82eef0dc16..0000000000 --- a/packages/alfa-css/src/value/position.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { Hash } from "@siteimprove/alfa-hash"; -import { Option, None } from "@siteimprove/alfa-option"; -import { Parser } from "@siteimprove/alfa-parser"; -import { Err } from "@siteimprove/alfa-result"; - -import { Keyword } from "./keyword"; -import { Length, Percentage } from "./numeric"; - -import { type Parser as CSSParser, Token } from "../syntax"; -import { Unit } from "../unit"; -import { Value } from "./value"; - -const { map, either, pair, right } = Parser; - -/** - * {@link https://drafts.csswg.org/css-values/#position} - * - * @public - */ -export class Position< - H extends Position.Component = Position.Component, - V extends Position.Component = Position.Component -> extends Value<"position", false> { - public static of< - H extends Position.Component, - V extends Position.Component - >(horizontal: H, vertical: V): Position { - return new Position(horizontal, vertical); - } - - private readonly _horizontal: H; - private readonly _vertical: V; - - private constructor(horizontal: H, vertical: V) { - super("position", false); - this._horizontal = horizontal; - this._vertical = vertical; - } - - public get horizontal(): H { - return this._horizontal; - } - - public get vertical(): V { - return this._vertical; - } - - public resolve(): Position { - return this; - } - - public equals(value: unknown): value is this { - return ( - value instanceof Position && - value._horizontal.equals(this._horizontal) && - value._vertical.equals(this._vertical) - ); - } - - public hash(hash: Hash): void { - hash.writeHashable(this._horizontal).writeHashable(this._vertical); - } - - public toJSON(): Position.JSON { - return { - ...super.toJSON(), - horizontal: this._horizontal.toJSON(), - vertical: this._vertical.toJSON(), - }; - } - - public toString(): string { - return `${this._horizontal} ${this._vertical}`; - } -} - -/** - * @public - */ -export namespace Position { - export type Canonical = Position< - Component.Canonical, - Component.Canonical - >; - - export interface JSON extends Value.JSON<"position"> { - horizontal: Component.JSON; - vertical: Component.JSON; - } - - export namespace Keywords { - export type Center = Keyword<"center">; - - /** - * @internal - */ - export const parseCenter = Keyword.parse("center"); - - export type Vertical = Keyword<"top"> | Keyword<"bottom">; - - /** - * @internal - */ - export const parseVertical = Keyword.parse("top", "bottom"); - - export type Horizontal = Keyword<"left"> | Keyword<"right">; - - /** - * @internal - */ - export const parseHorizontal = Keyword.parse("left", "right"); - } - - type Offset = - | Length.Fixed - | Percentage.Fixed; - - namespace Offset { - export const parse = either(Length.parseBase, Percentage.parseBase); - } - - export class Side< - S extends Keywords.Vertical | Keywords.Horizontal = - | Keywords.Vertical - | Keywords.Horizontal, - O extends Offset = Offset - > extends Value<"side", false> { - public static of< - S extends Keywords.Vertical | Keywords.Horizontal, - O extends Offset - >(side: S, offset: Option = None): Side { - return new Side(side, offset); - } - - private readonly _side: S; - private readonly _offset: Option; - - private constructor(side: S, offset: Option) { - super("side", false); - this._side = side; - this._offset = offset; - } - - public get side(): S { - return this._side; - } - - public get offset(): Option { - return this._offset; - } - - public isCenter(): boolean { - return this._offset.some( - (offset) => offset.type === "percentage" && offset.value === 0.5 - ); - } - - public resolve(): Side { - return this; - } - - public equals(value: unknown): value is this { - return ( - value instanceof Side && - value._side.equals(this._side) && - value._offset.equals(this._offset) - ); - } - - public hash(hash: Hash): void { - hash.writeHashable(this._side).writeHashable(this._offset); - } - - public toJSON(): Side.JSON { - return { - ...super.toJSON(), - side: this._side.toJSON(), - offset: this._offset.map((offset) => offset.toJSON()).getOr(null), - }; - } - - public toString(): string { - return `${this._side}${this._offset - .map((offset) => ` ${offset}`) - .getOr("")}`; - } - } - - export namespace Side { - export type Canonical = - Side; - - export interface JSON extends Value.JSON<"side"> { - side: Keyword.JSON; - offset: Length.Fixed.JSON | Percentage.Fixed.JSON | null; - } - - /** - * Parse a side keyword (top/bottom/left/right) or "center" - * - * @private - */ - function parseKeyword( - parser: CSSParser - ): CSSParser | Side> { - return either(Keywords.parseCenter, map(parser, Side.of)); - } - - /** - * Parse a side keyword followed by an offset (Length | Percentage). - * - * @private - */ - function parseKeywordValue< - S extends Keywords.Horizontal | Keywords.Vertical - >(parser: CSSParser): CSSParser> { - return map( - pair(parser, right(Token.parseWhitespace, Offset.parse)), - ([keyword, value]) => Side.of(keyword, Option.of(value)) - ); - } - - export const parseHorizontalKeywordValue = parseKeywordValue( - Keywords.parseHorizontal - ); - export const parseHorizontalKeyword = parseKeyword( - Keywords.parseHorizontal - ); - export const parseVerticalKeywordValue = parseKeywordValue( - Keywords.parseVertical - ); - export const parseVerticalKeyword = parseKeyword(Keywords.parseVertical); - - export const parseHorizontal = either( - parseHorizontalKeyword, - parseHorizontalKeywordValue - ); - export const parseVertical = either( - parseVerticalKeyword, - parseVerticalKeywordValue - ); - } - - export type Component< - S extends Keywords.Horizontal | Keywords.Vertical = - | Keywords.Horizontal - | Keywords.Vertical, - U extends Unit.Length = Unit.Length - > = Keywords.Center | Offset | Side>; - - export namespace Component { - export type Canonical = - | Percentage.Canonical - | Keywords.Center - | Length.Canonical - | Side.Canonical; - - export type JSON = - | Keyword.JSON - | Length.Fixed.JSON - | Percentage.Fixed.JSON - | Side.JSON; - - // "center" is included in Side.parse[Horizontal, Vertical] - export const parseHorizontal = either(Offset.parse, Side.parseHorizontal); - export const parseVertical = either(Offset.parse, Side.parseVertical); - } - - /** - * @remarks - * Positions can be declared using either 1, 2, 3, or 4 tokens with the - * longest possible match taking precedence. The 3-token syntax is deprecated - * and must be selectively enabled. - * - * It is not easy to reuse component parsing here because, for example, - * H v would be incorrectly parsed as Hh by the horizontal component parser, - * so we need parsing error recovery to happen globally. - * Moreover, the parsers must be tested in decreasing number of tokens so that - * the 1 token parser does not eagerly accept something which actually has 4 - * tokens. - * - * Notation: - * - * - H/V: keyword, top | bottom | right | left | center - * - h/v: numeric, \ - * - Hh/Vv: keyword (excluding center) and numeric - * - * Syntax: - * - * - 4 tokens: Hh Vv | Vv Hh - * - 3 tokens: Hh V | H Vv | Vv H | V Hh - * - 2 tokens: H V | H v | h V | h v | V H - * - 1 token: H | V | h - */ - const mapHV = ([horizontal, vertical]: [ - Component, - Component - ]) => Position.of(horizontal, vertical); - - const mapVH = ([vertical, horizontal]: [ - Component, - Component - ]) => Position.of(horizontal, vertical); - - const { - parseHorizontalKeywordValue, - parseHorizontalKeyword, - parseVerticalKeywordValue, - parseVerticalKeyword, - } = Side; - - // Hh Vv | Vv Hh - const parse4 = either( - map( - pair( - parseHorizontalKeywordValue, - right(Token.parseWhitespace, parseVerticalKeywordValue) - ), - mapHV - ), - map( - pair( - parseVerticalKeywordValue, - right(Token.parseWhitespace, parseHorizontalKeywordValue) - ), - mapVH - ) - ); - - // Hh V | H Vv | Vv H | V Hh - const parse3 = either( - map( - either( - pair( - parseHorizontalKeywordValue, - right(Token.parseWhitespace, parseVerticalKeyword) - ), - pair( - parseHorizontalKeyword, - right(Token.parseWhitespace, parseVerticalKeywordValue) - ) - ), - mapHV - ), - map( - either( - pair( - parseVerticalKeywordValue, - right(Token.parseWhitespace, parseHorizontalKeyword) - ), - pair( - parseVerticalKeyword, - right(Token.parseWhitespace, parseHorizontalKeywordValue) - ) - ), - mapVH - ) - ); - // H V | H v | h V | h v | V H = (H | h) (V | v) | V H - const parse2 = either( - map( - pair( - either(parseHorizontalKeyword, Offset.parse), - right(Token.parseWhitespace, either(parseVerticalKeyword, Offset.parse)) - ), - mapHV - ), - map( - pair( - parseVerticalKeyword, - right(Token.parseWhitespace, parseHorizontalKeyword) - ), - mapVH - ) - ); - - // H | V | h - const parse1 = either( - map(parseHorizontalKeyword, (horizontal) => - Position.of, Component>( - horizontal, - Keyword.of("center") - ) - ), - map(parseVerticalKeyword, (vertical) => - Position.of(Keyword.of("center"), vertical) - ), - map(Offset.parse, (horizontal) => - Position.of(horizontal, Keyword.of("center")) - ) - ); - - /** - * - * {@link https://drafts.csswg.org/css-values/#typedef-position} - * {@link https://drafts.csswg.org/css-backgrounds/#typedef-bg-position} - */ - export function parse(legacySyntax: boolean = false): CSSParser { - return either( - parse4, - legacySyntax ? parse3 : () => Err.of("Three-value syntax is not allowed"), - parse2, - parse1 - ); - } -} diff --git a/packages/alfa-css/src/value/position/component.ts b/packages/alfa-css/src/value/position/component.ts new file mode 100644 index 0000000000..8efe9f6253 --- /dev/null +++ b/packages/alfa-css/src/value/position/component.ts @@ -0,0 +1,102 @@ +import { Parser } from "@siteimprove/alfa-parser"; + +import { Parser as CSSParser } from "../../syntax"; +import { Unit } from "../../unit"; + +import { Keyword } from "../keyword"; +import { LengthPercentage } from "../numeric"; + +import { Keywords } from "./keywords"; +import { Side } from "./side"; + +const { either, map } = Parser; + +/** + * @public + */ +export type Component< + S extends Keywords.Horizontal | Keywords.Vertical = + | Keywords.Horizontal + | Keywords.Vertical, + U extends Unit.Length = Unit.Length, + CALC extends boolean = boolean +> = Keywords.Center | Side; + +/** + * @public + */ +export namespace Component { + export type Canonical = + | Keywords.Center + | Side.Canonical; + + export type PartiallyResolved< + S extends Keywords.Horizontal | Keywords.Vertical + > = Keywords.Center | Side.PartiallyResolved; + + /** + * @internal + */ + export type Fixed = + Component; + + export type JSON = Keyword.JSON | Side.JSON; + + export type Resolver = Side.Resolver; + + export function resolve( + resolver: Resolver + ): (value: Component) => Canonical { + return (value) => (Side.isSide(value) ? value.resolve(resolver) : value); + } + + export type PartialResolver = Side.PartialResolver; + + export function partiallyResolve< + S extends Keywords.Horizontal | Keywords.Vertical + >(resolver: PartialResolver): (value: Component) => PartiallyResolved { + return (value) => + Side.isSide(value) ? Side.partiallyResolve(resolver)(value) : value; + } + + /** + * Parses an isolated offset (length-percentage), and adds the provided default + * side to make a Side. + * + * @internal + */ + export function parseOffset< + T extends Keywords.Horizontal | Keywords.Vertical, + CALC extends boolean + >( + side: T, + withCalculation: CALC + ): CSSParser> { + const parser = ( + withCalculation ? LengthPercentage.parse : LengthPercentage.parseBase + ) as CSSParser>; + + return map(parser, (value) => Side.of(side, value)); + } + + // "center" is included in Side.parse[Horizontal, Vertical] + /** + * @internal + */ + export const parseHorizontal = ( + withCalculation: CALC + ) => + either( + parseOffset(Keyword.of("left"), withCalculation), + Side.parseHorizontal(withCalculation) + ); + + /** + * @internal + */ + export const parseVertical = (withCalculation: CALC) => + either( + parseOffset(Keyword.of("top"), withCalculation), + Side.parseVertical(withCalculation) + ); +} diff --git a/packages/alfa-css/src/value/position/index.ts b/packages/alfa-css/src/value/position/index.ts new file mode 100644 index 0000000000..82595aa6b0 --- /dev/null +++ b/packages/alfa-css/src/value/position/index.ts @@ -0,0 +1 @@ +export * from "./position"; diff --git a/packages/alfa-css/src/value/position/keywords.ts b/packages/alfa-css/src/value/position/keywords.ts new file mode 100644 index 0000000000..4df2953d0e --- /dev/null +++ b/packages/alfa-css/src/value/position/keywords.ts @@ -0,0 +1,27 @@ +import { Keyword } from "../keyword"; + +/** + * @public + */ +export namespace Keywords { + export type Center = Keyword<"center">; + + /** + * @internal + */ + export const parseCenter = Keyword.parse("center"); + + export type Vertical = Keyword<"top"> | Keyword<"bottom">; + + /** + * @internal + */ + export const parseVertical = Keyword.parse("top", "bottom"); + + export type Horizontal = Keyword<"left"> | Keyword<"right">; + + /** + * @internal + */ + export const parseHorizontal = Keyword.parse("left", "right"); +} diff --git a/packages/alfa-css/src/value/position/position.ts b/packages/alfa-css/src/value/position/position.ts new file mode 100644 index 0000000000..d0c97cec7b --- /dev/null +++ b/packages/alfa-css/src/value/position/position.ts @@ -0,0 +1,367 @@ +import { Hash } from "@siteimprove/alfa-hash"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Err } from "@siteimprove/alfa-result"; + +import { type Parser as CSSParser, Token } from "../../syntax"; +import { Unit } from "../../unit"; + +import { Keyword } from "../keyword"; +import { Length } from "../numeric"; +import { Resolvable } from "../resolvable"; +import { Value } from "../value"; + +import * as component from "./component"; +import * as keywords from "./keywords"; +import * as side from "./side"; + +const { map, either, pair, right } = Parser; + +/** + * {@link https://drafts.csswg.org/css-values/#position} + * + * @remarks + * A Position has a horizontal and a vertical component, corresponding to some + * coordinates. Each component can either be an offset (implicitly from the + * "start" side (left or top, usually)), or the keyword "center", or a Side + * (i.e. an explicit side with an optional offset). + * + * @public + */ +export class Position< + H extends Position.Keywords.Horizontal = Position.Keywords.Horizontal, + V extends Position.Keywords.Vertical = Position.Keywords.Vertical, + HC extends Position.Component = Position.Component, + VC extends Position.Component = Position.Component, + CALC extends boolean = boolean + > + extends Value<"position", CALC> + implements Resolvable, Position.Resolver> +{ + public static of< + H extends Position.Keywords.Horizontal = Position.Keywords.Horizontal, + V extends Position.Keywords.Vertical = Position.Keywords.Vertical, + HC extends Position.Component = Position.Component, + VC extends Position.Component = Position.Component + >( + horizontal: HC, + vertical: VC + ): Position> { + const calculation = (horizontal.hasCalculation() || + vertical.hasCalculation()) as Value.HasCalculation<[HC, VC]>; + return new Position(horizontal, vertical, calculation); + } + + private readonly _horizontal: HC; + private readonly _vertical: VC; + + private constructor(horizontal: HC, vertical: VC, calculation: CALC) { + super("position", calculation); + this._horizontal = horizontal; + this._vertical = vertical; + } + + public get horizontal(): HC { + return this._horizontal; + } + + public get vertical(): VC { + return this._vertical; + } + + public resolve(resolver: Position.Resolver): Position.Canonical { + return new Position( + Position.Component.resolve({ + length: resolver.length, + percentageBase: resolver.percentageHBase, + })(this._horizontal), + Position.Component.resolve({ + length: resolver.length, + percentageBase: resolver.percentageVBase, + })(this._vertical), + false + ); + } + + public equals(value: unknown): value is this { + return ( + value instanceof Position && + value._horizontal.equals(this._horizontal) && + value._vertical.equals(this._vertical) + ); + } + + public hash(hash: Hash): void { + hash.writeHashable(this._horizontal).writeHashable(this._vertical); + } + + public toJSON(): Position.JSON { + return { + ...super.toJSON(), + horizontal: this._horizontal.toJSON(), + vertical: this._vertical.toJSON(), + }; + } + + public toString(): string { + return `${this._horizontal} ${this._vertical}`; + } +} + +/** + * @public + */ +export namespace Position { + export type Canonical< + H extends Keywords.Horizontal = Keywords.Horizontal, + V extends Keywords.Vertical = Keywords.Vertical + > = Position, Component.Canonical, false>; + + export type PartiallyResolved< + H extends Keywords.Horizontal = Keywords.Horizontal, + V extends Keywords.Vertical = Keywords.Vertical + > = Position< + H, + V, + Component.PartiallyResolved, + Component.PartiallyResolved + >; + + /** + * @internal + */ + export type Fixed< + H extends Keywords.Horizontal = Keywords.Horizontal, + V extends Keywords.Vertical = Keywords.Vertical + > = Position, Component.Fixed, false>; + + export interface JSON extends Value.JSON<"position"> { + horizontal: Component.JSON; + vertical: Component.JSON; + } + + export import Keywords = keywords.Keywords; + + export import Side = side.Side; + + export import Component = component.Component; + + /** + * Percentages are not resolved against the same base in both dimensions. + */ + export interface Resolver extends Length.Resolver { + percentageHBase: Length.Canonical; + percentageVBase: Length.Canonical; + } + + export type PartialResolver = Component.PartialResolver; + + export function partiallyResolve< + H extends Keywords.Horizontal, + V extends Keywords.Vertical + >( + resolver: PartialResolver + ): (value: Position) => PartiallyResolved { + return (value) => + Position.of( + Component.partiallyResolve(resolver)(value.horizontal), + Component.partiallyResolve(resolver)(value.vertical) + ); + } + + /** + * @remarks + * Positions can be declared using either 1, 2, 3, or 4 tokens with the + * longest possible match taking precedence. The 3-token syntax is deprecated + * and must be selectively enabled. + * + * It is not easy to reuse component parsing here because, for example, + * H v would be incorrectly parsed as Hh by the horizontal component parser, + * so we need parsing error recovery to happen globally. + * Moreover, the parsers must be tested in decreasing number of tokens so that + * the 1 token parser does not eagerly accept something which actually has 4 + * tokens. + * + * Notation: + * + * - H/V: keyword: top | bottom | right | left | center + * - h/v: numeric: \ + * - Hh/Vv: keyword (excluding center) and numeric + * + * Syntax: + * + * - 4 tokens: Hh Vv | Vv Hh + * - 3 tokens: Hh V | H Vv | Vv H | V Hh + * - 2 tokens: H V | H v | h V | h v | V H <- Obs! no V h | v H | v h + * - 1 token: H | V | h + */ + const mapHV = ([horizontal, vertical]: [ + Component, + Component + ]) => Position.of(horizontal, vertical); + + const mapVH = ([vertical, horizontal]: [ + Component, + Component + ]) => Position.of(horizontal, vertical); + + const { + parseHorizontalKeywordValue, + parseHorizontalKeyword, + parseVerticalKeywordValue, + parseVerticalKeyword, + } = Side; + + const parse4 = (withCalculation: CALC) => + either( + map( + pair( + parseHorizontalKeywordValue(withCalculation), + right( + Token.parseWhitespace, + parseVerticalKeywordValue(withCalculation) + ) + ), + mapHV + ), + map( + pair( + parseVerticalKeywordValue(withCalculation), + right( + Token.parseWhitespace, + parseHorizontalKeywordValue(withCalculation) + ) + ), + mapVH + ) + ); + + // Hh V | H Vv | Vv H | V Hh + const parse3 = (withCalculation: CALC) => + either( + map( + either( + pair( + parseHorizontalKeywordValue(withCalculation), + right(Token.parseWhitespace, parseVerticalKeyword(withCalculation)) + ), + pair( + parseHorizontalKeyword(withCalculation), + right( + Token.parseWhitespace, + parseVerticalKeywordValue(withCalculation) + ) + ) + ), + mapHV + ), + map( + either( + pair( + parseVerticalKeywordValue(withCalculation), + right( + Token.parseWhitespace, + parseHorizontalKeyword(withCalculation) + ) + ), + pair( + parseVerticalKeyword(withCalculation), + right( + Token.parseWhitespace, + parseHorizontalKeywordValue(withCalculation) + ) + ) + ), + mapVH + ) + ); + + // H V | H v | h V | h v | V H = (H | h) (V | v) | V H + const parse2 = (withCalculation: CALC) => + either( + map( + pair( + either( + parseHorizontalKeyword(withCalculation), + Component.parseOffset(Keyword.of("left"), withCalculation) + ), + right( + Token.parseWhitespace, + either( + parseVerticalKeyword(withCalculation), + Component.parseOffset(Keyword.of("top"), withCalculation) + ) + ) + ), + mapHV + ), + map( + pair( + parseVerticalKeyword(withCalculation), + right(Token.parseWhitespace, parseHorizontalKeyword(withCalculation)) + ), + mapVH + ) + ); + + type withCalculation = Position< + Keywords.Horizontal, + Keywords.Vertical, + Component, + Component, + CALC + >; + + // H | V | h + const parse1 = (withCalculation: CALC) => + either( + map( + parseHorizontalKeyword(withCalculation), + (horizontal) => + Position.of(horizontal, Keyword.of("center")) as withCalculation + ), + map( + parseVerticalKeyword(withCalculation), + (vertical) => + Position.of(Keyword.of("center"), vertical) as withCalculation + ), + map( + Component.parseOffset(Keyword.of("left"), withCalculation), + (horizontal) => + Position.of(horizontal, Keyword.of("center")) as withCalculation + ) + ); + + /** + * Parse a position, optionally accepting legacy 3-values syntax. + * + * {@link https://drafts.csswg.org/css-values/#typedef-position} + * {@link https://drafts.csswg.org/css-backgrounds/#typedef-bg-position} + * + * @privateRemarks + * The parsers must be tested in decreasing number of tokens. + */ + export function parse(legacySyntax: boolean = false): CSSParser { + return either( + parse4(true), + legacySyntax + ? parse3(true) + : () => Err.of("Three-value syntax is not allowed"), + parse2(true), + parse1(true) + ); + } + + /** + * @internal + */ + export function parseBase(legacySyntax: boolean = false): CSSParser { + return either( + parse4(false), + legacySyntax + ? parse3(false) + : () => Err.of("Three-value syntax is not allowed"), + parse2(false), + parse1(false) + ); + } +} diff --git a/packages/alfa-css/src/value/position/side.ts b/packages/alfa-css/src/value/position/side.ts new file mode 100644 index 0000000000..90c3fc8e8e --- /dev/null +++ b/packages/alfa-css/src/value/position/side.ts @@ -0,0 +1,218 @@ +import { Hash } from "@siteimprove/alfa-hash"; +import { Option } from "@siteimprove/alfa-option"; +import { Parser } from "@siteimprove/alfa-parser"; +import { Parser as CSSParser, Token } from "../../syntax"; +import { Unit } from "../../unit"; + +import { Keyword } from "../keyword"; +import { Length, LengthPercentage } from "../numeric"; +import { Resolvable } from "../resolvable"; +import { Value } from "../value"; + +import { Keywords } from "./keywords"; + +const { either, map, pair, right } = Parser; + +/** + * @public + */ +export class Side< + S extends Keywords.Vertical | Keywords.Horizontal = + | Keywords.Vertical + | Keywords.Horizontal, + U extends Unit.Length = Unit.Length, + CALC extends boolean = boolean, + O extends LengthPercentage = LengthPercentage + > + extends Value<"side", CALC> + implements Resolvable, Side.Resolver> +{ + public static of( + side: S + ): Side; + + public static of< + S extends Keywords.Vertical | Keywords.Horizontal, + U extends Unit.Length, + CALC extends boolean, + O extends LengthPercentage + >(side: S, offset: O): Side; + + public static of< + S extends Keywords.Vertical | Keywords.Horizontal, + U extends Unit.Length, + CALC extends boolean, + O extends LengthPercentage + >(side: S, offset: Option): Side; + + public static of< + S extends Keywords.Vertical | Keywords.Horizontal, + U extends Unit.Length, + CALC extends boolean, + O extends LengthPercentage + >(side: S, offset?: O | Option): Side { + return new Side( + side, + Option.isOption(offset) ? offset : Option.from(offset) + ); + } + + private readonly _side: S; + private readonly _offset: Option; + + private constructor(side: S, offset: Option) { + super("side", offset.some((offset) => offset.hasCalculation()) as CALC); + this._side = side; + this._offset = offset; + } + + public get side(): S { + return this._side; + } + + public get offset(): Option { + return this._offset; + } + + public resolve(resolver: Side.Resolver): Side.Canonical { + return new Side( + this._side, + this._offset.map(LengthPercentage.resolve(resolver)) + ); + } + + public equals(value: unknown): value is this { + return ( + value instanceof Side && + value._side.equals(this._side) && + value._offset.equals(this._offset) + ); + } + + public hash(hash: Hash): void { + hash.writeHashable(this._side).writeHashable(this._offset); + } + + public toJSON(): Side.JSON { + return { + ...super.toJSON(), + side: this._side.toJSON(), + offset: this._offset.map((offset) => offset.toJSON()).getOr(null), + }; + } + + public toString(): string { + return `${this._side}${this._offset + .map((offset) => ` ${offset}`) + .getOr("")}`; + } +} + +/** + * @public + */ +export namespace Side { + export type Canonical = + Side; + + export type PartiallyResolved< + S extends Keywords.Vertical | Keywords.Horizontal + > = Side; + + export interface JSON extends Value.JSON<"side"> { + side: Keyword.JSON; + offset: LengthPercentage.JSON | null; + } + + export type Resolver = LengthPercentage.Resolver; + + export type PartialResolver = Length.Resolver; + + export function partiallyResolve< + S extends Keywords.Vertical | Keywords.Horizontal + >(resolver: PartialResolver): (side: Side) => PartiallyResolved { + return (side) => + Side.of( + side.side, + side.offset.map(LengthPercentage.partiallyResolve(resolver)) + ); + } + + export function isSide(value: unknown): value is Side { + return value instanceof Side; + } + + /** + * Parse a side keyword (top/bottom/left/right) or "center" + */ + function parseKeyword< + S extends Keywords.Horizontal | Keywords.Vertical, + CALC extends boolean + >( + parser: CSSParser, + // This is a useless parameter, temporarily used to enforce inference of CALC + // at call sites. + withCalculation: CALC + ): CSSParser | Side> { + return either( + Keywords.parseCenter, + // This is asserting false => true i.e. losing the fact that there is + // no calculation in the Keyword. This is acceptable. + map(parser, (side) => Side.of(side) as Side) + ); + } + + /** + * Parse a side keyword followed by an offset (length-percentage). + * + * @TODO + * The withCalculation parameter (and CALC type parameter) is temporally needed + * until Shape and Gradient are properly migrated to calculatable values. + */ + function parseKeywordValue< + S extends Keywords.Horizontal | Keywords.Vertical, + CALC extends boolean + >( + parser: CSSParser, + withCalculation: CALC + ): CSSParser> { + const offsetParser = ( + withCalculation ? LengthPercentage.parse : LengthPercentage.parseBase + ) as CSSParser>; + + return map( + pair(parser, right(Token.parseWhitespace, offsetParser)), + ([keyword, value]) => Side.of(keyword, value) + ); + } + + export const parseHorizontalKeywordValue = ( + withCalculation: CALC + ) => parseKeywordValue(Keywords.parseHorizontal, withCalculation); + + export const parseHorizontalKeyword = ( + withCalculation: CALC + ) => parseKeyword(Keywords.parseHorizontal, withCalculation); + + export const parseHorizontal = ( + withCalculation: CALC + ) => + either( + parseHorizontalKeyword(withCalculation), + parseHorizontalKeywordValue(withCalculation) + ); + + export const parseVerticalKeywordValue = ( + withCalculation: CALC + ) => parseKeywordValue(Keywords.parseVertical, withCalculation); + + export const parseVerticalKeyword = ( + withCalculation: CALC + ) => parseKeyword(Keywords.parseVertical, withCalculation); + + export const parseVertical = (withCalculation: CALC) => + either( + parseVerticalKeyword(withCalculation), + parseVerticalKeywordValue(withCalculation) + ); +} diff --git a/packages/alfa-css/src/value/shape/circle.ts b/packages/alfa-css/src/value/shape/circle.ts index 31b5cb3891..1c7f9a3cd2 100644 --- a/packages/alfa-css/src/value/shape/circle.ts +++ b/packages/alfa-css/src/value/shape/circle.ts @@ -18,9 +18,9 @@ const { map, option, pair, right } = Parser; */ export class Circle< R extends Radius = Radius, - P extends Position = Position + P extends Position.Fixed = Position.Fixed > extends BasicShape<"circle"> { - public static of( + public static of( radius: R, center: P ): Circle { @@ -100,7 +100,7 @@ export namespace Circle { option(Token.parseWhitespace), right( Keyword.parse("at"), - right(Token.parseWhitespace, Position.parse()) + right(Token.parseWhitespace, Position.parseBase()) ) ) ) diff --git a/packages/alfa-css/src/value/shape/ellipse.ts b/packages/alfa-css/src/value/shape/ellipse.ts index c5ed624a4d..6e275eb9b7 100644 --- a/packages/alfa-css/src/value/shape/ellipse.ts +++ b/packages/alfa-css/src/value/shape/ellipse.ts @@ -18,13 +18,12 @@ const { map, option, pair, right } = Parser; */ export class Ellipse< R extends Radius = Radius, - P extends Position = Position + P extends Position.Fixed = Position.Fixed > extends BasicShape<"ellipse"> { - public static of( - rx: R, - ry: R, - center: P - ): Ellipse { + public static of< + R extends Radius = Radius, + P extends Position.Fixed = Position.Fixed + >(rx: R, ry: R, center: P): Ellipse { return new Ellipse(rx, ry, center); } @@ -113,7 +112,7 @@ export namespace Ellipse { option(Token.parseWhitespace), right( Keyword.parse("at"), - right(Token.parseWhitespace, Position.parse()) + right(Token.parseWhitespace, Position.parseBase()) ) ) ) diff --git a/packages/alfa-css/test/value/position.spec.ts b/packages/alfa-css/test/value/position.spec.ts index 5812d1a63e..8cd7378f50 100644 --- a/packages/alfa-css/test/value/position.spec.ts +++ b/packages/alfa-css/test/value/position.spec.ts @@ -1,479 +1,477 @@ import { test } from "@siteimprove/alfa-test"; -import { Lexer } from "../../src/syntax/lexer"; -import { Position } from "../../src/value/position"; +import { Length, Lexer, Math, Position } from "../../src"; function parse(input: string, legacySyntax: boolean = false) { - return Position.parse(legacySyntax)(Lexer.lex(input)).map(([, position]) => - position.toJSON() - ); + return Position.parse(legacySyntax)(Lexer.lex(input)) + .map(([, position]) => position.toJSON()) + .getUnsafe(); } test(".parse() parses 1-token positions", (t) => { - t.deepEqual(parse("center").getUnsafe(), { + t.deepEqual(parse("center"), { type: "position", - horizontal: { - type: "keyword", - value: "center", - }, - vertical: { - type: "keyword", - value: "center", - }, + horizontal: { type: "keyword", value: "center" }, + vertical: { type: "keyword", value: "center" }, }); - t.deepEqual(parse("right").getUnsafe(), { + t.deepEqual(parse("right"), { type: "position", horizontal: { type: "side", - side: { - type: "keyword", - value: "right", - }, + side: { type: "keyword", value: "right" }, offset: null, }, - vertical: { - type: "keyword", - value: "center", - }, + vertical: { type: "keyword", value: "center" }, }); - t.deepEqual(parse("top").getUnsafe(), { + t.deepEqual(parse("top"), { type: "position", - horizontal: { - type: "keyword", - value: "center", - }, + horizontal: { type: "keyword", value: "center" }, vertical: { type: "side", - side: { - type: "keyword", - value: "top", - }, + side: { type: "keyword", value: "top" }, offset: null, }, }); - t.deepEqual(parse("10px").getUnsafe(), { + t.deepEqual(parse("10px"), { type: "position", horizontal: { - type: "length", - unit: "px", - value: 10, - }, - vertical: { - type: "keyword", - value: "center", + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, }, + vertical: { type: "keyword", value: "center" }, }); // "10px" is not consumed - t.deepEqual(parse("top 10px").getUnsafe(), { + t.deepEqual(parse("top 10px"), { type: "position", - horizontal: { - type: "keyword", - value: "center", - }, + horizontal: { type: "keyword", value: "center" }, vertical: { type: "side", - side: { - type: "keyword", - value: "top", - }, + side: { type: "keyword", value: "top" }, offset: null, }, }); // "left" is not consumed - t.deepEqual(parse("10px left").getUnsafe(), { + t.deepEqual(parse("10px left"), { type: "position", horizontal: { - type: "length", - unit: "px", - value: 10, - }, - vertical: { - type: "keyword", - value: "center", + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, }, + vertical: { type: "keyword", value: "center" }, }); }); test(".parse() parses 2-token positions", (t) => { - t.deepEqual(parse("left bottom").getUnsafe(), { + t.deepEqual(parse("left bottom"), { type: "position", horizontal: { type: "side", - side: { - type: "keyword", - value: "left", - }, + side: { type: "keyword", value: "left" }, offset: null, }, vertical: { type: "side", - side: { - type: "keyword", - value: "bottom", - }, + side: { type: "keyword", value: "bottom" }, offset: null, }, }); - t.deepEqual(parse("bottom left").getUnsafe(), { + t.deepEqual(parse("bottom left"), { type: "position", horizontal: { type: "side", - side: { - type: "keyword", - value: "left", - }, + side: { type: "keyword", value: "left" }, offset: null, }, vertical: { type: "side", - side: { - type: "keyword", - value: "bottom", - }, + side: { type: "keyword", value: "bottom" }, offset: null, }, }); - t.deepEqual(parse("left center").getUnsafe(), { + t.deepEqual(parse("left center"), { type: "position", horizontal: { type: "side", - side: { - type: "keyword", - value: "left", - }, + side: { type: "keyword", value: "left" }, offset: null, }, - vertical: { - type: "keyword", - value: "center", - }, + vertical: { type: "keyword", value: "center" }, }); - t.deepEqual(parse("center left").getUnsafe(), { + t.deepEqual(parse("center left"), { type: "position", horizontal: { type: "side", - side: { - type: "keyword", - value: "left", - }, + side: { type: "keyword", value: "left" }, offset: null, }, - vertical: { - type: "keyword", - value: "center", - }, + vertical: { type: "keyword", value: "center" }, }); - t.deepEqual(parse("left 10px").getUnsafe(), { + t.deepEqual(parse("left 10px"), { type: "position", horizontal: { type: "side", - side: { - type: "keyword", - value: "left", - }, + side: { type: "keyword", value: "left" }, offset: null, }, vertical: { - type: "length", - unit: "px", - value: 10, + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length", unit: "px", value: 10 }, }, }); - t.deepEqual(parse("10px top").getUnsafe(), { + t.deepEqual(parse("10px top"), { type: "position", horizontal: { - type: "length", - unit: "px", - value: 10, + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, }, vertical: { type: "side", - side: { - type: "keyword", - value: "top", - }, + side: { type: "keyword", value: "top" }, offset: null, }, }); - t.deepEqual(parse("10px 20%").getUnsafe(), { + t.deepEqual(parse("10px 20%"), { type: "position", horizontal: { - type: "length", - unit: "px", - value: 10, + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, }, vertical: { - type: "percentage", - value: 0.2, + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "percentage", value: 0.2 }, }, }); // "20%" is not consumed - t.deepEqual(parse("10px top 20%", true).getUnsafe(), { + t.deepEqual(parse("10px top 20%", true), { type: "position", horizontal: { - type: "length", - unit: "px", - value: 10, + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, }, vertical: { - offset: null, - side: { - type: "keyword", - value: "top", - }, type: "side", + side: { type: "keyword", value: "top" }, + offset: null, }, }); // "20%" is not consumed - t.deepEqual(parse("left 10px 20%", true).getUnsafe(), { + t.deepEqual(parse("left 10px 20%", true), { type: "position", horizontal: { - offset: null, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "left" }, + offset: null, }, vertical: { - type: "length", - unit: "px", - value: 10, + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length", unit: "px", value: 10 }, }, }); // "10px" not consumed - t.deepEqual(parse("left top 10px").getUnsafe(), { + t.deepEqual(parse("left top 10px"), { type: "position", horizontal: { - offset: null, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "left" }, + offset: null, }, vertical: { - offset: null, - side: { - type: "keyword", - value: "top", - }, type: "side", + side: { type: "keyword", value: "top" }, + offset: null, }, }); // "left" not consumed - t.deepEqual(parse("top 10px left").getUnsafe(), { + t.deepEqual(parse("top 10px left"), { + type: "position", + horizontal: { type: "keyword", value: "center" }, + vertical: { + type: "side", + side: { type: "keyword", value: "top" }, + offset: null, + }, + }); + + // "10px" not consumed + t.deepEqual(parse("top left 10px"), { type: "position", horizontal: { - type: "keyword", - value: "center", + type: "side", + side: { type: "keyword", value: "left" }, + offset: null, }, vertical: { + type: "side", + side: { type: "keyword", value: "top" }, offset: null, - side: { - type: "keyword", - value: "top", - }, + }, + }); + + // "right 10px" not consumed + t.deepEqual(parse("center 20% right 10px"), { + type: "position", + horizontal: { type: "keyword", value: "center" }, + vertical: { type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "percentage", value: 0.2 }, }, }); +}); - // "10px" not consumed - t.deepEqual(parse("top left 10px").getUnsafe(), { +test(".parse() parses 3-token positions", (t) => { + t.deepEqual(parse("left 10px center", true), { + type: "position", + horizontal: { + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, + }, + vertical: { type: "keyword", value: "center" }, + }); + + t.deepEqual(parse("left top 10px", true), { type: "position", horizontal: { - offset: null, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "left" }, + offset: null, }, vertical: { + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length", unit: "px", value: 10 }, + }, + }); + + t.deepEqual(parse("top 10px left", true), { + type: "position", + horizontal: { + type: "side", + side: { type: "keyword", value: "left" }, offset: null, - side: { - type: "keyword", - value: "top", - }, + }, + vertical: { type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length", unit: "px", value: 10 }, }, }); - // "right 10px" not consumed - t.deepEqual(parse("center 20% right 10px").getUnsafe(), { + t.deepEqual(parse("top left 10px", true), { type: "position", horizontal: { - type: "keyword", - value: "center", + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 10 }, }, vertical: { - type: "percentage", - value: 0.2, + type: "side", + side: { type: "keyword", value: "top" }, + offset: null, }, }); }); -test(".parse() parses 3-token positions", (t) => { - t.deepEqual(parse("left 10px center", true).getUnsafe(), { +test(".parse() parses 4-token positions", (t) => { + t.deepEqual(parse("right 10px bottom 20%"), { type: "position", horizontal: { - offset: { - type: "length", - unit: "px", - value: 10, - }, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "right" }, + offset: { type: "length", unit: "px", value: 10 }, }, vertical: { - type: "keyword", - value: "center", + type: "side", + side: { type: "keyword", value: "bottom" }, + offset: { type: "percentage", value: 0.2 }, }, }); - t.deepEqual(parse("left top 10px", true).getUnsafe(), { + t.deepEqual(parse("bottom 20% right 10px"), { type: "position", horizontal: { - offset: null, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "right" }, + offset: { type: "length", unit: "px", value: 10 }, }, vertical: { - offset: { - type: "length", - unit: "px", - value: 10, - }, - side: { - type: "keyword", - value: "top", - }, type: "side", + side: { type: "keyword", value: "bottom" }, + offset: { type: "percentage", value: 0.2 }, }, }); +}); + +test(".parse() accepts calculations", (t) => { + const calcJSON: Math.JSON = { + /* calc(10px + 5%) */ type: "math expression", + expression: { + type: "calculation", + arguments: [ + { + type: "sum", + operands: [ + { type: "value", value: { type: "length", unit: "px", value: 10 } }, + { type: "value", value: { type: "percentage", value: 0.05 } }, + ], + }, + ], + }, + }; - t.deepEqual(parse("top 10px left", true).getUnsafe(), { + t.deepEqual(parse("calc(10px + 5%)"), { type: "position", horizontal: { - offset: null, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length-percentage", math: calcJSON }, + }, + vertical: { type: "keyword", value: "center" }, + }); + + t.deepEqual(parse("left calc(10px + 5%)"), { + type: "position", + horizontal: { + type: "side", + side: { type: "keyword", value: "left" }, + offset: null, }, vertical: { - offset: { - type: "length", - unit: "px", - value: 10, - }, - side: { - type: "keyword", - value: "top", - }, type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length-percentage", math: calcJSON }, }, }); - t.deepEqual(parse("top left 10px", true).getUnsafe(), { + t.deepEqual(parse("calc(10px + 5%) calc(10px + 5%)"), { type: "position", horizontal: { - offset: { - type: "length", - unit: "px", - value: 10, - }, - side: { - type: "keyword", - value: "left", - }, type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length-percentage", math: calcJSON }, }, vertical: { - offset: null, - side: { - type: "keyword", - value: "top", - }, type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length-percentage", math: calcJSON }, }, }); -}); -test(".parse() parses 4-token positions", (t) => { - t.deepEqual(parse("right 10px bottom 20%").getUnsafe(), { + t.deepEqual(parse("top calc(10px + 5%) left", true), { type: "position", horizontal: { - offset: { - type: "length", - unit: "px", - value: 10, - }, - side: { - type: "keyword", - value: "right", - }, type: "side", + side: { type: "keyword", value: "left" }, + offset: null, }, vertical: { - offset: { - type: "percentage", - value: 0.2, - }, - side: { - type: "keyword", - value: "bottom", - }, type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length-percentage", math: calcJSON }, }, }); - t.deepEqual(parse("bottom 20% right 10px").getUnsafe(), { + t.deepEqual(parse("right calc(10px + 5%) bottom calc(10px + 5%)"), { type: "position", horizontal: { - offset: { - type: "length", - unit: "px", - value: 10, - }, - side: { - type: "keyword", - value: "right", - }, type: "side", + side: { type: "keyword", value: "right" }, + offset: { type: "length-percentage", math: calcJSON }, }, vertical: { - offset: { - type: "percentage", - value: 0.2, - }, - side: { - type: "keyword", - value: "bottom", - }, type: "side", + side: { type: "keyword", value: "bottom" }, + offset: { type: "length-percentage", math: calcJSON }, }, }); }); + +test(".resolve() fully resolves positions", (t) => { + const actual = Position.parse()(Lexer.lex("left calc(1em + 5%) top 20%")) + .map(([, position]) => position) + .getUnsafe(); + + t.deepEqual( + actual + .resolve({ + length: Length.resolver( + Length.of(16, "px"), + Length.of(0, "px"), + Length.of(0, "px"), + Length.of(0, "px") + ), + percentageHBase: Length.of(10, "px"), + percentageVBase: Length.of(20, "px"), + }) + .toJSON(), + { + type: "position", + horizontal: { + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 16.5 }, + }, + vertical: { + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length", unit: "px", value: 4 }, + }, + } + ); +}); + +test(".partiallyResolve() partially resolves positions", (t) => { + const actual = Position.parse()( + Lexer.lex("left calc(1em + 1px) top calc(20% + 10%)") + ) + .map(([, position]) => position) + .getUnsafe(); + + t.deepEqual( + Position.partiallyResolve({ + length: Length.resolver( + Length.of(16, "px"), + Length.of(0, "px"), + Length.of(0, "px"), + Length.of(0, "px") + ), + })(actual).toJSON(), + { + type: "position", + horizontal: { + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", unit: "px", value: 17 }, + }, + vertical: { + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "percentage", value: 0.3 }, + }, + } + ); +}); diff --git a/packages/alfa-css/tsconfig.json b/packages/alfa-css/tsconfig.json index 447dd3a0a4..68d324d497 100644 --- a/packages/alfa-css/tsconfig.json +++ b/packages/alfa-css/tsconfig.json @@ -62,7 +62,11 @@ "src/value/numeric/number.ts", "src/value/numeric/numeric.ts", "src/value/numeric/percentage.ts", - "src/value/position.ts", + "src/value/position/component.ts", + "src/value/position/index.ts", + "src/value/position/keywords.ts", + "src/value/position/position.ts", + "src/value/position/side.ts", "src/value/resolvable.ts", "src/value/shadow.ts", "src/value/shape/basic-shape.ts", diff --git a/packages/alfa-rules/test/sia-r62/serialise.spec.ts b/packages/alfa-rules/test/sia-r62/serialise.spec.ts index 619c7b9620..02f903ae05 100644 --- a/packages/alfa-rules/test/sia-r62/serialise.spec.ts +++ b/packages/alfa-rules/test/sia-r62/serialise.spec.ts @@ -47,7 +47,7 @@ test(`background() serialises a complex single layer background`, (t) => { t.deepEqual( background(style), - "rgb(100% 0% 0%) url(a) 10px center / 50% auto space no-repeat fixed content-box padding-box" + "rgb(100% 0% 0%) url(a) left 10px center / 50% auto space no-repeat fixed content-box padding-box" ); }); diff --git a/packages/alfa-style/src/property/background-position-x.ts b/packages/alfa-style/src/property/background-position-x.ts index 84ac85f32a..77d69af10a 100644 --- a/packages/alfa-style/src/property/background-position-x.ts +++ b/packages/alfa-style/src/property/background-position-x.ts @@ -1,4 +1,4 @@ -import { List, Percentage, Position } from "@siteimprove/alfa-css"; +import { Keyword, List, Percentage, Position } from "@siteimprove/alfa-css"; import { Longhand } from "../longhand"; import { Resolver } from "../resolver"; @@ -15,15 +15,21 @@ export namespace Specified { type Computed = List; namespace Computed { - export type Item = Position.Component; + export type Item = + Position.Component.PartiallyResolved; } -const parse = List.parseCommaSeparated(Position.Component.parseHorizontal); +const parse = List.parseCommaSeparated( + Position.Component.parseHorizontal(true) +); /** * @internal */ -export const initialItem = Percentage.of(0); +export const initialItem: Computed.Item = Position.Side.of( + Keyword.of("left"), + Percentage.of(0) +); /** * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/background-position} @@ -34,6 +40,6 @@ export default Longhand.of( parse, (value, style) => value.map((positions) => - positions.map((position) => Resolver.positionComponent(position, style)) + positions.map(Position.Component.partiallyResolve(Resolver.length(style))) ) ); diff --git a/packages/alfa-style/src/property/background-position-y.ts b/packages/alfa-style/src/property/background-position-y.ts index f9d42a2fd3..d0e623ca0a 100644 --- a/packages/alfa-style/src/property/background-position-y.ts +++ b/packages/alfa-style/src/property/background-position-y.ts @@ -1,4 +1,4 @@ -import { List, Percentage, Position } from "@siteimprove/alfa-css"; +import { Keyword, List, Percentage, Position } from "@siteimprove/alfa-css"; import { Longhand } from "../longhand"; import { Resolver } from "../resolver"; @@ -15,15 +15,19 @@ export namespace Specified { type Computed = List; namespace Computed { - export type Item = Position.Component; + export type Item = + Position.Component.PartiallyResolved; } -const parse = List.parseCommaSeparated(Position.Component.parseVertical); +const parse = List.parseCommaSeparated(Position.Component.parseVertical(true)); /** * @internal */ -export const initialItem = Percentage.of(0); +export const initialItem: Computed.Item = Position.Side.of( + Keyword.of("top"), + Percentage.of(0) +); /** * {@link https://developer.mozilla.org/en-US/docs/Web/CSS/background-position} @@ -34,6 +38,6 @@ export default Longhand.of( parse, (value, style) => value.map((positions) => - positions.map((position) => Resolver.positionComponent(position, style)) + positions.map(Position.Component.partiallyResolve(Resolver.length(style))) ) ); diff --git a/packages/alfa-style/src/resolver.ts b/packages/alfa-style/src/resolver.ts index f714c1e4ad..940496b08e 100644 --- a/packages/alfa-style/src/resolver.ts +++ b/packages/alfa-style/src/resolver.ts @@ -145,9 +145,9 @@ export namespace Resolver { } export function position( - position: Position, + position: Position.Fixed, style: Style - ): Position.Canonical { + ): Position.Fixed { return Position.of( positionComponent(position.horizontal, style), positionComponent(position.vertical, style) @@ -157,17 +157,13 @@ export namespace Resolver { export function positionComponent< S extends Position.Keywords.Horizontal | Position.Keywords.Vertical >( - position: Position.Component, + position: Position.Component.Fixed, style: Style - ): Position.Component.Canonical { + ): Position.Component.Fixed { switch (position.type) { case "keyword": - case "percentage": return position; - case "length": - return position.resolve(Resolver.length(style)); - case "side": return Position.Side.of( position.side, diff --git a/packages/alfa-style/test/property/background.spec.tsx b/packages/alfa-style/test/property/background.spec.tsx index 5c5e1bd6ed..1769efd9c4 100644 --- a/packages/alfa-style/test/property/background.spec.tsx +++ b/packages/alfa-style/test/property/background.spec.tsx @@ -7,11 +7,7 @@ test("#cascaded() parses `background: red`", (t) => { const element =
; t.deepEqual(cascaded(element, "background-color"), { - value: { - type: "color", - format: "named", - color: "red", - }, + value: { type: "color", format: "named", color: "red" }, source: h.declaration("background", "red").toJSON(), }); }); @@ -25,10 +21,7 @@ test(`#cascaded() parses \`background: url("foo.png")\``, (t) => { values: [ { type: "image", - image: { - type: "url", - url: "foo.png", - }, + image: { type: "url", url: "foo.png" }, }, ], separator: ", ", @@ -45,9 +38,9 @@ test(`#cascaded() parses \`background: 12px\``, (t) => { type: "list", values: [ { - type: "length", - value: 12, - unit: "px", + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", value: 12, unit: "px" }, }, ], separator: ", ", @@ -58,12 +51,7 @@ test(`#cascaded() parses \`background: 12px\``, (t) => { t.deepEqual(cascaded(element, "background-position-y"), { value: { type: "list", - values: [ - { - type: "keyword", - value: "center", - }, - ], + values: [{ type: "keyword", value: "center" }], separator: ", ", }, source: h.declaration("background", `12px`).toJSON(), @@ -78,9 +66,9 @@ test(`#cascaded() parses \`background: 12px 0\``, (t) => { type: "list", values: [ { - type: "length", - value: 12, - unit: "px", + type: "side", + side: { type: "keyword", value: "left" }, + offset: { type: "length", value: 12, unit: "px" }, }, ], separator: ", ", @@ -93,9 +81,9 @@ test(`#cascaded() parses \`background: 12px 0\``, (t) => { type: "list", values: [ { - type: "length", - value: 0, - unit: "px", + type: "side", + side: { type: "keyword", value: "top" }, + offset: { type: "length", value: 0, unit: "px" }, }, ], separator: ", ", @@ -110,12 +98,7 @@ test(`#cascaded() parses \`background: 0 / cover\``, (t) => { t.deepEqual(cascaded(element, "background-size"), { value: { type: "list", - values: [ - { - type: "keyword", - value: "cover", - }, - ], + values: [{ type: "keyword", value: "cover" }], separator: ", ", }, source: h.declaration("background", `0 / cover`).toJSON(),