diff --git a/.changeset/lucky-keys-listen.md b/.changeset/lucky-keys-listen.md new file mode 100644 index 0000000000..32a98e8476 --- /dev/null +++ b/.changeset/lucky-keys-listen.md @@ -0,0 +1,11 @@ +--- +"@siteimprove/alfa-rules": minor +--- + +**Added:** The new rule R120 checks that the access keys an element declares are usable. + +It makes three checks. No other element may declare the same key, since two elements competing for one key means at most one of them can be reached and which one wins is left to the user agent. Each key must be a single character, since anything longer is not a key a user can press. And an element may not declare the same key twice. + +Case is folded throughout, so `A` and `a` are the same key. Two elements declaring them compete, and `accesskey="a A"` on one element declares that key twice and is reported for the repeat. That is stricter than the W3C checker, which compares the tokens of a single value literally and accepts it. + +The attribute holds a set of space-separated tokens, so `accesskey="a b"` declares two keys and each is checked separately. diff --git a/packages/alfa-rules/src/rules.ts b/packages/alfa-rules/src/rules.ts index 253cbdc7d7..a70c2b8f4b 100644 --- a/packages/alfa-rules/src/rules.ts +++ b/packages/alfa-rules/src/rules.ts @@ -88,6 +88,7 @@ import R111 from "./sia-r111/rule.ts"; import R113 from "./sia-r113/rule.ts"; import R116 from "./sia-r116/rule.ts"; import R119 from "./sia-r119/rule.ts"; +import R120 from "./sia-r120/rule.ts"; export { R1, @@ -180,4 +181,5 @@ export { R113, R116, R119, + R120, }; diff --git a/packages/alfa-rules/src/sia-r120/rule.ts b/packages/alfa-rules/src/sia-r120/rule.ts new file mode 100644 index 0000000000..e97a400b73 --- /dev/null +++ b/packages/alfa-rules/src/sia-r120/rule.ts @@ -0,0 +1,178 @@ +import { Diagnostic, Rule } from "@siteimprove/alfa-act"; +import { Element, Namespace, Node, Query } from "@siteimprove/alfa-dom"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Err, Ok } from "@siteimprove/alfa-result"; +import { Sequence } from "@siteimprove/alfa-sequence"; +import { Style } from "@siteimprove/alfa-style"; +import type { Page } from "@siteimprove/alfa-web"; + +import { expectation } from "../common/act/index.ts"; +import { BestPractice } from "../requirements/index.ts"; + +import { Scope, Stability } from "../tags/index.ts"; + +const { hasNamespace } = Element; +const { and } = Predicate; +const { isRendered } = Style; +const { getElementDescendants } = Query; + +/** + * This rule checks that the access keys an element declares are usable: that no + * other element declares the same key, that each key is a single character, and + * that the element does not declare the same key twice. + * + * Two elements competing for one key means at most one of them can be reached + * by it, and which one wins is left to the user agent. + * + * Case is folded throughout, since `A` and `a` reach the same physical key, so + * `accesskey="a A"` declares one key twice and is reported for the repeat. That + * is stricter than the specification, which asks only that the tokens not be + * "identical to another token", a literal comparison the W3C checker + * implements with a case-sensitive equality. + * + * Elements that are not rendered are left out, since their access keys cannot + * be activated, and a key declared only on such an element competes with + * nothing. + * + * {@link https://html.spec.whatwg.org/multipage/interaction.html#the-accesskey-attribute} + */ +export default Rule.Atomic.of({ + uri: "https://alfa.siteimprove.com/rules/sia-r120", + requirements: [BestPractice.of("accesskey-unique")], + tags: [Scope.Page, Stability.Stable], + evaluate({ device, document }) { + const elements = getElementDescendants(document, Node.fullTree) + .filter(and(hasNamespace(Namespace.HTML), declaresAccesskey)) + .filter(isRendered(device)); + + const accessKeyElementPairs = new Map>(); + + for (const element of elements) { + for (const key of accesskeys(element)) { + const elementsForKey = accessKeyElementPairs.get(key) ?? []; + + elementsForKey.push(element); + accessKeyElementPairs.set(key, elementsForKey); + } + } + + return { + applicability() { + return elements; + }, + + expectations(target) { + const contested = accesskeys(target).filter( + (key) => (accessKeyElementPairs.get(key)?.length ?? 0) > 1, + ); + const tooLong = tokens(target).reject((token) => token.length === 1); + + const duplicated = repeatedTokens(target); + + return { + 1: expectation( + contested.isEmpty(), + () => Outcomes.HasUniqueAccesskeys, + () => Outcomes.HasNonUniqueAccesskeys(contested), + ), + 2: expectation( + tooLong.isEmpty(), + () => Outcomes.HasSingleCharacterAccesskeys, + () => Outcomes.HasMultiCharacterAccesskeys(tooLong), + ), + 3: expectation( + duplicated.isEmpty(), + () => Outcomes.HasDistinctAccesskeys, + () => Outcomes.HasRepeatedAccesskeys(duplicated), + ), + }; + }, + }; + }, +}); + +function tokens(element: Element): Sequence { + return Sequence.from(element.attribute("accesskey")).flatMap((attribute) => + attribute.tokens(), + ); +} + +function accesskeys(element: Element): Sequence { + return tokens(element) + .map((token) => token.toLowerCase()) + .distinct(); +} + +function repeatedTokens(element: Element): Sequence { + const seen = new Set(); + const repeated = new Set(); + + for (const token of tokens(element).map((token) => token.toLowerCase())) { + if (seen.has(token)) { + repeated.add(token); + } else { + seen.add(token); + } + } + + return Sequence.from(repeated); +} + +const declaresAccesskey: Predicate = (element) => + !tokens(element).isEmpty(); + +/** + * @public + */ +export namespace Outcomes { + export const HasUniqueAccesskeys = Ok.of( + Diagnostic.of( + `No access key of the element is declared by another element.`, + ), + ); + + export const HasNonUniqueAccesskeys = (keys: Iterable) => + Err.of( + Diagnostic.of( + many(keys) + ? `More than one element declares the access keys ${list(keys)}.` + : `More than one element declares the access key ${list(keys)}.`, + ), + ); + + export const HasSingleCharacterAccesskeys = Ok.of( + Diagnostic.of(`Every access key of the element is a single character.`), + ); + + export const HasMultiCharacterAccesskeys = (keys: Iterable) => + Err.of( + Diagnostic.of( + many(keys) + ? `The access keys ${list(keys)} are not single characters.` + : `The access key ${list(keys)} is not a single character.`, + ), + ); + + export const HasDistinctAccesskeys = Ok.of( + Diagnostic.of(`The element declares each of its access keys once.`), + ); + + export const HasRepeatedAccesskeys = (keys: Iterable) => + Err.of( + Diagnostic.of( + many(keys) + ? `The element declares the access keys ${list(keys)} more than once.` + : `The element declares the access key ${list(keys)} more than once.`, + ), + ); +} + +function list(keys: Iterable): string { + return Sequence.from(keys) + .map((key) => `"${key}"`) + .join(", "); +} + +function many(keys: Iterable): boolean { + return Sequence.from(keys).size > 1; +} diff --git a/packages/alfa-rules/src/tsconfig.json b/packages/alfa-rules/src/tsconfig.json index a9ec491b73..3a2885773d 100644 --- a/packages/alfa-rules/src/tsconfig.json +++ b/packages/alfa-rules/src/tsconfig.json @@ -166,6 +166,7 @@ "./sia-r117/rule.ts", "./sia-r118/rule.ts", "./sia-r119/rule.ts", + "./sia-r120/rule.ts", "./tags/index.ts", "./tags/stability.ts", "./tags/scope.ts", diff --git a/packages/alfa-rules/test/sia-r120/rule.spec.tsx b/packages/alfa-rules/test/sia-r120/rule.spec.tsx new file mode 100644 index 0000000000..af0ab10437 --- /dev/null +++ b/packages/alfa-rules/test/sia-r120/rule.spec.tsx @@ -0,0 +1,448 @@ +import { h } from "@siteimprove/alfa-dom"; +import { test } from "@siteimprove/alfa-test"; + +import R120, { Outcomes } from "../../src/sia-r120/rule.ts"; + +import { evaluate } from "../common/evaluate.ts"; +import { failed, inapplicable, passed } from "../common/outcome.ts"; + +test(`evaluate() passes elements with distinct access keys`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, first, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + passed(R120, second, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() fails both elements sharing an access key`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() compares access keys without regard to case`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() compares access keys with surrounding whitespace removed`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() names only the contested key of a multi-token access key`, async (t) => { + const both = ; + const other = ; + + const document = h.document([both, other]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, both, { + 1: Outcomes.HasNonUniqueAccesskeys(["b"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, other, { + 1: Outcomes.HasNonUniqueAccesskeys(["b"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() names every contested key of a multi-token access key`, async (t) => { + const both = ; + const first = ; + const second = ; + + const document = h.document([both, first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, both, { + 1: Outcomes.HasNonUniqueAccesskeys(["a", "b"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["b"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() passes multi-token access keys that share no token`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, first, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + passed(R120, second, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports a key the element declares twice`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasRepeatedAccesskeys(["a"]), + }), + ]); +}); + +test(`evaluate() reports a key declared twice in different cases`, async (t) => { + // `A` and `a` reach the same physical key, so this element declares one key + // twice. Stricter than the W3C checker, which compares the tokens literally + // and accepts this value. + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasRepeatedAccesskeys(["a"]), + }), + ]); +}); + +test(`evaluate() reports a multi-character key, which no user can press`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasMultiCharacterAccesskeys(["aa"]), + 3: Outcomes.HasDistinctAccesskeys, + }), + passed(R120, second, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports two elements sharing a multi-character key`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["aa"]), + 2: Outcomes.HasMultiCharacterAccesskeys(["aa"]), + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["aa"]), + 2: Outcomes.HasMultiCharacterAccesskeys(["aa"]), + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() accepts a single Japanese character as a key`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() accepts several Japanese characters as separate keys`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports two elements sharing a Japanese key`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["ぬ"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["ぬ"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports a Japanese key an element declares twice`, async (t) => { + // Verbatim from the checker's duplicate-key-labels test case. + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasRepeatedAccesskeys(["ぬ"]), + }), + ]); +}); + +test(`evaluate() reports a multi-character Japanese key`, async (t) => { + // Adapted from the checker's multi-character-key-label test case, which uses + // `ほげ`. Both kana here sit directly on a JIS keyboard, ほ on the `-` key and + // す on the `R` key, so the value is plainly two keystrokes rather than one. + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasMultiCharacterAccesskeys(["ほす"]), + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports every multi-character key of one element`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasMultiCharacterAccesskeys(["aa", "bb"]), + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports every key an element declares twice`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasRepeatedAccesskeys(["a", "b"]), + }), + ]); +}); + +test(`evaluate() passes an access key shared only with an element that is not rendered`, async (t) => { + const target = ; + + const document = h.document([ + target, + , + ]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports a key shared across a frame boundary`, async (t) => { + // Access keys are treated as unique across the whole page, frames included, + // rather than per document. + const outer = ; + const inner = ; + + const document = h.document([outer, ]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, outer, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, inner, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() passes distinct keys either side of a frame boundary`, async (t) => { + const outer = ; + const inner = ; + + const document = h.document([outer, ]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, outer, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + passed(R120, inner, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports a key shared between two frames`, async (t) => { + const first = ; + const second = ; + + const document = h.document([ + , + , + ]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, first, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, second, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() reports a key shared across a shadow boundary in one document`, async (t) => { + const light = ; + const shadowed = ; + + const document = h.document([light,
{h.shadow([shadowed])}
]); + + t.deepEqual(await evaluate(R120, { document }), [ + failed(R120, light, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + failed(R120, shadowed, { + 1: Outcomes.HasNonUniqueAccesskeys(["a"]), + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + ]); +}); + +test(`evaluate() is inapplicable to a document with no access key`, async (t) => { + const document = h.document([]); + + t.deepEqual(await evaluate(R120, { document }), [inapplicable(R120)]); +}); + +test(`evaluate() is inapplicable to an access key that is only whitespace`, async (t) => { + const document = h.document([]); + + t.deepEqual(await evaluate(R120, { document }), [inapplicable(R120)]); +}); diff --git a/packages/alfa-rules/test/tsconfig.json b/packages/alfa-rules/test/tsconfig.json index 5612de8902..1046da1a46 100644 --- a/packages/alfa-rules/test/tsconfig.json +++ b/packages/alfa-rules/test/tsconfig.json @@ -124,7 +124,8 @@ "./sia-r116/rule.spec.tsx", "./sia-r117/rule.spec.tsx", "./sia-r118/rule.spec.tsx", - "./sia-r119/rule.spec.tsx" + "./sia-r119/rule.spec.tsx", + "./sia-r120/rule.spec.tsx" ], "references": [ { "path": "../src" },