diff --git a/packages/alfa-rules/src/common/predicate.ts b/packages/alfa-rules/src/common/predicate.ts index 729f848546..3c4d4347d0 100644 --- a/packages/alfa-rules/src/common/predicate.ts +++ b/packages/alfa-rules/src/common/predicate.ts @@ -4,6 +4,7 @@ export * from "./predicate/has-border"; export * from "./predicate/has-box-shadow"; export * from "./predicate/has-cascaded-value-declared-in-inline-style"; export * from "./predicate/has-child"; +export * from "./predicate/has-computed-style"; export * from "./predicate/has-descendant"; export * from "./predicate/has-explicit-role"; export * from "./predicate/has-heading-level"; diff --git a/packages/alfa-rules/src/common/predicate/has-computed-style.ts b/packages/alfa-rules/src/common/predicate/has-computed-style.ts new file mode 100644 index 0000000000..1a14bdacfd --- /dev/null +++ b/packages/alfa-rules/src/common/predicate/has-computed-style.ts @@ -0,0 +1,23 @@ +import { Device } from "@siteimprove/alfa-device"; +import { Element, Text } from "@siteimprove/alfa-dom"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Context } from "@siteimprove/alfa-selector"; +import { Property, Style } from "@siteimprove/alfa-style"; + +const { isElement } = Element; + +export function hasComputedStyle( + name: N, + predicate: Predicate>, + device: Device, + context?: Context +): Predicate { + return function hasComputedStyle(node): boolean { + return isElement(node) + ? Style.from(node, device, context).computed(name).some(predicate) + : node + .parent({ flattened: true }) + .filter(isElement) + .some(hasComputedStyle); + }; +} diff --git a/packages/alfa-rules/src/common/predicate/is-clipped.ts b/packages/alfa-rules/src/common/predicate/is-clipped.ts index 71ed96dc26..19c59d0b43 100644 --- a/packages/alfa-rules/src/common/predicate/is-clipped.ts +++ b/packages/alfa-rules/src/common/predicate/is-clipped.ts @@ -1,17 +1,21 @@ import { Cache } from "@siteimprove/alfa-cache"; import { Device } from "@siteimprove/alfa-device"; -import { Element, Text, Node } from "@siteimprove/alfa-dom"; +import { Element, Node } from "@siteimprove/alfa-dom"; import { Predicate } from "@siteimprove/alfa-predicate"; +import { Refinement } from "@siteimprove/alfa-refinement"; import { Context } from "@siteimprove/alfa-selector"; import { Style } from "@siteimprove/alfa-style"; const { abs } = Math; const { isElement } = Element; const { or, test } = Predicate; -const { isText } = Text; +const { and } = Refinement; const cache = Cache.empty>>(); +/** + * Checks if a node (or one of its ancestor) is fully clipped + */ export function isClipped( device: Device, context: Context = Context.empty() @@ -23,132 +27,139 @@ export function isClipped( .get(node, () => test( or( - isClippedBySize(device, context), - isClippedByMasking(device, context) + // Either it a clipped element + and( + isElement, + or( + isClippedBySize(device, context), + isClippedByIndent(device, context), + isClippedByMasking(device, context) + ) + ), + // Or its parent is clipped + (node: Node) => + node + .parent({ + flattened: true, + nested: true, + }) + .some(isClipped(device, context)) ), node ) ); } +/** + * Checks if an element's size is reduced to 0 or 1 pixel, and overflow is + * somehow hidden. + */ function isClippedBySize( device: Device, context: Context = Context.empty() -): Predicate { - return function isClipped(node): boolean { - if (isElement(node)) { - const style = Style.from(node, device, context); - - const { value: x } = style.computed("overflow-x"); - const { value: y } = style.computed("overflow-y"); - - const { value: height } = style.computed("height"); - const { value: width } = style.computed("width"); - - if (x.value === "hidden" || y.value === "hidden") { - for (const dimension of [height, width]) { - switch (dimension.type) { - case "percentage": - if (dimension.value <= 0) { - return true; - } else { - break; - } - - case "length": - if (dimension.value <= 1) { - return true; - } else { - break; - } - } - } - } +): Predicate { + return function isClipped(element: Element): boolean { + const style = Style.from(element, device, context); + + const { + value: { value: x }, + } = style.computed("overflow-x"); + const { + value: { value: y }, + } = style.computed("overflow-y"); + + const { value: height } = style.computed("height"); + const { value: width } = style.computed("width"); + + const hasNoScrollBar = x === "hidden" || y === "hidden"; + + if (x !== "visible" || y !== "visible") { + for (const dimension of [height, width]) { + switch (dimension.type) { + case "percentage": + if (dimension.value <= 0) { + return true; + } else { + break; + } - if ( - x.value === "auto" || - x.value === "scroll" || - y.value === "auto" || - y.value === "scroll" - ) { - for (const dimension of [height, width]) { - switch (dimension.type) { - case "percentage": - if (dimension.value <= 0) { - return true; - } else { - break; - } - - case "length": - if (dimension.value <= 0) { - return true; - } else { - break; - } - } + // Technically, 1×1 elements are (possibly) visible since they + // show one pixel of background. We assume this is used to hide + // elements and that the background is the same as the surrounding + // one. + case "length": + if (dimension.value <= (hasNoScrollBar ? 1 : 0)) { + return true; + } else { + break; + } } } } - for (const parent of node.parent({ flattened: true })) { - if (isText(node) && isElement(parent)) { - const style = Style.from(parent, device, context); - - const { value: x } = style.computed("overflow-x"); - - if (x.value === "hidden") { - const { value: indent } = style.computed("text-indent"); - const { value: whitespace } = style.computed("white-space"); + return false; + }; +} - if (indent.value < 0 || whitespace.value === "nowrap") { - switch (indent.type) { - case "percentage": - if (abs(indent.value) >= 1) { - return true; - } +/** + * Checks if an element is fully indented out of its box. + */ +function isClippedByIndent( + device: Device, + context: Context +): Predicate { + return function isClipped(element: Element): boolean { + const style = Style.from(element, device, context); + + const { value: x } = style.computed("overflow-x"); + + if (x.value === "hidden") { + const { value: indent } = style.computed("text-indent"); + const { value: whitespace } = style.computed("white-space"); + + if (indent.value < 0 || whitespace.value === "nowrap") { + switch (indent.type) { + case "percentage": + if (abs(indent.value) >= 1) { + return true; + } else { + break; + } - case "length": - if (abs(indent.value) >= 999) { - return true; - } + case "length": + if (abs(indent.value) >= 999) { + return true; + } else { + break; } - } } } - - return isClipped(parent); } return false; }; } -function isClippedByMasking(device: Device, context: Context): Predicate { - return function isClipped(node: Node): boolean { - if (isElement(node)) { - const style = Style.from(node, device, context); - - const { value: clip } = style.computed("clip"); - const { value: position } = style.computed("position"); - - if ( - (position.value === "absolute" || position.value === "fixed") && - clip.type === "shape" && - ((clip.shape.top.type === "length" && - clip.shape.top.equals(clip.shape.bottom)) || - (clip.shape.left.type === "length" && - clip.shape.top.equals(clip.shape.right))) - ) { - return true; - } - } - - return node - .parent({ - flattened: true, - nested: true, - }) - .some(isClipped); +/** + * Checks if an element is fully masked by a clipping shape. + */ +function isClippedByMasking( + device: Device, + context: Context +): Predicate { + return function isClipped(element: Element): boolean { + const style = Style.from(element, device, context); + + const { value: clip } = style.computed("clip"); + const { value: position } = style.computed("position"); + + return ( + (position.value === "absolute" || position.value === "fixed") && + clip.type === "shape" && + ((clip.shape.top.type === "length" && + clip.shape.top.equals(clip.shape.bottom)) || + (clip.shape.left.type === "length" && + clip.shape.top.equals(clip.shape.right))) + ); }; } diff --git a/packages/alfa-rules/src/common/predicate/is-visible.ts b/packages/alfa-rules/src/common/predicate/is-visible.ts index f0e3f77a1b..11843e2fcf 100644 --- a/packages/alfa-rules/src/common/predicate/is-visible.ts +++ b/packages/alfa-rules/src/common/predicate/is-visible.ts @@ -1,12 +1,12 @@ import { Device } from "@siteimprove/alfa-device"; import { Element, Text, Node } from "@siteimprove/alfa-dom"; -import { Iterable } from "@siteimprove/alfa-iterable"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Refinement } from "@siteimprove/alfa-refinement"; import { Context } from "@siteimprove/alfa-selector"; -import { Style } from "@siteimprove/alfa-style"; +import { Property, Style } from "@siteimprove/alfa-style"; import { + hasComputedStyle, isClipped, isOffscreen, isRendered, @@ -14,70 +14,53 @@ import { isTransparent, } from "../predicate"; -const { every } = Iterable; -const { not } = Predicate; -const { and, or } = Refinement; +const { nor, not, or } = Predicate; +const { and } = Refinement; const { hasName, isElement } = Element; const { isText } = Text; +/** + * Checks if a node is visible + */ export function isVisible(device: Device, context?: Context): Predicate { - return and( - isRendered(device, context), - not(isTransparent(device, context)), - not( - and( - or(isElement, isText), - or(isClipped(device, context), isOffscreen(device, context)) + return not(isInvisible(device, context)); +} + +function isInvisible(device: Device, context?: Context): Predicate { + return or( + not(isRendered(device, context)), + isTransparent(device, context), + isClipped(device, context), + isOffscreen(device, context), + // Empty text + and(isText, (text) => text.data.trim() === ""), + // Text of size 0 + and( + isText, + hasComputedStyle("font-size", (size) => size.value === 0, device, context) + ), + // Element with visibility != "visible" + and( + isElement, + hasComputedStyle( + "visibility", + (visibility) => visibility.value !== "visible", + device, + context ) ), - (node) => { - if ( - isElement(node) && - Style.from(node, device, context) - .computed("visibility") - .some((visibility) => visibility.value !== "visible") - ) { - return false; - } - - if (isText(node)) { - if (node.data.trim() === "") { - return false; - } - - if ( - node - .parent({ - flattened: true, - }) - .filter(isElement) - .some((element) => - Style.from(element, device, context) - .computed("font-size") - .some((size) => size.value === 0) - ) - ) { - return false; - } - } - - return true; - }, // Most non-replaced elements with no visible children are not visible while // replaced elements are assumed to be replaced by something visible. Some // non-replaced elements are, however, visible even when empty. - not( - and( - isElement, - and(not(or(isReplaced, isVisibleWhenEmpty)), (element) => - every( - element.children({ - nested: true, - flattened: true, - }), - not(isVisible(device, context)) - ) - ) + and( + isElement, + and(nor(isReplaced, isVisibleWhenEmpty), (element) => + element + .children({ + nested: true, + flattened: true, + }) + .every(isInvisible(device, context)) ) ) ); diff --git a/packages/alfa-rules/test/common/predicate/is-visible.spec.tsx b/packages/alfa-rules/test/common/predicate/is-visible.spec.tsx index 635f106706..d2224a2e1d 100644 --- a/packages/alfa-rules/test/common/predicate/is-visible.spec.tsx +++ b/packages/alfa-rules/test/common/predicate/is-visible.spec.tsx @@ -418,3 +418,27 @@ test(`isVisible() returns false for a text node with a parent element with t.equal(isVisible(text), false); t.equal(isVisible(element), false); }); + +test(`isVisible() returns false for an element with a fully clipped ancestor`, (t) => { + const spanSize = Hello World; + const spanIndent = Hello World; + const spanMask = Hello World; + + h.document([ +
+ {spanSize} +
, +
+ {spanIndent} +
, +
+ {spanMask} +
, + ]); + + for (const target of [spanSize, spanIndent, spanMask]) { + t.equal(isVisible(target), false); + } +}); diff --git a/packages/alfa-rules/tsconfig.json b/packages/alfa-rules/tsconfig.json index 746cecb88c..49a1867d77 100644 --- a/packages/alfa-rules/tsconfig.json +++ b/packages/alfa-rules/tsconfig.json @@ -28,6 +28,7 @@ "src/common/predicate/has-box-shadow.ts", "src/common/predicate/has-cascaded-value-declared-in-inline-style.ts", "src/common/predicate/has-child.ts", + "src/common/predicate/has-computed-style.ts", "src/common/predicate/has-descendant.ts", "src/common/predicate/has-explicit-role.ts", "src/common/predicate/has-heading-level.ts",