diff --git a/.changeset/twenty-boxes-complain.md b/.changeset/twenty-boxes-complain.md new file mode 100644 index 0000000000..e13ae9a7e6 --- /dev/null +++ b/.changeset/twenty-boxes-complain.md @@ -0,0 +1,8 @@ +--- +"@siteimprove/alfa-rules": minor +"@siteimprove/alfa-aria": minor +--- + +**Added:** Expose `allowedAttributes` on ARIA Element type. + +This function takes into account "implicit ARIA semantics" and "ARIA role allowances" from [ARIA in HTML](https://w3c.github.io/html-aria/#docconformance). The logic is moved from rule R18 implementation. diff --git a/docs/review/api/alfa-aria.api.md b/docs/review/api/alfa-aria.api.md index 8c74e17894..48a63eb413 100644 --- a/docs/review/api/alfa-aria.api.md +++ b/docs/review/api/alfa-aria.api.md @@ -126,6 +126,7 @@ export namespace DOM { // @public (undocumented) export class Element extends Node<"element"> { + allowedAttributes(): ReadonlyArray; // (undocumented) attribute(refinement: Refinement>): Option>; // (undocumented) @@ -137,6 +138,8 @@ export class Element extends Node<"element"> { // (undocumented) clone(): Element; // (undocumented) + isAttributeAllowed(attribute: Attribute.Name): boolean; + // (undocumented) isIgnored(): boolean; // (undocumented) get name(): Option; diff --git a/packages/alfa-aria/package.json b/packages/alfa-aria/package.json index 3cc81f972f..31d9564771 100644 --- a/packages/alfa-aria/package.json +++ b/packages/alfa-aria/package.json @@ -40,6 +40,7 @@ "@siteimprove/alfa-option": "workspace:^0.95.0", "@siteimprove/alfa-predicate": "workspace:^0.95.0", "@siteimprove/alfa-refinement": "workspace:^0.95.0", + "@siteimprove/alfa-selective": "workspace:^0.95.0", "@siteimprove/alfa-selector": "workspace:^0.95.0", "@siteimprove/alfa-sequence": "workspace:^0.95.0", "@siteimprove/alfa-set": "workspace:^0.95.0", diff --git a/packages/alfa-aria/src/node/element.ts b/packages/alfa-aria/src/node/element.ts index 0bb2796785..24c3dbd9d7 100644 --- a/packages/alfa-aria/src/node/element.ts +++ b/packages/alfa-aria/src/node/element.ts @@ -10,7 +10,11 @@ import type * as dom from "@siteimprove/alfa-dom"; import type { Attribute } from "../attribute.js"; import type { Name } from "../name/index.js"; import { Node } from "../node.js"; -import type { Role } from "../role.js"; +import { Set } from "@siteimprove/alfa-set"; +import { Role } from "../role.js"; +import type { InputType } from "../../../alfa-dom/src/node/element/input-type.js"; +import { Element as DomElement } from "@siteimprove/alfa-dom"; +import { Selective } from "@siteimprove/alfa-selective"; /** * @public @@ -115,6 +119,65 @@ export class Element extends Node<"element"> { ...this._children.map((child) => String.indent(child.toString())), ].join("\n"); } + + private static allowedAttributesForInputType( + inputType: InputType + ): ReadonlyArray { + switch (inputType) { + // https://www.w3.org/TR/html-aria/#el-input-color + case "color": + return ["aria-disabled"]; + // https://www.w3.org/TR/html-aria/#el-input-date + case "date": + // https://www.w3.org/TR/html-aria/#el-input-datetime-local + case "datetime-local": + // https://www.w3.org/TR/html-aria/#el-input-email + case "email": + // https://www.w3.org/TR/html-aria/#el-input-month + case "month": + // https://www.w3.org/TR/html-aria/#el-input-password + case "password": + // https://www.w3.org/TR/html-aria/#el-input-time + case "time": + // https://www.w3.org/TR/html-aria/#el-input-week + case "week": + return Role.of("textbox").supportedAttributes; + // https://www.w3.org/TR/html-aria/#el-input-file + case "file": + return ["aria-disabled", "aria-invalid", "aria-required"]; + default: + return []; + } + } + + /** + * The attributes that are allowed on this element, taking into consideration ARIA in HTML conformance requirements. + * See {@link https://w3c.github.io/html-aria/#docconformance} + */ + public allowedAttributes(): ReadonlyArray { + const global = Role.of("roletype").supportedAttributes; + const fromRole = this.role.map(role => role.supportedAttributes).getOr([]); + const additional = Selective.of(this.node) + .if(DomElement.hasName("input"), input => + Element.allowedAttributesForInputType(input.inputType()) + ) + // https://www.w3.org/TR/html-aria/#el-select + .if( + DomElement.hasName("select"), + select => + DomElement.hasDisplaySize((size: Number) => size !== 1)(select) + ? Role.of("combobox").supportedAttributes + : Role.of("menu").supportedAttributes + ) + .else(() => []) + .get(); + + return Array.from(Set.from([... global, ...fromRole, ...additional])); + } + + public isAttributeAllowed(attribute: Attribute.Name): boolean { + return this.allowedAttributes().includes(attribute); + } } /** diff --git a/packages/alfa-aria/src/tsconfig.json b/packages/alfa-aria/src/tsconfig.json index 3721bd25a7..8a32e6d6db 100644 --- a/packages/alfa-aria/src/tsconfig.json +++ b/packages/alfa-aria/src/tsconfig.json @@ -57,6 +57,7 @@ { "path": "../../alfa-option" }, { "path": "../../alfa-predicate" }, { "path": "../../alfa-refinement" }, + { "path": "../../alfa-selective" }, { "path": "../../alfa-selector" }, { "path": "../../alfa-sequence" }, { "path": "../../alfa-set" }, diff --git a/packages/alfa-rules/package.json b/packages/alfa-rules/package.json index 133ea6313b..513695c52d 100644 --- a/packages/alfa-rules/package.json +++ b/packages/alfa-rules/package.json @@ -62,7 +62,6 @@ "@siteimprove/alfa-refinement": "workspace:^0.95.0", "@siteimprove/alfa-result": "workspace:^0.95.0", "@siteimprove/alfa-sarif": "workspace:^0.95.0", - "@siteimprove/alfa-selective": "workspace:^0.95.0", "@siteimprove/alfa-selector": "workspace:^0.95.0", "@siteimprove/alfa-sequence": "workspace:^0.95.0", "@siteimprove/alfa-set": "workspace:^0.95.0", diff --git a/packages/alfa-rules/src/sia-r18/rule.ts b/packages/alfa-rules/src/sia-r18/rule.ts index 4a7c0793b3..1315207c74 100644 --- a/packages/alfa-rules/src/sia-r18/rule.ts +++ b/packages/alfa-rules/src/sia-r18/rule.ts @@ -1,12 +1,10 @@ import { Diagnostic, Rule } from "@siteimprove/alfa-act"; -import { DOM, Role } from "@siteimprove/alfa-aria"; +import { DOM } from "@siteimprove/alfa-aria"; import type { Attribute } from "@siteimprove/alfa-dom"; -import { Element, Node, Query } from "@siteimprove/alfa-dom"; +import { Node, Query } from "@siteimprove/alfa-dom"; import { Predicate } from "@siteimprove/alfa-predicate"; import { Err, Ok } from "@siteimprove/alfa-result"; -import { Selective } from "@siteimprove/alfa-selective"; import { Sequence } from "@siteimprove/alfa-sequence"; -import { Set } from "@siteimprove/alfa-set"; import { Technique } from "@siteimprove/alfa-wcag"; import type { Page } from "@siteimprove/alfa-web"; @@ -18,7 +16,6 @@ import { ARIA } from "../requirements/index.js"; import { Scope, Stability, Version } from "../tags/index.js"; const { hasRole, isIncludedInTheAccessibilityTree } = DOM; -const { hasDisplaySize, hasInputType, hasName } = Element; const { test, property } = Predicate; const { getElementDescendants } = Query; @@ -30,8 +27,6 @@ export default Rule.Atomic.of({ ], tags: [Scope.Component, Stability.Stable, Version.of(2)], evaluate({ device, document }) { - const global = Set.from(Role.of("roletype").supportedAttributes); - return { applicability() { return getElementDescendants(document, Node.fullTree) @@ -44,19 +39,13 @@ export default Rule.Atomic.of({ }, expectations(target) { - // Since the attribute was found on a element, it has a owner. + // Since the attribute was found on a element, it has an owner. const owner = target.owner.getUnsafe(); + const ariaNode = aria.Node.from(owner, device) as aria.Element; return { 1: expectation( - global.has(target.name as aria.Attribute.Name) || - test( - hasRole(device, (role) => - role.isAttributeSupported(target.name as aria.Attribute.Name), - ), - owner, - ) || - ariaHtmlAllowed(target), + ariaNode.isAttributeAllowed(target.name as aria.Attribute.Name), () => Outcomes.IsAllowed, () => Outcomes.IsNotAllowed, ), @@ -76,62 +65,6 @@ export default Rule.Atomic.of({ }, }); -function allowedForInputType( - attributeName: aria.Attribute.Name, -): Predicate { - return hasInputType((inputType) => { - switch (inputType) { - // https://www.w3.org/TR/html-aria/#el-input-color - case "color": - return attributeName === "aria-disabled"; - // https://www.w3.org/TR/html-aria/#el-input-date - case "date": - // https://www.w3.org/TR/html-aria/#el-input-datetime-local - case "datetime-local": - // https://www.w3.org/TR/html-aria/#el-input-email - case "email": - // https://www.w3.org/TR/html-aria/#el-input-month - case "month": - // https://www.w3.org/TR/html-aria/#el-input-password - case "password": - // https://www.w3.org/TR/html-aria/#el-input-time - case "time": - // https://www.w3.org/TR/html-aria/#el-input-week - case "week": - return Role.of("textbox").isAttributeSupported(attributeName); - // https://www.w3.org/TR/html-aria/#el-input-file - case "file": - return ( - attributeName === "aria-disabled" || - attributeName === "aria-invalid" || - attributeName === "aria-required" - ); - default: - return false; - } - }); -} - -function ariaHtmlAllowed(target: Attribute): boolean { - const attributeName = target.name as aria.Attribute.Name; - return target.owner - .map((element) => - Selective.of(element) - .if(hasName("input"), allowedForInputType(attributeName)) - // https://www.w3.org/TR/html-aria/#el-select - .if( - hasName("select"), - (select) => - (hasDisplaySize((size: Number) => size !== 1)(select) && - Role.of("combobox").isAttributeSupported(attributeName)) || - Role.of("menu").isAttributeSupported(attributeName), - ) - .else(() => false) - .get(), - ) - .getOr(false); -} - /** * @public */ diff --git a/packages/alfa-rules/src/tsconfig.json b/packages/alfa-rules/src/tsconfig.json index 747ef534a2..801c880c39 100644 --- a/packages/alfa-rules/src/tsconfig.json +++ b/packages/alfa-rules/src/tsconfig.json @@ -197,7 +197,6 @@ { "path": "../../alfa-refinement" }, { "path": "../../alfa-result" }, { "path": "../../alfa-sarif" }, - { "path": "../../alfa-selective" }, { "path": "../../alfa-selector" }, { "path": "../../alfa-sequence" }, { "path": "../../alfa-set" }, diff --git a/yarn.lock b/yarn.lock index 2c6d0537d6..5abbdcb995 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1008,6 +1008,7 @@ __metadata: "@siteimprove/alfa-option": "workspace:^0.95.0" "@siteimprove/alfa-predicate": "workspace:^0.95.0" "@siteimprove/alfa-refinement": "workspace:^0.95.0" + "@siteimprove/alfa-selective": "workspace:^0.95.0" "@siteimprove/alfa-selector": "workspace:^0.95.0" "@siteimprove/alfa-sequence": "workspace:^0.95.0" "@siteimprove/alfa-set": "workspace:^0.95.0" @@ -1742,7 +1743,6 @@ __metadata: "@siteimprove/alfa-refinement": "workspace:^0.95.0" "@siteimprove/alfa-result": "workspace:^0.95.0" "@siteimprove/alfa-sarif": "workspace:^0.95.0" - "@siteimprove/alfa-selective": "workspace:^0.95.0" "@siteimprove/alfa-selector": "workspace:^0.95.0" "@siteimprove/alfa-sequence": "workspace:^0.95.0" "@siteimprove/alfa-set": "workspace:^0.95.0"