diff --git a/.changeset/dry-cows-tap.md b/.changeset/dry-cows-tap.md new file mode 100644 index 0000000000..265dc31df1 --- /dev/null +++ b/.changeset/dry-cows-tap.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-aria": patch +--- + +**Fixed:** `` elements that are not summary for their parent details are now correctly treated as `generic` role. diff --git a/.changeset/eight-ants-lick.md b/.changeset/eight-ants-lick.md new file mode 100644 index 0000000000..4106fb906d --- /dev/null +++ b/.changeset/eight-ants-lick.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-dom": minor +--- + +**Added:** An `Element<"summary">#isSummaryForItsParentDetails` predicate is now available. diff --git a/.changeset/eight-dolphins-taste.md b/.changeset/eight-dolphins-taste.md new file mode 100644 index 0000000000..7727ae2c27 --- /dev/null +++ b/.changeset/eight-dolphins-taste.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-rules": minor +--- + +**Added:** SIA-R116: "`` element has non-empty accessible name" is now available. diff --git a/.changeset/five-frogs-beg.md b/.changeset/five-frogs-beg.md new file mode 100644 index 0000000000..67ff66af99 --- /dev/null +++ b/.changeset/five-frogs-beg.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-aria": patch +--- + +**Fixed:** `
` elements now correctly have an implicit role of `group`. diff --git a/.changeset/serious-dragons-clean.md b/.changeset/serious-dragons-clean.md new file mode 100644 index 0000000000..8e39638a8d --- /dev/null +++ b/.changeset/serious-dragons-clean.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-aria": patch +--- + +**Fixed:** `` elements that are summary for their parent details now correctly have their name computed from content. diff --git a/packages/alfa-aria/src/feature.ts b/packages/alfa-aria/src/feature.ts index 682a9b47c7..7a7731ad2d 100644 --- a/packages/alfa-aria/src/feature.ts +++ b/packages/alfa-aria/src/feature.ts @@ -247,6 +247,8 @@ type Features = { * The third parameter (`name`) is called during Step 2E of name computation, * that is after `aria-labelledby` and `aria-label`, and before content. * The `html` wrapper adds `nameFromAttributes(element, title)` at its end. + * + * {@link https://w3c.github.io/html-aam/#accname-computation} */ const Features: Features = { [Namespace.HTML]: { @@ -287,6 +289,14 @@ const Features: Features = { dd: html("definition"), + details: html("group", function* (element) { + // https://w3c.github.io/html-aam/#att-open-details + yield Attribute.of( + "aria-expanded", + element.attribute("open").isSome() ? "true" : "false", + ); + }), + dfn: html("term"), dialog: html("dialog", function* (element) { @@ -297,17 +307,6 @@ const Features: Features = { ); }), - details: html( - () => None, - function* (element) { - // https://w3c.github.io/html-aam/#att-open-details - yield Attribute.of( - "aria-expanded", - element.attribute("open").isSome() ? "true" : "false", - ); - }, - ), - dt: html("term"), fieldset: html( @@ -676,6 +675,19 @@ const Features: Features = { nameFromLabel, ), + summary: html( + (element) => + // the type is ensured by the name. + (element as Element<"summary">).isSummaryForItsParentDetails() + ? None + : Option.of(Role.of("generic")), + () => [], + (element, device, state) => + (element as Element<"summary">).isSummaryForItsParentDetails() + ? Name.fromDescendants(element, device, state) + : None, + ), + table: html("table", () => [], nameFromChild(hasName("caption"))), tbody: html("rowgroup"), diff --git a/packages/alfa-aria/src/name/name.ts b/packages/alfa-aria/src/name/name.ts index f007d4b881..42041f977f 100644 --- a/packages/alfa-aria/src/name/name.ts +++ b/packages/alfa-aria/src/name/name.ts @@ -1,7 +1,7 @@ import { Array } from "@siteimprove/alfa-array"; import { Cache } from "@siteimprove/alfa-cache"; import type { Device } from "@siteimprove/alfa-device"; -import type { Attribute} from "@siteimprove/alfa-dom"; +import type { Attribute } from "@siteimprove/alfa-dom"; import { Element, Node, Query, Text } from "@siteimprove/alfa-dom"; import type { Equatable } from "@siteimprove/alfa-equatable"; import type { Iterable } from "@siteimprove/alfa-iterable"; diff --git a/packages/alfa-aria/test/node.spec.tsx b/packages/alfa-aria/test/node.spec.tsx index 6bf8bbe5bf..592786432e 100644 --- a/packages/alfa-aria/test/node.spec.tsx +++ b/packages/alfa-aria/test/node.spec.tsx @@ -572,3 +572,89 @@ test(`.from() behaves when encountering an element with global properties where ], }); }); + +test(`.from() names elements that are summary for their parent detail`, (t) => { + const summary = Opening times; + +
+ {summary} +

