diff --git a/.changeset/neat-ads-happen.md b/.changeset/neat-ads-happen.md new file mode 100644 index 0000000000..63af1aeaa2 --- /dev/null +++ b/.changeset/neat-ads-happen.md @@ -0,0 +1,8 @@ +--- +"@siteimprove/alfa-aria": patch +"@siteimprove/alfa-dom": patch +"@siteimprove/alfa-rules": patch +"@siteimprove/alfa-style": patch +--- + +**Added:** The `inert` attribute is now supported. diff --git a/docs/review/api/alfa-dom.api.md b/docs/review/api/alfa-dom.api.md index ed320917b3..551b81ae9a 100644 --- a/docs/review/api/alfa-dom.api.md +++ b/docs/review/api/alfa-dom.api.md @@ -316,6 +316,7 @@ export class Element extends Node<"element"> implemen protected _inputType: helpers.InputType | undefined; // @internal (undocumented) protected _internalPath(options?: Node.Traversal): string; + isInert(): boolean; // (undocumented) isVoid(): boolean; // (undocumented) diff --git a/docs/review/api/alfa-style.api.md b/docs/review/api/alfa-style.api.md index a879ec7618..5b13183e65 100644 --- a/docs/review/api/alfa-style.api.md +++ b/docs/review/api/alfa-style.api.md @@ -485,7 +485,7 @@ export namespace Style { isFlexOrGridChild: typeof element.isFlexOrGridChild, // (undocumented) isFocusable: typeof element.isFocusable, // (undocumented) isImportant: typeof element.isImportant, // (undocumented) - isInert: typeof element.isInert, // (undocumented) + isInert: (device: Device) => Predicate, []>, // (undocumented) isPositioned: typeof element.isPositioned, // (undocumented) isTabbable: typeof element.isTabbable, // (undocumented) isVisibleShadow: typeof element.isVisibleShadow; diff --git a/packages/alfa-aria/src/dom/predicate/is-programmatically-hidden.ts b/packages/alfa-aria/src/dom/predicate/is-programmatically-hidden.ts index 340106f7bc..399215d4c2 100644 --- a/packages/alfa-aria/src/dom/predicate/is-programmatically-hidden.ts +++ b/packages/alfa-aria/src/dom/predicate/is-programmatically-hidden.ts @@ -9,7 +9,7 @@ import { Style } from "@siteimprove/alfa-style"; const { hasAttribute, isElement } = Element; const { or, equals } = Predicate; const { and } = Refinement; -const { hasComputedStyle } = Style; +const { hasComputedStyle, isInert } = Style; /** * Check if an element is programmatically hidden. @@ -23,15 +23,7 @@ export function isProgrammaticallyHidden( device: Device, context: Context = Context.empty(), ): Predicate { - return or( - hasComputedStyle( - "visibility", - (visibility) => visibility.value !== "visible", - device, - context, - ), - hasHiddenAncestors(device, context), - ); + return or(isInert(device), hasHiddenAncestors(device, context)); } const hasHiddenAncestors = Cache.memoize(function ( diff --git a/packages/alfa-aria/src/node.ts b/packages/alfa-aria/src/node.ts index 021a9b6a06..fa364cb81c 100644 --- a/packages/alfa-aria/src/node.ts +++ b/packages/alfa-aria/src/node.ts @@ -275,7 +275,7 @@ export namespace Node { } class State { - private static _empty = new State(false, true); + private static _empty = new State(false, true, false); public static empty(): State { return this._empty; @@ -283,10 +283,16 @@ export namespace Node { private readonly _isPresentational: boolean; private readonly _isVisible: boolean; + private readonly _isInert: boolean; - protected constructor(isPresentational: boolean, isVisible: boolean) { + protected constructor( + isPresentational: boolean, + isVisible: boolean, + isInert: boolean, + ) { this._isPresentational = isPresentational; this._isVisible = isVisible; + this._isInert = isInert; } public get isPresentational(): boolean { @@ -297,12 +303,16 @@ export namespace Node { return this._isVisible; } + public get isInert(): boolean { + return this._isInert; + } + public presentational(isPresentational: boolean): State { if (this._isPresentational === isPresentational) { return this; } - return new State(isPresentational, this._isVisible); + return new State(isPresentational, this._isVisible, this._isInert); } public visible(isVisible: boolean): State { @@ -310,10 +320,24 @@ export namespace Node { return this; } - return new State(this._isPresentational, isVisible); + return new State(this._isPresentational, isVisible, this._isInert); + } + + public inert(isInert: boolean): State { + if (this._isInert === isInert) { + return this; + } + + return new State(this._isPresentational, this._isVisible, isInert); } } + const hasInertDomAttribute = dom.Element.hasAttribute("inert"); + const isOpenDialog = and( + dom.Element.hasName("dialog"), + dom.Element.hasAttribute("open"), + ); + function fromNode( node: dom.Node, device: Device, @@ -397,6 +421,19 @@ export namespace Node { state = state.visible(true); + if (hasInertDomAttribute(node)) { + // Elements with the inert attribute are exposed as containers + // as they may contain non-inert descendants + return Container.of(node, children(state.inert(true))); + } else if (isOpenDialog(node)) { + // Open dialogs without the inert attribute escapes inertness + state = state.inert(false); + } + + if (state.isInert) { + return Inert.of(node); + } + const role = Role.fromExplicit(node).orElse(() => // If the element has no explicit role and instead inherits a // presentational role then use that, otherwise fall back to the @@ -464,13 +501,16 @@ export namespace Node { // nor a tabindex, it is not itself interesting for accessibility // purposes. It is therefore exposed as a container. // Some elements (mostly embedded content) are always exposed. + // However, if the element is inert, it becomes an Inert node instead. if ( attributes.isEmpty() && role.every(Role.hasName("generic")) && node.tabIndex().isNone() && !test(alwaysExpose, node) ) { - return Container.of(node, children(state), role); + return state.isInert + ? Inert.of(node) + : Container.of(node, children(state), role); } // If the element has a role that designates its children as @@ -489,12 +529,12 @@ export namespace Node { } if (dom.Text.isText(node)) { - // As elements with `visibility: hidden` are exposed as containers for - // other elements that _might_ be visible, we need to check the - // visibility of the parent element before deciding to expose the text - // node. If the parent element isn't visible, the text node instead - // becomes inert. - if (!state.isVisible) { + // As elements with `visibility: hidden` or inert are exposed as + // containers for other elements that _might_ be visible or escape + // inertness, we need to check the state before deciding to expose + // the text node. If the parent element isn't visible or is inert, + // the text node becomes inert. + if (!state.isVisible || state.isInert) { return Inert.of(node); } diff --git a/packages/alfa-aria/test/dom/predicate/is-programmatically-hidden.spec.tsx b/packages/alfa-aria/test/dom/predicate/is-programmatically-hidden.spec.tsx new file mode 100644 index 0000000000..d5407b3016 --- /dev/null +++ b/packages/alfa-aria/test/dom/predicate/is-programmatically-hidden.spec.tsx @@ -0,0 +1,177 @@ +import { h } from "@siteimprove/alfa-dom/h"; +import { test } from "@siteimprove/alfa-test"; + +import { Device } from "@siteimprove/alfa-device"; + +import { DOM } from "../../../dist/index.js"; + +const device = Device.standard(); +const isProgrammaticallyHidden = DOM.isProgrammaticallyHidden(device); + +test("isProgrammaticallyHidden() returns false for visible elements", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), false); +}); + +test("isProgrammaticallyHidden() returns true for elements with display: none", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements with visibility: hidden", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements with visibility: collapse", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns false for elements with visibility: visible", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), false); +}); + +test("isProgrammaticallyHidden() returns true for elements with aria-hidden='true'", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns false for elements with aria-hidden='false'", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), false); +}); + +test("isProgrammaticallyHidden() returns true for inert elements", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements inside inert container", (t) => { + const button = ; + const parent =
{button}
; + h.document([parent]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements with display: none ancestor", (t) => { + const button = ; + const parent =
{button}
; + h.document([parent]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements with aria-hidden='true' ancestor", (t) => { + const button = ; + const parent = ; + h.document([parent]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements with visibility: hidden ancestor", (t) => { + const button = ; + const parent =
{button}
; + h.document([parent]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for elements with visibility: collapse ancestor", (t) => { + const button = ; + const parent =
{button}
; + h.document([parent]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns true for deeply nested hidden elements", (t) => { + const button = ; + const level2 =
{button}
; + const level1 = ; + h.document([level1]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns false for elements inside open dialog within inert container", (t) => { + const button = ; + const dialog = {button}; + const parent =
{dialog}
; + h.document([parent]); + + t.equal(isProgrammaticallyHidden(button), false); +}); + +test("isProgrammaticallyHidden() returns true for elements with multiple hiding conditions", (t) => { + const button = ( + + ); + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns false for visible elements with visible ancestors", (t) => { + const button = ; + const parent =
{button}
; + const grandparent =
{parent}
; + h.document([grandparent]); + + t.equal(isProgrammaticallyHidden(button), false); +}); + +test("isProgrammaticallyHidden() returns true for elements with display: none even if aria-hidden='false'", (t) => { + const button = ( + + ); + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), true); +}); + +test("isProgrammaticallyHidden() returns false for elements with display: block", (t) => { + const button = ; + h.document([button]); + + t.equal(isProgrammaticallyHidden(button), false); +}); + +test("isProgrammaticallyHidden() handles mixed visibility in hierarchy", (t) => { + const visibleButton = ; + const hiddenButton = ; + const hiddenDiv = ; + const parent = ( +
+ {visibleButton} + {hiddenDiv} +
+ ); + h.document([parent]); + + t.equal(isProgrammaticallyHidden(visibleButton), false); + t.equal(isProgrammaticallyHidden(hiddenButton), true); +}); diff --git a/packages/alfa-aria/test/node.spec.tsx b/packages/alfa-aria/test/node.spec.tsx index 592786432e..a673178d72 100644 --- a/packages/alfa-aria/test/node.spec.tsx +++ b/packages/alfa-aria/test/node.spec.tsx @@ -658,3 +658,110 @@ test(`.from() treats second elements as generic`, (t) => { ], }); }); + +test(`.from() does not expose elements with the inert attribute`, (t) => { + const target =
Hello world
; + + t.deepEqual(Node.from(target, device).toJSON(), { + type: "container", + node: "/div[1]", + role: null, + children: [ + { + type: "inert", + node: "/div[1]/text()[1]", + }, + ], + }); +}); + +test(`.from() does not expose flat tree descendants of elements with the inert attribute`, (t) => { + const target = ( +
+ Some text +
+ ); + + t.deepEqual(Node.from(target, device).toJSON(), { + type: "container", + node: "/div[1]", + role: null, + children: [ + { + type: "inert", + node: "/div[1]/span[1]", + }, + ], + }); +}); + +test(`.from() exposes descendants that escape inertness via open dialog`, (t) => { + const target = ( +
+ Hidden text + +
I'm in a popup dialog
+
+
+ ); + + t.deepEqual(Node.from(target, device).toJSON(), { + type: "container", + node: "/div[1]", + role: null, + children: [ + { + type: "inert", + node: "/div[1]/span[1]", + }, + { + type: "element", + node: "/div[1]/dialog[1]", + role: "dialog", + name: null, + attributes: [ + { + name: "aria-expanded", + value: "true", + }, + ], + children: [ + { + type: "container", + node: "/div[1]/dialog[1]/div[1]", + role: "generic", + children: [ + { + type: "text", + node: "/div[1]/dialog[1]/div[1]/text()[1]", + name: "I'm in a popup dialog", + }, + ], + }, + ], + }, + ], + }); +}); + +test(`.from() does not expose dialog without open attribute inside inert element`, (t) => { + const target = ( +
+ + + +
+ ); + + t.deepEqual(Node.from(target, device).toJSON(), { + type: "container", + node: "/div[1]", + role: null, + children: [ + { + type: "inert", + node: "/div[1]/dialog[1]", + }, + ], + }); +}); diff --git a/packages/alfa-aria/test/tsconfig.json b/packages/alfa-aria/test/tsconfig.json index 7653842782..d291ecb079 100644 --- a/packages/alfa-aria/test/tsconfig.json +++ b/packages/alfa-aria/test/tsconfig.json @@ -11,6 +11,7 @@ "./dom/predicate/has-accessible-name.spec.tsx", "./dom/predicate/has-incorrect-role-without-name.spec.tsx", "./dom/predicate/has-role.spec.tsx", + "./dom/predicate/is-programmatically-hidden.spec.tsx", "./name.spec.tsx", "./name-testable-statements.spec.tsx", "./node.spec.tsx", diff --git a/packages/alfa-dom/src/node/element.ts b/packages/alfa-dom/src/node/element.ts index 74646e69e4..fec71d52c8 100644 --- a/packages/alfa-dom/src/node/element.ts +++ b/packages/alfa-dom/src/node/element.ts @@ -268,6 +268,30 @@ export class Element return None; } + /** + * Computes inertness of an element based on the `inert` attribute. + * + * {@link https://html.spec.whatwg.org/#the-inert-attribute} + * + * @privateRemarks + * According to {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inert} + * only open dialogs can escape inertness (except when they have the `inert` attribute). + */ + public isInert(): boolean { + if (this._isInert === undefined) { + if (this.attribute("inert").isSome()) { + this._isInert = true; + } else if (this.name === "dialog" && this.attribute("open").isSome()) { + this._isInert = false; + } else { + this._isInert = this.parent(Node.flatTree) + .filter(Element.isElement) + .some((parent) => parent.isInert()); + } + } + return this._isInert; + } + /* * This collects caches for methods that are specific to some kind of elements. * The actual methods are declared in element/augment.ts to de-clutter this @@ -279,6 +303,7 @@ export class Element protected _inputType: helpers.InputType | undefined; protected _displaySize: number | undefined; protected _optionsList: Sequence> | undefined; + private _isInert: boolean | undefined; /* * End of caches for methods specific to some kind of elements. diff --git a/packages/alfa-dom/src/node/element/predicate/is-suggested-focusable.ts b/packages/alfa-dom/src/node/element/predicate/is-suggested-focusable.ts index ad0aa34ddb..95debc1dcd 100644 --- a/packages/alfa-dom/src/node/element/predicate/is-suggested-focusable.ts +++ b/packages/alfa-dom/src/node/element/predicate/is-suggested-focusable.ts @@ -12,6 +12,10 @@ import { Element } from "../../element.js"; * @public */ export function isSuggestedFocusable(element: Element): boolean { + if (element.isInert()) { + return false; + } + switch (element.name) { case "a": case "link": diff --git a/packages/alfa-dom/test/node/predicate/is-suggested-focusable.spec.tsx b/packages/alfa-dom/test/node/predicate/is-suggested-focusable.spec.tsx new file mode 100644 index 0000000000..7bc0a20a6a --- /dev/null +++ b/packages/alfa-dom/test/node/predicate/is-suggested-focusable.spec.tsx @@ -0,0 +1,143 @@ +import { test } from "@siteimprove/alfa-test"; +import { h, type Element } from "../../../dist/index.js"; +import { isSuggestedFocusable } from "../../../dist/node/element/predicate/is-suggested-focusable.js"; + +test("isSuggestedFocusable() returns false for inert elements", (t) => { + const div =
content
; + const button = ; + const input = ; + + t.deepEqual(isSuggestedFocusable(div), false); + t.deepEqual(isSuggestedFocusable(button), false); + t.deepEqual(isSuggestedFocusable(input), false); +}); + +test("isSuggestedFocusable() handles nested inert containers", (t) => { + const button = ; + h.document([
{button}
]); + t.deepEqual(isSuggestedFocusable(button), false); +}); + +test("isSuggestedFocusable() returns true for elements inside open dialog within inert container", (t) => { + const button = ; + h.document([ +
+ {button} +
, + ]); + t.deepEqual(isSuggestedFocusable(button), true); +}); + +test("isSuggestedFocusable() returns true for with href", (t) => { + const link = link; + t.deepEqual(isSuggestedFocusable(link), true); +}); + +test("isSuggestedFocusable() returns false for without href", (t) => { + const link = not a link; + t.deepEqual(isSuggestedFocusable(link), false); +}); + +test("isSuggestedFocusable() returns true for with href", (t) => { + const link = ; + t.deepEqual(isSuggestedFocusable(link), true); +}); + +test("isSuggestedFocusable() returns false for without href", (t) => { + const link = ; + t.deepEqual(isSuggestedFocusable(link), false); +}); + +test("isSuggestedFocusable() returns true for with non-hidden type", (t) => { + const text = ; + const checkbox = ; + const radio = ; + const button = ; + const noType = ; + + t.deepEqual(isSuggestedFocusable(text), true); + t.deepEqual(isSuggestedFocusable(checkbox), true); + t.deepEqual(isSuggestedFocusable(radio), true); + t.deepEqual(isSuggestedFocusable(button), true); + t.deepEqual(isSuggestedFocusable(noType), true); +}); + +test("isSuggestedFocusable() returns false for ", (t) => { + const hidden = ; + t.deepEqual(isSuggestedFocusable(hidden), false); +}); + +test("isSuggestedFocusable() returns true for