Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/neat-ads-happen.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/review/api/alfa-dom.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export class Element<N extends string = string> extends Node<"element"> implemen
protected _inputType: helpers.InputType | undefined;
// @internal (undocumented)
protected _internalPath(options?: Node.Traversal): string;
isInert(): boolean;
// (undocumented)
isVoid(): boolean;
// (undocumented)
Expand Down
2 changes: 1 addition & 1 deletion docs/review/api/alfa-style.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Element<string>, []>, // (undocumented)
isPositioned: typeof element.isPositioned, // (undocumented)
isTabbable: typeof element.isTabbable, // (undocumented)
isVisibleShadow: typeof element.isVisibleShadow;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,15 +23,7 @@ export function isProgrammaticallyHidden(
device: Device,
context: Context = Context.empty(),
): Predicate<Element> {
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 (
Expand Down
62 changes: 51 additions & 11 deletions packages/alfa-aria/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,18 +275,24 @@ 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;
}

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 {
Expand All @@ -297,23 +303,41 @@ 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 {
if (this._isVisible === isVisible) {
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,
Expand Down Expand Up @@ -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);
}
Comment on lines +433 to +435
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Won't that break for deeply nested dialogs?

<div inert>
  <div>
    <dialog open>Hello</dialog>
  </div>
</div>

The outer <div> is a Container due to its inert attribute. The inner <div> is a Inert due to the state being set to inert by the parent, this means we never reach the dialog 🤔

I agree that we should set inert elements as Inert when possible, since we work on static snapshot, this will reduce the accessibility tree size and probably make some request faster.
Given that we do now have a Element#isInert predicate (and this seems to redo part of it), how about something like:

  • if inert is set, and no descendant is an open dialog not inert => Inert;
  • if inert is set and some descendant is not inert => Container;
  • if State.inert, then we have an inert Container ancestor:
    • if open dialog not inert => Element and reset State.inert;
    • if no descendant is not inert => Inert
    • otherwise, Container.
  • otherwise, as usual.

This probably requires some caching of "no descendant is not inert". Or maybe we can start by gathering all open dialogs (should be at most 1, iirc) and the set of their ancestors 🤔


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
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = <button>Click me</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), false);
});

test("isProgrammaticallyHidden() returns true for elements with display: none", (t) => {
const button = <button style={{ display: "none" }}>Hidden</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note (non-blocking): t.equal(foo, true) is actually equivalent to t(foo); and t.equal(foo, false) to !t(foo), although I'm still not really sure which style I prefer… (explicitness vs compactness).

});

test("isProgrammaticallyHidden() returns true for elements with visibility: hidden", (t) => {
const button = <button style={{ visibility: "hidden" }}>Hidden</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for elements with visibility: collapse", (t) => {
const button = <button style={{ visibility: "collapse" }}>Hidden</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns false for elements with visibility: visible", (t) => {
const button = <button style={{ visibility: "visible" }}>Visible</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), false);
});

test("isProgrammaticallyHidden() returns true for elements with aria-hidden='true'", (t) => {
const button = <button aria-hidden="true">Hidden</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns false for elements with aria-hidden='false'", (t) => {
const button = <button aria-hidden="false">Visible</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), false);
});

test("isProgrammaticallyHidden() returns true for inert elements", (t) => {
const button = <button inert>Inert</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for elements inside inert container", (t) => {
const button = <button>Not inert itself</button>;
const parent = <div inert>{button}</div>;
h.document([parent]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for elements with display: none ancestor", (t) => {
const button = <button>Hidden by ancestor</button>;
const parent = <div style={{ display: "none" }}>{button}</div>;
h.document([parent]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for elements with aria-hidden='true' ancestor", (t) => {
const button = <button>Hidden by ancestor</button>;
const parent = <div aria-hidden="true">{button}</div>;
h.document([parent]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for elements with visibility: hidden ancestor", (t) => {
const button = <button>Hidden by ancestor</button>;
const parent = <div style={{ visibility: "hidden" }}>{button}</div>;
h.document([parent]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for elements with visibility: collapse ancestor", (t) => {
const button = <button>Hidden by ancestor</button>;
const parent = <div style={{ visibility: "collapse" }}>{button}</div>;
h.document([parent]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns true for deeply nested hidden elements", (t) => {
const button = <button>Deeply hidden</button>;
const level2 = <div>{button}</div>;
const level1 = <div aria-hidden="true">{level2}</div>;
h.document([level1]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns false for elements inside open dialog within inert container", (t) => {
const button = <button>Should be visible</button>;
const dialog = <dialog open>{button}</dialog>;
const parent = <div inert>{dialog}</div>;
h.document([parent]);

t.equal(isProgrammaticallyHidden(button), false);
});

test("isProgrammaticallyHidden() returns true for elements with multiple hiding conditions", (t) => {
const button = (
<button aria-hidden="true" style={{ display: "none" }}>
Multiple conditions
</button>
);
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns false for visible elements with visible ancestors", (t) => {
const button = <button>Visible</button>;
const parent = <div>{button}</div>;
const grandparent = <section>{parent}</section>;
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 = (
<button aria-hidden="false" style={{ display: "none" }}>
Hidden
</button>
);
h.document([button]);

t.equal(isProgrammaticallyHidden(button), true);
});

test("isProgrammaticallyHidden() returns false for elements with display: block", (t) => {
const button = <button style={{ display: "block" }}>Visible</button>;
h.document([button]);

t.equal(isProgrammaticallyHidden(button), false);
});

test("isProgrammaticallyHidden() handles mixed visibility in hierarchy", (t) => {
const visibleButton = <button>Visible</button>;
const hiddenButton = <button>Hidden</button>;
const hiddenDiv = <div aria-hidden="true">{hiddenButton}</div>;
const parent = (
<div>
{visibleButton}
{hiddenDiv}
</div>
);
h.document([parent]);

t.equal(isProgrammaticallyHidden(visibleButton), false);
t.equal(isProgrammaticallyHidden(hiddenButton), true);
});
Loading