diff --git a/.changeset/calm-camels-sell.md b/.changeset/calm-camels-sell.md new file mode 100644 index 0000000000..a4d384119a --- /dev/null +++ b/.changeset/calm-camels-sell.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-rules": minor +--- + +**Changed:** Color contrast rules, currently SIA-R66 and SIA-R69, can now tell which interposed elements can be ignored if layout is available. + +If layout is not available the rules keep the current behavior of asking a `ignored-interposed-elements` question. diff --git a/.changeset/empty-ears-cheat.md b/.changeset/empty-ears-cheat.md new file mode 100644 index 0000000000..b7b91aa505 --- /dev/null +++ b/.changeset/empty-ears-cheat.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-selector": minor +--- + +**Added:** A function `isEmpty` to `Context` class diff --git a/.changeset/lovely-bees-type.md b/.changeset/lovely-bees-type.md new file mode 100644 index 0000000000..bcc08b0b29 --- /dev/null +++ b/.changeset/lovely-bees-type.md @@ -0,0 +1,7 @@ +--- +"@siteimprove/alfa-style": minor +--- + +**Added:** A function for getting the bounding box of an element given a device. + +This should be the only way of accessing an elements bounding box and prepares us for having device dependent boxes. diff --git a/docs/review/api/alfa-selector.api.md b/docs/review/api/alfa-selector.api.md index 9da6be69df..f360c20448 100644 --- a/docs/review/api/alfa-selector.api.md +++ b/docs/review/api/alfa-selector.api.md @@ -42,6 +42,8 @@ export class Context { // (undocumented) isActive(element: Element): boolean; // (undocumented) + isEmpty(): boolean; + // (undocumented) isFocused(element: Element): boolean; // (undocumented) isHovered(element: Element): boolean; diff --git a/docs/review/api/alfa-style.api.md b/docs/review/api/alfa-style.api.md index 6f816a84fa..e1bab902b1 100644 --- a/docs/review/api/alfa-style.api.md +++ b/docs/review/api/alfa-style.api.md @@ -35,6 +35,7 @@ import { Percentage } from '@siteimprove/alfa-css'; import { Position } from '@siteimprove/alfa-css'; import { Predicate } from '@siteimprove/alfa-predicate'; import { Rectangle } from '@siteimprove/alfa-css'; +import { Rectangle as Rectangle_2 } from '@siteimprove/alfa-rectangle'; import { Rotate } from '@siteimprove/alfa-css'; import { Serializable } from '@siteimprove/alfa-json'; import { Shadow } from '@siteimprove/alfa-css'; @@ -48,6 +49,9 @@ import { Tuple } from '@siteimprove/alfa-css'; import { Unit } from '@siteimprove/alfa-css'; import { URL } from '@siteimprove/alfa-css'; +// @public +function getBoundingBox(element: Element, device: Device, context?: Context): Option; + // @public (undocumented) function getOffsetParent(element: Element, device: Device): Option; @@ -440,6 +444,7 @@ export namespace Style { const // Warning: (ae-forgotten-export) The symbol "element" needs to be exported by the entry point index.d.ts // // (undocumented) + getBoundingBox: typeof element.getBoundingBox, // (undocumented) getOffsetParent: typeof element.getOffsetParent, // (undocumented) getPositioningParent: typeof element.getPositioningParent, // (undocumented) hasBorder: typeof element.hasBorder, // (undocumented) diff --git a/packages/alfa-rules/src/common/expectation/contrast.ts b/packages/alfa-rules/src/common/expectation/contrast.ts index 660b407386..90124d5033 100644 --- a/packages/alfa-rules/src/common/expectation/contrast.ts +++ b/packages/alfa-rules/src/common/expectation/contrast.ts @@ -2,8 +2,10 @@ import { Cache } from "@siteimprove/alfa-cache"; import { RGB } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { Element, Node, Text } from "@siteimprove/alfa-dom"; -import { Set } from "@siteimprove/alfa-set"; import { Iterable } from "@siteimprove/alfa-iterable"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Set } from "@siteimprove/alfa-set"; +import { Style } from "@siteimprove/alfa-style"; import { expectation } from "../act/expectation"; import { Group } from "../act/group"; @@ -16,6 +18,7 @@ import { isLargeText } from "../predicate"; const { isElement } = Element; const { flatMap, map, takeWhile } = Iterable; const { min, max, round } = Math; +const { getBoundingBox } = Style; /** * @deprecated This is only used in the deprecated R66v1 and R69v1. @@ -113,7 +116,11 @@ export function hasSufficientContrast( "ignored-interposed-elements", Group.of(interposedDescendants), target - ).answerIf(interposedDescendants.isEmpty(), []); + ).answerIf( + getIntersectors(parent, interposedDescendants, device).map((intersectors) => + interposedDescendants.subtract(intersectors) + ) + ); const foregrounds = Question.of("foreground-colors", target); const backgrounds = Question.of("background-colors", target); @@ -223,3 +230,41 @@ export function contrast(foreground: RGB, background: RGB): number { return round(contrast * 100) / 100; } + +/** + * Finds elements from a collection of candidate that intersect with a given element + * + * @remarks + * If either the element or one of the `candidates` doesn't have layout, we can't fully decide intersection and return `None`. + */ +function getIntersectors( + element: Element, + candidates: Iterable, + device: Device +): Option> { + // If the collection of candidates is empty we don't need layout to determine that there are no intersectors + if (Iterable.isEmpty(candidates)) { + return Option.of(candidates); + } + + const elementBox = getBoundingBox(element, device); + + if ( + !elementBox.isSome() || + Iterable.some(candidates, (candidate) => + getBoundingBox(candidate, device).isNone() + ) + ) { + return None; + } + + return Option.of( + Iterable.filter( + candidates, + (canditate) => + elementBox + .get() + .intersects(getBoundingBox(canditate, device).getUnsafe()) // Presence of the box is guaranteed by the above check + ) + ); +} diff --git a/packages/alfa-rules/test/sia-r69/rule.spec.tsx b/packages/alfa-rules/test/sia-r69/rule.spec.tsx index 0c360d6547..1faf147b28 100644 --- a/packages/alfa-rules/test/sia-r69/rule.spec.tsx +++ b/packages/alfa-rules/test/sia-r69/rule.spec.tsx @@ -4,19 +4,19 @@ import { Future } from "@siteimprove/alfa-future"; import { None } from "@siteimprove/alfa-option"; import { test } from "@siteimprove/alfa-test"; -import { RGB, Percentage, Keyword } from "@siteimprove/alfa-css"; +import { Keyword, Percentage, RGB } from "@siteimprove/alfa-css"; import { Device } from "@siteimprove/alfa-device"; import { Style } from "@siteimprove/alfa-style"; -import R69 from "../../src/sia-r69/rule"; import { Contrast as Diagnostic } from "../../src/common/diagnostic/contrast"; import { Contrast as Outcomes } from "../../src/common/outcome/contrast"; +import R69 from "../../src/sia-r69/rule"; import { evaluate } from "../common/evaluate"; -import { passed, failed, cantTell, inapplicable } from "../common/outcome"; +import { cantTell, failed, inapplicable, passed } from "../common/outcome"; -import { oracle } from "../common/oracle"; import { ColorError, ColorErrors } from "../../src/common/dom/get-colors"; +import { oracle } from "../common/oracle"; const rgb = (r: number, g: number, b: number, a: number = 1) => RGB.of( @@ -856,3 +856,43 @@ test("evaluate() can tell when encountering an opaque background before an absol }), ]); }); + +test("evaluate() can tell when interposed descendant overlaps offset parent, but does not overlap target", async (t) => { + const target = h.text("Hello World"); + + const document = h.document([ + +
+
{target}
+
+
+
+
+
+
+ , + ]); + + t.deepEqual(await evaluate(R69, { document }), [ + passed(R69, target, { + 1: Outcomes.HasSufficientContrast(21, 4.5, [ + Diagnostic.Pairing.of( + ["foreground", rgb(0, 0, 0)], + ["background", rgb(1, 1, 1)], + 21 + ), + ]), + }), + ]); +}); diff --git a/packages/alfa-selector/src/context.ts b/packages/alfa-selector/src/context.ts index cdef2a1395..2a28fe279b 100644 --- a/packages/alfa-selector/src/context.ts +++ b/packages/alfa-selector/src/context.ts @@ -21,6 +21,10 @@ export class Context { this._state = state; } + public isEmpty(): boolean { + return this._state.isEmpty(); + } + public hasState(element: Element, state: Context.State): boolean { return this._state.get(element).some((found) => (found & state) !== 0); } diff --git a/packages/alfa-style/src/element/element.ts b/packages/alfa-style/src/element/element.ts index 06b891a0fe..dc46d252b8 100755 --- a/packages/alfa-style/src/element/element.ts +++ b/packages/alfa-style/src/element/element.ts @@ -1,3 +1,4 @@ +export * from "./helpers/get-bounding-box"; export * from "./helpers/get-offset-parent"; export * from "./helpers/get-positioning-parent"; export * from "./predicate/has-border"; diff --git a/packages/alfa-style/src/element/helpers/get-bounding-box.ts b/packages/alfa-style/src/element/helpers/get-bounding-box.ts new file mode 100644 index 0000000000..1f02d091c1 --- /dev/null +++ b/packages/alfa-style/src/element/helpers/get-bounding-box.ts @@ -0,0 +1,25 @@ +import { Device } from "@siteimprove/alfa-device"; +import { Element } from "@siteimprove/alfa-dom"; +import { None, Option } from "@siteimprove/alfa-option"; +import { Rectangle } from "@siteimprove/alfa-rectangle"; +import { Context } from "@siteimprove/alfa-selector"; + +/** + * @public + * Gets the bounding box, corresponding to a specific device, of an element + * + * @privateRemarks + * We don't use the passed in device yet, but later we should use it to ensure the device used to collect the bounding box corresponds to the current device + */ +export function getBoundingBox( + element: Element, + device: Device, + context: Context = Context.empty() +): Option { + // We assume layout is only grabbed on empty contexts, so if the context is non-empty we don't have layout + if (!context.isEmpty()) { + return None; + } + + return element.box; +} diff --git a/packages/alfa-style/src/style.ts b/packages/alfa-style/src/style.ts index 72e026c475..253b5aea01 100644 --- a/packages/alfa-style/src/style.ts +++ b/packages/alfa-style/src/style.ts @@ -336,6 +336,7 @@ export namespace Style { export type Inherited = Longhands.Inherited; export const { + getBoundingBox, getOffsetParent, getPositioningParent, hasBorder, diff --git a/packages/alfa-style/tsconfig.json b/packages/alfa-style/tsconfig.json index 6eb14afa48..bc436d9e42 100644 --- a/packages/alfa-style/tsconfig.json +++ b/packages/alfa-style/tsconfig.json @@ -7,6 +7,7 @@ }, "files": [ "src/element/element.ts", + "src/element/helpers/get-bounding-box.ts", "src/element/helpers/get-offset-parent.ts", "src/element/helpers/get-positioning-parent.ts", "src/element/predicate/has-border.ts",