This is a website. We are available 24/7.

+
; + + t.deepEqual(Node.from(summary, device).toJSON(), { + type: "element", + node: "/details[1]/summary[1]", + role: null, + name: "Opening times", + attributes: [], + children: [ + { + type: "text", + node: "/details[1]/summary[1]/text()[1]", + name: "Opening times", + }, + ], + }); +}); + +test(`.from() treats isolated elements as generic`, (t) => { + const summary = Opening times; + + t.deepEqual(Node.from(summary, device).toJSON(), { + type: "container", + node: "/summary[1]", + role: "generic", + children: [ + { + type: "text", + node: "/summary[1]/text()[1]", + name: "Opening times", + }, + ], + }); +}); + +test(`.from() treats nested elements as generic`, (t) => { + const summary = Opening times; + +
+
{summary}
+

This is a website. We are available 24/7.

+
; + + t.deepEqual(Node.from(summary, device).toJSON(), { + type: "container", + node: "/details[1]/div[1]/summary[1]", + role: "generic", + children: [ + { + type: "text", + node: "/details[1]/div[1]/summary[1]/text()[1]", + name: "Opening times", + }, + ], + }); +}); + +test(`.from() treats second elements as generic`, (t) => { + const summary = Opening times; + +
+ Hello + {summary} +

This is a website. We are available 24/7.

+
; + + t.deepEqual(Node.from(summary, device).toJSON(), { + type: "container", + node: "/details[1]/summary[2]", + role: "generic", + children: [ + { + type: "text", + node: "/details[1]/summary[2]/text()[1]", + name: "Opening times", + }, + ], + }); +}); diff --git a/packages/alfa-dom/src/node/element/augment.ts b/packages/alfa-dom/src/node/element/augment.ts index 6608c66a6c..9e7327411a 100644 --- a/packages/alfa-dom/src/node/element/augment.ts +++ b/packages/alfa-dom/src/node/element/augment.ts @@ -7,10 +7,15 @@ */ import { None, Some } from "@siteimprove/alfa-option"; +import { Refinement } from "@siteimprove/alfa-refinement"; import { Sequence } from "@siteimprove/alfa-sequence"; + import { Element } from "../element.js"; import type { InputType } from "./input-type.js"; +const { isElement } = Element; +const { and } = Refinement; + declare module "../element.js" { interface Element { /** @@ -34,6 +39,11 @@ declare module "../element.js" { * {@link https://html.spec.whatwg.org/multipage/form-elements.html#concept-select-option-list} */ optionsList(this: Element<"select">): Sequence>; + + /** + * {@link https://html.spec.whatwg.org/multipage/#summary-for-its-parent-details} + */ + isSummaryForItsParentDetails(this: Element<"summary">): boolean; } } @@ -92,7 +102,7 @@ Element.prototype.optionsList = function ( ): Sequence> { if (this._optionsList === undefined) { this._optionsList = this.children() - .filter(Element.isElement) + .filter(isElement) .flatMap((child) => { switch (child.name) { case "option": @@ -101,7 +111,7 @@ Element.prototype.optionsList = function ( case "optgroup": return child .children() - .filter(Element.isElement) + .filter(isElement) .filter( // We cannot really use `Element.hasName` here as it would // create a circular dependency. @@ -117,3 +127,19 @@ Element.prototype.optionsList = function ( return this._optionsList; }; + +Element.prototype.isSummaryForItsParentDetails = function ( + this: Element<"summary">, +): boolean { + // We cannot use `Element.hasName` here as it would create a circular dependency. + return this.parent() + .filter(and(Element.isElement, (parent) => parent.name === "details")) + .some((details) => + details + .children() + .find( + and(Element.isElement, (candidate) => candidate.name === "summary"), + ) + .includes(this), + ); +}; 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 5e0a13de8c..ad0aa34ddb 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 @@ -33,21 +33,8 @@ export function isSuggestedFocusable(element: Element): boolean { return true; case "summary": - return element - .parent() - .filter(Element.isElement) - .some( - (parent) => - parent.name === "details" && - // Checking that element is the first child of parent. - parent - .children() - .filter(Element.isElement) - // Switching on element.name does not narrow the type, so we must - // keep it as Element. - .find(Element.hasName("summary")) - .includes(element), - ); + // The type is ensured by the switch on the name. + return (element as Element<"summary">).isSummaryForItsParentDetails(); } return ( diff --git a/packages/alfa-rules/src/rules.ts b/packages/alfa-rules/src/rules.ts index 0e9eb2f45b..110546331f 100644 --- a/packages/alfa-rules/src/rules.ts +++ b/packages/alfa-rules/src/rules.ts @@ -88,6 +88,7 @@ import R96 from "./sia-r96/rule.js"; import R110 from "./sia-r110/rule.js"; import R111 from "./sia-r111/rule.js"; import R113 from "./sia-r113/rule.js"; +import R116 from "./sia-r116/rule.js"; export { R1, @@ -180,4 +181,5 @@ export { R110, R111, R113, + R116, }; diff --git a/packages/alfa-rules/src/sia-r116/rule.ts b/packages/alfa-rules/src/sia-r116/rule.ts new file mode 100644 index 0000000000..2eb0ef5ca4 --- /dev/null +++ b/packages/alfa-rules/src/sia-r116/rule.ts @@ -0,0 +1,70 @@ +import { Diagnostic, Rule } from "@siteimprove/alfa-act"; +import { DOM, Role } from "@siteimprove/alfa-aria"; +import { Element, Namespace, Node, Query } from "@siteimprove/alfa-dom"; +import { Refinement } from "@siteimprove/alfa-refinement"; +import { Err, Ok } from "@siteimprove/alfa-result"; +import { Criterion } from "@siteimprove/alfa-wcag"; +import type { Page } from "@siteimprove/alfa-web"; + +import { expectation } from "../common/act/expectation.js"; + +import { Scope, Stability } from "../tags/index.js"; + +const { + hasExplicitRole, + hasNonEmptyAccessibleName, + isIncludedInTheAccessibilityTree, +} = DOM; +const { hasName, hasNamespace } = Element; +const { and, not } = Refinement; +const { getElementDescendants } = Query; + +export default Rule.Atomic.of>({ + uri: "https://alfa.siteimprove.com/rules/sia-r116", + requirements: [Criterion.of("4.1.2")], + tags: [Scope.Component, Stability.Stable], + evaluate({ device, document }) { + return { + applicability() { + return getElementDescendants(document, Node.fullTree) + .filter(and(hasNamespace(Namespace.HTML), hasName("summary"))) + .filter( + and( + isIncludedInTheAccessibilityTree(device), + (summary) => summary.isSummaryForItsParentDetails(), + // If the explicit role is none/presentation but the element is + // nonetheless included in the accessibility tree, then the + // conflict triggered, and we want to keep it as target. + not(hasExplicitRole(not(Role.hasName("none", "presentation")))), + ), + ); + }, + + expectations(target) { + return { + 1: expectation( + // This does not explicitly exclude the ::marker pseudo-element from + // the name. Since we currently do not handle pseudo-elements, this + // is effectively the wanted outcome. + hasNonEmptyAccessibleName(device)(target), + () => Outcomes.HasAccessibleName, + () => Outcomes.HasNoAccessibleName, + ), + }; + }, + }; + }, +}); + +/** + * @public + */ +export namespace Outcomes { + export const HasAccessibleName = Ok.of( + Diagnostic.of(`The \`\` element has an accessible name`), + ); + + export const HasNoAccessibleName = Err.of( + Diagnostic.of(`The \`\` element does not have an accessible name`), + ); +} diff --git a/packages/alfa-rules/src/tsconfig.json b/packages/alfa-rules/src/tsconfig.json index 3673ba777e..701badd208 100644 --- a/packages/alfa-rules/src/tsconfig.json +++ b/packages/alfa-rules/src/tsconfig.json @@ -160,6 +160,7 @@ "./sia-r113/rule.ts", "./sia-r114/rule.ts", "./sia-r115/rule.ts", + "./sia-r116/rule.ts", "./tags/index.ts", "./tags/stability.ts", "./tags/scope.ts", diff --git a/packages/alfa-rules/test/sia-r116/rule.spec.tsx b/packages/alfa-rules/test/sia-r116/rule.spec.tsx new file mode 100644 index 0000000000..e3cece7b69 --- /dev/null +++ b/packages/alfa-rules/test/sia-r116/rule.spec.tsx @@ -0,0 +1,148 @@ +import { type Element, h } from "@siteimprove/alfa-dom"; +import { test } from "@siteimprove/alfa-test"; + +import R116, { Outcomes } from "../../dist/sia-r116/rule.js"; + +import { evaluate } from "../common/evaluate.js"; +import { failed, inapplicable, passed } from "../common/outcome.js"; + +test("evaluate() passes summary elements with an accessible name from aria-label", async (t) => { + const target = ( + Opening times + ) as Element<"summary">; + + const document = h.document([ +
+ {target} +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [ + passed(R116, target, { + 1: Outcomes.HasAccessibleName, + }), + ]); +}); + +test("evaluate() passes summary elements with an accessible name from content", async (t) => { + const target = (Opening times) as Element<"summary">; + + const document = h.document([ +
+ {target} +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [ + passed(R116, target, { + 1: Outcomes.HasAccessibleName, + }), + ]); +}); + +test("evaluate() passes summary elements that are not the first children", async (t) => { + const target = (Opening times) as Element<"summary">; + + const document = h.document([ +
+

This is a website. We are available 24/7.

+ {target} +
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [ + passed(R116, target, { + 1: Outcomes.HasAccessibleName, + }), + ]); +}); + +test("evaluate() is only applicable to the first summary element child", async (t) => { + const target = (Opening times) as Element<"summary">; + + const document = h.document([ +
+ {target} + Hello +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [ + passed(R116, target, { + 1: Outcomes.HasAccessibleName, + }), + ]); +}); + +test("evaluate() fails summary elements without an accessible name", async (t) => { + const target = () as Element<"summary">; + + const document = h.document([ +
+ {target} +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [ + failed(R116, target, { + 1: Outcomes.HasNoAccessibleName, + }), + ]); +}); + +test("evaluate() applies to element where the presentational conflict triggers", async (t) => { + const target = () as Element<"summary">; + + const document = h.document([ +
+ {target} +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [ + failed(R116, target, { + 1: Outcomes.HasNoAccessibleName, + }), + ]); +}); + +test("evaluate() is inapplicable to summary elements that are not summary for their parent details", async (t) => { + const document = h.document([ + Isolated, +
+
+ Nested +
+

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [inapplicable(R116)]); +}); + +test("evaluate() is inapplicable to summary elements that are not exposed", async (t) => { + const document = h.document([ +
+ Opening times +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [inapplicable(R116)]); +}); + +test("evaluate() is inapplicable to summary elements with an explicit role", async (t) => { + const document = h.document([ +
+ Opening times +

This is a website. We are available 24/7.

+
, + ]); + + t.deepEqual(await evaluate(R116, { document }), [inapplicable(R116)]); +}); diff --git a/packages/alfa-rules/test/tsconfig.json b/packages/alfa-rules/test/tsconfig.json index ba815db1e1..dd40e9ca2d 100644 --- a/packages/alfa-rules/test/tsconfig.json +++ b/packages/alfa-rules/test/tsconfig.json @@ -119,7 +119,8 @@ "./sia-r111/rule.spec.tsx", "./sia-r113/rule.spec.tsx", "./sia-r114/rule.spec.tsx", - "./sia-r115/rule.spec.tsx" + "./sia-r115/rule.spec.tsx", + "./sia-r116/rule.spec.tsx" ], "references": [{ "path": "../src" }, { "path": "../../alfa-test" }] }