diff --git a/.changeset/swift-meals-check.md b/.changeset/swift-meals-check.md new file mode 100755 index 0000000000..90114a7405 --- /dev/null +++ b/.changeset/swift-meals-check.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-rules": minor +--- + +**Added:** Experimental rule SIA-ER8. It adds support for type="password" and more. diff --git a/docs/review/api/alfa-dom.api.md b/docs/review/api/alfa-dom.api.md index 981df03d5a..4b8cdf7064 100644 --- a/docs/review/api/alfa-dom.api.md +++ b/docs/review/api/alfa-dom.api.md @@ -342,6 +342,8 @@ export namespace Element { // @internal (undocumented) export function fromElement(json: JSON, device?: Device): Trampoline>; // (undocumented) + export type InputType = helpers.InputType; + // (undocumented) export function isElement(value: unknown): value is Element; // (undocumented) export interface JSON extends Node.JSON<"element"> { @@ -362,9 +364,6 @@ export namespace Element { // (undocumented) style: Block.JSON | string | null; } - // (undocumented) - export interface MinimalJSON extends Node.JSON<"element"> { - } const // Warning: (ae-forgotten-export) The symbol "predicate_3" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -391,6 +390,9 @@ export namespace Element { // // (undocumented) inputType: typeof helpers.inputType; + // (undocumented) + export interface MinimalJSON extends Node.JSON<"element"> { + } } // @public (undocumented) diff --git a/docs/review/api/alfa-rules.api.md b/docs/review/api/alfa-rules.api.md index 6726ce53a0..e68a9415f7 100644 --- a/docs/review/api/alfa-rules.api.md +++ b/docs/review/api/alfa-rules.api.md @@ -28,41 +28,44 @@ import { Text } from '@siteimprove/alfa-dom'; // @public (undocumented) export const alfaVersion = "0.92.0"; +// @public (undocumented) +const _default: Rule.Atomic, {}, Element>; + +// @public @deprecated (undocumented) +const _default_10: Rule.Atomic; + // @public -const _default: Rule.Atomic>; +const _default_2: Rule.Atomic>; // @public -const _default_2: Rule.Atomic, Question.Metadata, Node | Array_2>>; +const _default_3: Rule.Atomic, Question.Metadata, Node | Array_2>>; // @public -const _default_3: Rule.Atomic; +const _default_4: Rule.Atomic; // @public @deprecated (undocumented) -const _default_4: Rule.Atomic, {}, Element>; +const _default_5: Rule.Atomic, {}, Element>; // @public @deprecated (undocumented) -const _default_5: Rule.Atomic, {}, Attribute>; - -// @public (undocumented) -const _default_6: Rule.Atomic, Question.Metadata, Element>; +const _default_6: Rule.Atomic, {}, Attribute>; // @public (undocumented) const _default_7: Rule.Atomic, Question.Metadata, Element>; -// @public @deprecated (undocumented) -const _default_8: Rule.Atomic; +// @public (undocumented) +const _default_8: Rule.Atomic, Question.Metadata, Element>; // @public @deprecated (undocumented) const _default_9: Rule.Atomic; declare namespace deprecatedRules { export { - _default_4 as DR6, - _default_5 as DR18, - _default_6 as DR34, - _default_7 as DR36, - _default_8 as DR66, - _default_9 as DR69 + _default_5 as DR6, + _default_6 as DR18, + _default_7 as DR34, + _default_8 as DR36, + _default_9 as DR66, + _default_10 as DR69 } } export { deprecatedRules } @@ -93,9 +96,10 @@ export namespace Diagnostic { declare namespace experimentalRules { export { - _default as ER87, - _default_2 as R82, - _default_3 as R109 + _default as ER8, + _default_2 as ER87, + _default_3 as R82, + _default_4 as R109 } } export { experimentalRules } diff --git a/packages/alfa-dom/src/node/element.ts b/packages/alfa-dom/src/node/element.ts index e5a9559786..06d867bf2f 100644 --- a/packages/alfa-dom/src/node/element.ts +++ b/packages/alfa-dom/src/node/element.ts @@ -573,4 +573,6 @@ export namespace Element { } = predicate; export const { inputType } = helpers; + + export type InputType = helpers.InputType; } diff --git a/packages/alfa-rules/src/experimental.ts b/packages/alfa-rules/src/experimental.ts index ed0c9b431f..eb4baf4d74 100755 --- a/packages/alfa-rules/src/experimental.ts +++ b/packages/alfa-rules/src/experimental.ts @@ -1,6 +1,7 @@ +import ER8 from "./sia-er8/rule.js"; import ER87 from "./sia-er87/rule.js"; import R82 from "./sia-r82/rule.js"; import R109 from "./sia-r109/rule.js"; -export { ER87, R82, R109 }; +export { ER8, ER87, R82, R109 }; diff --git a/packages/alfa-rules/src/sia-er8/rule.ts b/packages/alfa-rules/src/sia-er8/rule.ts new file mode 100644 index 0000000000..8e391f8c80 --- /dev/null +++ b/packages/alfa-rules/src/sia-er8/rule.ts @@ -0,0 +1,96 @@ +import { Diagnostic, Rule } from "@siteimprove/alfa-act"; +import type { Role } from "@siteimprove/alfa-aria"; +import * as aria from "@siteimprove/alfa-aria" +import { Element, Namespace, Node, Query } from "@siteimprove/alfa-dom"; +import { Predicate } from "@siteimprove/alfa-predicate"; +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, Version } from "../tags/index.js"; +import { WithRole } from "../common/diagnostic.js"; + +const { hasNonEmptyAccessibleName, hasRole, isIncludedInTheAccessibilityTree } = aria.DOM; +const { hasInputType, hasNamespace } = Element; +const { and, or } = Predicate; +const { getElementDescendants } = Query; + +export default Rule.Atomic.of({ + uri: "https://alfa.siteimprove.com/rules/sia-r8", + requirements: [Criterion.of("4.1.2")], + tags: [Scope.Component, Stability.Experimental, Version.of(2)], + evaluate({ device, document }) { + return { + applicability() { + return getElementDescendants(document, Node.fullTree).filter( + and( + hasNamespace(Namespace.HTML), + or( + hasRole( + device, + "checkbox", + "combobox", + "listbox", + "menuitemcheckbox", + "menuitemradio", + "radio", + "searchbox", + "slider", + "spinbutton", + "switch", + "textbox", + ), + hasInputType("password", "color", "date", "datetime-local", "file", "month", "time", "week"), + ), + isIncludedInTheAccessibilityTree(device), + ), + ); + }, + + expectations(target) { + const role = aria.Node.from(target, device).role; + if(role.isSome()) { + const roleName = role.get().name; + return { + 1: expectation( + hasNonEmptyAccessibleName(device)(target), + () => Outcomes.FormFieldWithAriaRoleHasName(roleName), + () => Outcomes.FormFieldWithAriaRoleHasNoName(roleName), + ), + }; + } else { + // We know the type attribute has a correct value because of the applicability. + const inputType = target.attribute("type").map(attr => attr.value).getUnsafe(`R8v2 found an element with no role nor 'type' attribute: ${target.path()}`) as Element.InputType; + return { + 1: expectation( + hasNonEmptyAccessibleName(device)(target), + () => Outcomes.InputElementWithNoAriaRoleHasName(inputType), + () => Outcomes.InputElementWithNoAriaRoleHasNoName(inputType), + ), + }; + } + }, + }; + }, +}); + +/** + * @public + */ +export namespace Outcomes { + export const FormFieldWithAriaRoleHasName = (role: Role.Name) => + Ok.of(WithRole.of(`The form field has an accessible name`, role)); + + export const FormFieldWithAriaRoleHasNoName = (role: Role.Name) => + Err.of( + WithRole.of(`The form field does not have an accessible name`, role), + ); + + export const InputElementWithNoAriaRoleHasName = (typeAttribValue: Element.InputType) => + Ok.of(Diagnostic.of(`The type="${typeAttribValue}" form field has an accessible name`)); + + export const InputElementWithNoAriaRoleHasNoName = (typeAttribValue: Element.InputType) => + Err.of(Diagnostic.of(`The type="${typeAttribValue}" form field does not have an accessible name`)); +} diff --git a/packages/alfa-rules/src/tsconfig.json b/packages/alfa-rules/src/tsconfig.json index cb34a2d6e0..7f74092948 100644 --- a/packages/alfa-rules/src/tsconfig.json +++ b/packages/alfa-rules/src/tsconfig.json @@ -57,6 +57,7 @@ "./sia-dr36/rule.ts", "./sia-dr66/rule.ts", "./sia-dr69/rule.ts", + "./sia-er8/rule.ts", "./sia-er87/rule.ts", "./sia-r1/rule.ts", "./sia-r10/rule.ts", diff --git a/packages/alfa-rules/test/sia-er8/rule.spec.tsx b/packages/alfa-rules/test/sia-er8/rule.spec.tsx new file mode 100644 index 0000000000..7b50886d0b --- /dev/null +++ b/packages/alfa-rules/test/sia-er8/rule.spec.tsx @@ -0,0 +1,350 @@ +import { h, Element } from "@siteimprove/alfa-dom"; +import { test } from "@siteimprove/alfa-test"; + +import ER8, { Outcomes } from "../../dist/sia-er8/rule.js"; + +import { evaluate } from "../common/evaluate.js"; +import { passed, failed, inapplicable } from "../common/outcome.js"; + +test("evaluate() passes an input element with implicit label", async (t) => { + const target = ; + + const label = ( + + ); + + const document = h.document([label]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasName("textbox"), + }), + ]); +}); + +test("evaluate() passes an input element with aria-label", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasName("textbox"), + }), + ]); +}); + +test("evaluate() passes a select element with explicit label", async (t) => { + const target = ( + + ); + + const label = ; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasName("listbox"), + }), + ]); +}); + +test("evaluate() passes a textarea element with aria-labelledby", async (t) => { + const target = ; + + const label =
Country
; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasName("textbox"), + }), + ]); +}); + +test("evaluate() passes a input element with placeholder attribute", async (t) => { + const target = ; + + const label = ; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasName("textbox"), + }), + ]); +}); + +test(`evaluate() passes a div element with explicit combobox role and an + aria-label attribute`, async (t) => { + const role = "combobox"; + + const target = ( +
+ England +
+ ); + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasName(role), + }), + ]); +}); + +test("evaluate() fails a input element without accessible name", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasNoName("textbox"), + }), + ]); +}); + +test("evaluate() fails a input element with empty aria-label", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasNoName("textbox"), + }), + ]); +}); + +test(`evaluate() fails a select element with aria-labelledby pointing to an + empty element`, async (t) => { + const target = ( + + ); + + const label =
; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasNoName("listbox"), + }), + ]); +}); + +test("evaluate() fails a textbox with no accessible name", async (t) => { + const role = "textbox"; + + const target =
; + + const label = ( + + ); + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.FormFieldWithAriaRoleHasNoName(role), + }), + ]); +}); + +test("evaluate() is inapplicable for an element with aria-hidden", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [inapplicable(ER8)]); +}); + +test("evaluate() is inapplicable for a disabled element", async (t) => { + const target = ( + + ); + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [inapplicable(ER8)]); +}); + +test("evaluate() is inapplicable for an element which is not displayed", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [inapplicable(ER8)]); +}); + +test(`evaluate() fails an input element with type=password which is disabled + and without accessible name`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasNoName("password"), + }), + ]); +}); + +test("evaluate() passes an input element with type=password and implicit label", async (t) => { + const target = ; + + const label = ( + + ); + + const document = h.document([label]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasName("password"), + }), + ]); +}); + +test("evaluate() passes an input element with type=password and aria-label", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasName("password"), + }), + ]); +}); + +test("evaluate() passes an input element with type=password and explicit label", async (t) => { + const target = ; + + const label = ; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasName("password"), + }), + ]); +}); + +test("evaluate() passes an input element with type=password and aria-labelledby", async (t) => { + const target = ; + + const label =
Password
; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasName("password"), + }), + ]); +}); + +test("evaluate() passes an input element with type=password and placeholder attribute", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + passed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasName("password"), + }), + ]); +}); + +test("evaluate() fails an input element with type=password and empty aria-label", async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasNoName("password"), + }), + ]); +}); + +test(`evaluate() fails an input element with type=password and aria-labelledby pointing to an + empty element`, async (t) => { + const target = ; + + const label =
; + + const document = h.document([label, target]); + + t.deepEqual(await evaluate(ER8, { document }), [ + failed(ER8, target, { + 1: Outcomes.InputElementWithNoAriaRoleHasNoName("password"), + }), + ]); +}); + +test(`evaluate() is inapplicable for an input element with type=password + and aria-hidden`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [inapplicable(ER8)]); +}); + +test(`evaluate() is inapplicable for an element with type=password and which + is not displayed`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(ER8, { document }), [inapplicable(ER8)]); +}); + +const supportedInputTypesOtherThanPassword = ["color", "date", "datetime-local", "file", "month", "time", "week"]; + +test(`evaluate() fails for input elements with various types which give it no ARIA + role and which have no accessible name`, async (t) => { + for (const type of supportedInputTypesOtherThanPassword) { + const target = ; + const document = h.document([target]); + t.deepEqual(await evaluate(ER8, { document }), + [failed(ER8, target, {1: Outcomes.InputElementWithNoAriaRoleHasNoName(type as Element.InputType)})]); + } +}); + +test(`evaluate() passes for input elements with various types which give it no ARIA + role and which have an aria-label`, async (t) => { + for (const type of supportedInputTypesOtherThanPassword) { + const target = ; + const document = h.document([target]); + t.deepEqual(await evaluate(ER8, { document }), + [passed(ER8, target, {1: Outcomes.InputElementWithNoAriaRoleHasName(type as Element.InputType)})]); + } +}); diff --git a/packages/alfa-rules/test/tsconfig.json b/packages/alfa-rules/test/tsconfig.json index 380c9988b4..d1ebce630e 100644 --- a/packages/alfa-rules/test/tsconfig.json +++ b/packages/alfa-rules/test/tsconfig.json @@ -19,6 +19,7 @@ "./sia-dr36/rule.spec.tsx", "./sia-dr66/rule.spec.tsx", "./sia-dr69/rule.spec.tsx", + "./sia-er8/rule.spec.tsx", "./sia-er87/rule.spec.tsx", "./sia-r1/rule.spec.tsx", "./sia-r2/rule.spec.tsx",