From 24fe3f0b4b630904ce2094271651e31096030c69 Mon Sep 17 00:00:00 2001 From: pooya-si Date: Wed, 16 Sep 2026 18:56:20 -0400 Subject: [PATCH 1/4] [11Y-12318] Add R120. --- .changeset/lucky-keys-listen.md | 9 ++ packages/alfa-rules/src/rules.ts | 2 + packages/alfa-rules/src/sia-r120/rule.ts | 101 +++++++++++++++ packages/alfa-rules/src/tsconfig.json | 1 + .../alfa-rules/test/sia-r120/rule.spec.tsx | 120 ++++++++++++++++++ packages/alfa-rules/test/tsconfig.json | 3 +- 6 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 .changeset/lucky-keys-listen.md create mode 100644 packages/alfa-rules/src/sia-r120/rule.ts create mode 100644 packages/alfa-rules/test/sia-r120/rule.spec.tsx diff --git a/.changeset/lucky-keys-listen.md b/.changeset/lucky-keys-listen.md new file mode 100644 index 0000000000..e6e1f9818a --- /dev/null +++ b/.changeset/lucky-keys-listen.md @@ -0,0 +1,9 @@ +--- +"@siteimprove/alfa-rules": minor +--- + +**Added:** The new rule R120 checks that no element declares an access key that another element also declares. + +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. The diagnostic names the keys that are contested, so an element declaring several is told which of them clash. + +The attribute holds a set of space-separated tokens, so `accesskey="a b"` declares two keys and is reported if either is shared. Tokens are compared without regard to case, so `A` and `a` are the same key. Elements that are not rendered are left out, since their access keys cannot be activated. 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..0f4977057b --- /dev/null +++ b/packages/alfa-rules/src/sia-r120/rule.ts @@ -0,0 +1,101 @@ +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 no element declares an access key that another element + * also declares. + * + * 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. + * + * 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, + ); + + return { + 1: expectation( + contested.isEmpty(), + () => Outcomes.HasUniqueAccesskeys, + () => Outcomes.HasNonUniqueAccesskeys(contested), + ), + }; + }, + }; + }, +}); + +function accesskeys(element: Element): Sequence { + return Sequence.from(element.attribute("accesskey")).flatMap((attribute) => + attribute.tokens().map((token) => token.toLowerCase()), + ); +} + +const declaresAccesskey: Predicate = (element) => + !accesskeys(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( + `More than one element declares the access key ${Sequence.from(keys) + .map((key) => `"${key}"`) + .join(", ")}.`, + ), + ); +} 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..3945a27ff6 --- /dev/null +++ b/packages/alfa-rules/test/sia-r120/rule.spec.tsx @@ -0,0 +1,120 @@ +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 }), + passed(R120, second, { 1: Outcomes.HasUniqueAccesskeys }), + ]); +}); + +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"]) }), + failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + ]); +}); + +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"]) }), + failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + ]); +}); + +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"]) }), + failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + ]); +}); + +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"]) }), + failed(R120, other, { 1: Outcomes.HasNonUniqueAccesskeys(["b"]) }), + ]); +}); + +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"]) }), + failed(R120, first, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["b"]) }), + ]); +}); + +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 }), + passed(R120, second, { 1: Outcomes.HasUniqueAccesskeys }), + ]); +}); + +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 }), + ]); +}); + +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" }, From a317497641c99062f3973423a457bc103f665bbe Mon Sep 17 00:00:00 2001 From: pooya-si Date: Thu, 17 Sep 2026 14:43:41 -0400 Subject: [PATCH 2/4] [11Y-12318] Add more tests. --- .../alfa-rules/test/sia-r120/rule.spec.tsx | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/alfa-rules/test/sia-r120/rule.spec.tsx b/packages/alfa-rules/test/sia-r120/rule.spec.tsx index 3945a27ff6..44b64cae40 100644 --- a/packages/alfa-rules/test/sia-r120/rule.spec.tsx +++ b/packages/alfa-rules/test/sia-r120/rule.spec.tsx @@ -107,6 +107,59 @@ test(`evaluate() passes an access key shared only with an element that is not re ]); }); +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"]) }), + failed(R120, inner, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + ]); +}); + +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 }), + passed(R120, inner, { 1: Outcomes.HasUniqueAccesskeys }), + ]); +}); + +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"]) }), + failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + ]); +}); + +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"]) }), + failed(R120, shadowed, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + ]); +}); + test(`evaluate() is inapplicable to a document with no access key`, async (t) => { const document = h.document([]); From 5448b786c86bad31ad086d111e9ac1e4fe579711 Mon Sep 17 00:00:00 2001 From: pooya-si Date: Thu, 17 Sep 2026 16:37:48 -0400 Subject: [PATCH 3/4] [11Y-12318] Cover edge case of duplicate keys on an element. --- packages/alfa-rules/src/sia-r120/rule.ts | 11 ++++-- .../alfa-rules/test/sia-r120/rule.spec.tsx | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/alfa-rules/src/sia-r120/rule.ts b/packages/alfa-rules/src/sia-r120/rule.ts index 0f4977057b..6df5dc2f17 100644 --- a/packages/alfa-rules/src/sia-r120/rule.ts +++ b/packages/alfa-rules/src/sia-r120/rule.ts @@ -72,8 +72,15 @@ export default Rule.Atomic.of({ }); function accesskeys(element: Element): Sequence { - return Sequence.from(element.attribute("accesskey")).flatMap((attribute) => - attribute.tokens().map((token) => token.toLowerCase()), + return ( + Sequence.from(element.attribute("accesskey")) + .flatMap((attribute) => + attribute.tokens().map((token) => token.toLowerCase()), + ) + // The specification requires the tokens to be distinct across the document, + // but a repeated one within the same element would otherwise enter the same group twice and the element would be + // reported as competing with itself. + .distinct() ); } diff --git a/packages/alfa-rules/test/sia-r120/rule.spec.tsx b/packages/alfa-rules/test/sia-r120/rule.spec.tsx index 44b64cae40..00bb4b547e 100644 --- a/packages/alfa-rules/test/sia-r120/rule.spec.tsx +++ b/packages/alfa-rules/test/sia-r120/rule.spec.tsx @@ -92,6 +92,40 @@ test(`evaluate() passes multi-token access keys that share no token`, async (t) ]); }); +test(`evaluate() does not report an element against itself for a repeated token`, async (t) => { + const target = ; + + const document = h.document([target]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, target, { 1: Outcomes.HasUniqueAccesskeys }), + ]); +}); + +test(`evaluate() treats a multi-character token as its own key`, async (t) => { + const first = ; + const second = ; + + const document = h.document([first, second]); + + t.deepEqual(await evaluate(R120, { document }), [ + passed(R120, first, { 1: Outcomes.HasUniqueAccesskeys }), + passed(R120, second, { 1: Outcomes.HasUniqueAccesskeys }), + ]); +}); + +test(`evaluate() reports two elements sharing a multi-character token`, 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"]) }), + failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["aa"]) }), + ]); +}); + test(`evaluate() passes an access key shared only with an element that is not rendered`, async (t) => { const target = ; From bd28ed6060dce1afba602a3b1f73c68ad8fff84a Mon Sep 17 00:00:00 2001 From: pooya-si Date: Fri, 18 Sep 2026 12:06:43 -0400 Subject: [PATCH 4/4] [11Y-12318] Cover edge cases. --- .changeset/lucky-keys-listen.md | 8 +- packages/alfa-rules/src/sia-r120/rule.ts | 102 +++++- .../alfa-rules/test/sia-r120/rule.spec.tsx | 305 ++++++++++++++++-- 3 files changed, 364 insertions(+), 51 deletions(-) diff --git a/.changeset/lucky-keys-listen.md b/.changeset/lucky-keys-listen.md index e6e1f9818a..32a98e8476 100644 --- a/.changeset/lucky-keys-listen.md +++ b/.changeset/lucky-keys-listen.md @@ -2,8 +2,10 @@ "@siteimprove/alfa-rules": minor --- -**Added:** The new rule R120 checks that no element declares an access key that another element also declares. +**Added:** The new rule R120 checks that the access keys an element declares are usable. -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. The diagnostic names the keys that are contested, so an element declaring several is told which of them clash. +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. -The attribute holds a set of space-separated tokens, so `accesskey="a b"` declares two keys and is reported if either is shared. Tokens are compared without regard to case, so `A` and `a` are the same key. Elements that are not rendered are left out, since their access keys cannot be activated. +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/sia-r120/rule.ts b/packages/alfa-rules/src/sia-r120/rule.ts index 6df5dc2f17..e97a400b73 100644 --- a/packages/alfa-rules/src/sia-r120/rule.ts +++ b/packages/alfa-rules/src/sia-r120/rule.ts @@ -17,12 +17,19 @@ const { isRendered } = Style; const { getElementDescendants } = Query; /** - * This rule checks that no element declares an access key that another element - * also declares. + * 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. @@ -58,6 +65,9 @@ export default Rule.Atomic.of({ 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( @@ -65,27 +75,51 @@ export default Rule.Atomic.of({ () => Outcomes.HasUniqueAccesskeys, () => Outcomes.HasNonUniqueAccesskeys(contested), ), + 2: expectation( + tooLong.isEmpty(), + () => Outcomes.HasSingleCharacterAccesskeys, + () => Outcomes.HasMultiCharacterAccesskeys(tooLong), + ), + 3: expectation( + duplicated.isEmpty(), + () => Outcomes.HasDistinctAccesskeys, + () => Outcomes.HasRepeatedAccesskeys(duplicated), + ), }; }, }; }, }); -function accesskeys(element: Element): Sequence { - return ( - Sequence.from(element.attribute("accesskey")) - .flatMap((attribute) => - attribute.tokens().map((token) => token.toLowerCase()), - ) - // The specification requires the tokens to be distinct across the document, - // but a repeated one within the same element would otherwise enter the same group twice and the element would be - // reported as competing with itself. - .distinct() +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) => - !accesskeys(element).isEmpty(); + !tokens(element).isEmpty(); /** * @public @@ -100,9 +134,45 @@ export namespace Outcomes { export const HasNonUniqueAccesskeys = (keys: Iterable) => Err.of( Diagnostic.of( - `More than one element declares the access key ${Sequence.from(keys) - .map((key) => `"${key}"`) - .join(", ")}.`, + 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/test/sia-r120/rule.spec.tsx b/packages/alfa-rules/test/sia-r120/rule.spec.tsx index 00bb4b547e..af0ab10437 100644 --- a/packages/alfa-rules/test/sia-r120/rule.spec.tsx +++ b/packages/alfa-rules/test/sia-r120/rule.spec.tsx @@ -13,8 +13,16 @@ test(`evaluate() passes elements with distinct access keys`, async (t) => { const document = h.document([first, second]); t.deepEqual(await evaluate(R120, { document }), [ - passed(R120, first, { 1: Outcomes.HasUniqueAccesskeys }), - passed(R120, second, { 1: Outcomes.HasUniqueAccesskeys }), + passed(R120, first, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + passed(R120, second, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), ]); }); @@ -25,8 +33,16 @@ test(`evaluate() fails both elements sharing an access key`, async (t) => { const document = h.document([first, second]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, first, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + 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, + }), ]); }); @@ -37,8 +53,16 @@ test(`evaluate() compares access keys without regard to case`, async (t) => { const document = h.document([first, second]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, first, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + 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, + }), ]); }); @@ -49,8 +73,16 @@ test(`evaluate() compares access keys with surrounding whitespace removed`, asyn const document = h.document([first, second]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, first, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + 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, + }), ]); }); @@ -61,8 +93,16 @@ test(`evaluate() names only the contested key of a multi-token access key`, asyn const document = h.document([both, other]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, both, { 1: Outcomes.HasNonUniqueAccesskeys(["b"]) }), - failed(R120, other, { 1: Outcomes.HasNonUniqueAccesskeys(["b"]) }), + 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, + }), ]); }); @@ -74,9 +114,21 @@ test(`evaluate() names every contested key of a multi-token access key`, async ( const document = h.document([both, first, second]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, both, { 1: Outcomes.HasNonUniqueAccesskeys(["a", "b"]) }), - failed(R120, first, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["b"]) }), + 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, + }), ]); }); @@ -87,42 +139,195 @@ test(`evaluate() passes multi-token access keys that share no token`, async (t) const document = h.document([first, second]); t.deepEqual(await evaluate(R120, { document }), [ - passed(R120, first, { 1: Outcomes.HasUniqueAccesskeys }), - passed(R120, second, { 1: Outcomes.HasUniqueAccesskeys }), + 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() does not report an element against itself for a repeated token`, async (t) => { +test(`evaluate() reports a key the element declares twice`, async (t) => { const target = ; const document = h.document([target]); t.deepEqual(await evaluate(R120, { document }), [ - passed(R120, target, { 1: Outcomes.HasUniqueAccesskeys }), + failed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasRepeatedAccesskeys(["a"]), + }), ]); }); -test(`evaluate() treats a multi-character token as its own key`, async (t) => { +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 }), [ - passed(R120, first, { 1: Outcomes.HasUniqueAccesskeys }), - passed(R120, second, { 1: Outcomes.HasUniqueAccesskeys }), + 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 token`, async (t) => { +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"]) }), - failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["aa"]) }), + 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"]), + }), ]); }); @@ -137,7 +342,11 @@ test(`evaluate() passes an access key shared only with an element that is not re ]); t.deepEqual(await evaluate(R120, { document }), [ - passed(R120, target, { 1: Outcomes.HasUniqueAccesskeys }), + passed(R120, target, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), ]); }); @@ -150,8 +359,16 @@ test(`evaluate() reports a key shared across a frame boundary`, async (t) => { const document = h.document([outer, ]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, outer, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, inner, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + 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, + }), ]); }); @@ -162,8 +379,16 @@ test(`evaluate() passes distinct keys either side of a frame boundary`, async (t const document = h.document([outer, ]); t.deepEqual(await evaluate(R120, { document }), [ - passed(R120, outer, { 1: Outcomes.HasUniqueAccesskeys }), - passed(R120, inner, { 1: Outcomes.HasUniqueAccesskeys }), + passed(R120, outer, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), + passed(R120, inner, { + 1: Outcomes.HasUniqueAccesskeys, + 2: Outcomes.HasSingleCharacterAccesskeys, + 3: Outcomes.HasDistinctAccesskeys, + }), ]); }); @@ -177,8 +402,16 @@ test(`evaluate() reports a key shared between two frames`, async (t) => { ]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, first, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, second, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + 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, + }), ]); }); @@ -189,8 +422,16 @@ test(`evaluate() reports a key shared across a shadow boundary in one document`, const document = h.document([light,
{h.shadow([shadowed])}
]); t.deepEqual(await evaluate(R120, { document }), [ - failed(R120, light, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), - failed(R120, shadowed, { 1: Outcomes.HasNonUniqueAccesskeys(["a"]) }), + 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, + }), ]); });