From 5497b22e07a7ff73c589a2077f6d4f26679fe11c Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Mon, 30 Aug 2021 09:21:51 +0200 Subject: [PATCH 1/6] Create rule file --- packages/alfa-rules/src/sia-r76/rule.ts | 15 +++++++++++++++ packages/alfa-rules/tsconfig.json | 1 + 2 files changed, 16 insertions(+) create mode 100644 packages/alfa-rules/src/sia-r76/rule.ts diff --git a/packages/alfa-rules/src/sia-r76/rule.ts b/packages/alfa-rules/src/sia-r76/rule.ts new file mode 100644 index 0000000000..0009561b83 --- /dev/null +++ b/packages/alfa-rules/src/sia-r76/rule.ts @@ -0,0 +1,15 @@ +import { Rule } from "@siteimprove/alfa-act"; +import { Element } from "@siteimprove/alfa-dom"; +import { Criterion, Technique } from "@siteimprove/alfa-wcag"; +import { Page } from "@siteimprove/alfa-web"; + +export default Rule.Atomic.of({ + uri: "https://alfa.siteimprove.com/rules/sia-r76", + requirements: [Criterion.of("1.3.1")], + evaluate({ device, document }) { + return { + applicability() {}, + expectations(target) {}, + }; + }, +}); diff --git a/packages/alfa-rules/tsconfig.json b/packages/alfa-rules/tsconfig.json index 85d2085dda..e606404275 100644 --- a/packages/alfa-rules/tsconfig.json +++ b/packages/alfa-rules/tsconfig.json @@ -143,6 +143,7 @@ "src/sia-r73/rule.ts", "src/sia-r74/rule.ts", "src/sia-r75/rule.ts", + "src/sia-r76/rule.ts", "src/sia-r78/rule.ts", "src/sia-r8/rule.ts", "src/sia-r80/rule.ts", From 48aeddd92fd88dced28a5e4c608e5ed09d163308 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Tue, 31 Aug 2021 16:03:45 +0200 Subject: [PATCH 2/6] Implement R76 --- packages/alfa-rules/src/sia-r76/rule.ts | 103 ++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/alfa-rules/src/sia-r76/rule.ts b/packages/alfa-rules/src/sia-r76/rule.ts index 0009561b83..39a9915618 100644 --- a/packages/alfa-rules/src/sia-r76/rule.ts +++ b/packages/alfa-rules/src/sia-r76/rule.ts @@ -1,15 +1,108 @@ -import { Rule } from "@siteimprove/alfa-act"; -import { Element } from "@siteimprove/alfa-dom"; -import { Criterion, Technique } from "@siteimprove/alfa-wcag"; +import { Diagnostic, Rule } from "@siteimprove/alfa-act"; +import { Element, Namespace } from "@siteimprove/alfa-dom"; +import { Predicate } from "@siteimprove/alfa-predicate"; +import { Err, Ok } from "@siteimprove/alfa-result"; +import { Cell, Scope, Table } from "@siteimprove/alfa-table"; +import { Criterion } from "@siteimprove/alfa-wcag"; import { Page } from "@siteimprove/alfa-web"; +import { expectation } from "../common/expectation"; +import { hasRole, isIgnored, isPerceivable } from "../common/predicate"; + +const { isElement, hasName, hasNamespace } = Element; +const { and, not, test } = Predicate; + export default Rule.Atomic.of({ uri: "https://alfa.siteimprove.com/rules/sia-r76", requirements: [Criterion.of("1.3.1")], evaluate({ device, document }) { + const data = new Map(); + return { - applicability() {}, - expectations(target) {}, + *applicability() { + const tables = document + .descendants() + .filter(isElement) + .filter( + and( + hasNamespace(Namespace.HTML), + hasName("table"), + not(isIgnored(device)) + ) + ); + + for (const table of tables) { + const model = Table.from(table); + + const headers = table + .descendants() + .filter(isElement) + .filter( + and( + hasNamespace(Namespace.HTML), + hasName("th"), + isPerceivable(device) + ) + ); + + for (const header of headers) { + for (const cell of model.cells.find((cell) => + cell.element.equals(header) + )) { + data.set(header, cell); + + yield header; + } + } + } + }, + expectations(target) { + // header is yielded after storing cell in data. + const cell = data.get(target)!; + + return { + 1: + // We cannot use `expectation` because we need the narrowing + // of cell to Cell.Header in the true branch. + // + // The scope attribute can be "auto" and resolve to an actual scope. + // But cell.scope has been filled by Table > formTable > assignScope + // and only defaults to "auto" if no actual scope is found. + cell.isHeader() && cell.scope !== "auto" + ? Outcomes.HasScope(cell.scope) + : Outcomes.HasNoScope, + + 2: expectation( + test(hasRole(device, "columnheader", "rowheader"), target), + () => Outcomes.HasHeaderRole, + () => Outcomes.HasNoHeaderRole + ), + }; + }, }; }, }); + +export namespace Outcomes { + export const HasScope = (scope: Scope) => + Ok.of(Diagnostic.of(`The header cell is a ${scope}`)); + + export const HasNoScope = Err.of( + Diagnostic.of( + `The header cell is neither a \`column\`, \`column group\`, + \`row\`, nor \`row group\` header` + ) + ); + + export const HasHeaderRole = Ok.of( + Diagnostic.of( + `The header element has either a \`columnheader\` or \`rowheader\` role` + ) + ); + + export const HasNoHeaderRole = Err.of( + Diagnostic.of( + `The header element has neither a \`columnheader\` nor a \`rowheader\` role` + ) + ); +} From 9b745f9adc3bf8bf1e2241889524a764e3b5609c Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Tue, 31 Aug 2021 16:04:00 +0200 Subject: [PATCH 3/6] Group imports --- packages/alfa-rules/src/sia-r46/rule.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/alfa-rules/src/sia-r46/rule.ts b/packages/alfa-rules/src/sia-r46/rule.ts index c9e7dde8f8..1d35555025 100644 --- a/packages/alfa-rules/src/sia-r46/rule.ts +++ b/packages/alfa-rules/src/sia-r46/rule.ts @@ -8,9 +8,7 @@ import { Page } from "@siteimprove/alfa-web"; import { expectation } from "../common/expectation"; -import { hasRole } from "../common/predicate/has-role"; -import { isIgnored } from "../common/predicate/is-ignored"; -import { isPerceivable } from "../common/predicate/is-perceivable"; +import { hasRole, isIgnored, isPerceivable } from "../common/predicate"; const { isElement, hasName, hasNamespace } = Element; const { and, not } = Predicate; From 5a75b55520280eeae7d373fe801a6d8e779b07bb Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 2 Sep 2021 14:28:41 +0200 Subject: [PATCH 4/6] Add tests for R76 --- packages/alfa-rules/src/sia-r76/rule.ts | 46 +---- .../alfa-rules/test/sia-r76/rule.spec.tsx | 173 ++++++++++++++++++ packages/alfa-rules/tsconfig.json | 1 + 3 files changed, 178 insertions(+), 42 deletions(-) create mode 100644 packages/alfa-rules/test/sia-r76/rule.spec.tsx diff --git a/packages/alfa-rules/src/sia-r76/rule.ts b/packages/alfa-rules/src/sia-r76/rule.ts index 39a9915618..3acc2bd207 100644 --- a/packages/alfa-rules/src/sia-r76/rule.ts +++ b/packages/alfa-rules/src/sia-r76/rule.ts @@ -16,8 +16,6 @@ export default Rule.Atomic.of({ uri: "https://alfa.siteimprove.com/rules/sia-r76", requirements: [Criterion.of("1.3.1")], evaluate({ device, document }) { - const data = new Map(); - return { *applicability() { const tables = document @@ -32,8 +30,6 @@ export default Rule.Atomic.of({ ); for (const table of tables) { - const model = Table.from(table); - const headers = table .descendants() .filter(isElement) @@ -46,33 +42,13 @@ export default Rule.Atomic.of({ ); for (const header of headers) { - for (const cell of model.cells.find((cell) => - cell.element.equals(header) - )) { - data.set(header, cell); - - yield header; - } + yield header; } } }, expectations(target) { - // header is yielded after storing cell in data. - const cell = data.get(target)!; - return { - 1: - // We cannot use `expectation` because we need the narrowing - // of cell to Cell.Header in the true branch. - // - // The scope attribute can be "auto" and resolve to an actual scope. - // But cell.scope has been filled by Table > formTable > assignScope - // and only defaults to "auto" if no actual scope is found. - cell.isHeader() && cell.scope !== "auto" - ? Outcomes.HasScope(cell.scope) - : Outcomes.HasNoScope, - - 2: expectation( + 1: expectation( test(hasRole(device, "columnheader", "rowheader"), target), () => Outcomes.HasHeaderRole, () => Outcomes.HasNoHeaderRole @@ -84,25 +60,11 @@ export default Rule.Atomic.of({ }); export namespace Outcomes { - export const HasScope = (scope: Scope) => - Ok.of(Diagnostic.of(`The header cell is a ${scope}`)); - - export const HasNoScope = Err.of( - Diagnostic.of( - `The header cell is neither a \`column\`, \`column group\`, - \`row\`, nor \`row group\` header` - ) - ); - export const HasHeaderRole = Ok.of( - Diagnostic.of( - `The header element has either a \`columnheader\` or \`rowheader\` role` - ) + Diagnostic.of(`The header element is a semantic header`) ); export const HasNoHeaderRole = Err.of( - Diagnostic.of( - `The header element has neither a \`columnheader\` nor a \`rowheader\` role` - ) + Diagnostic.of(`The header element is not a semantic header`) ); } diff --git a/packages/alfa-rules/test/sia-r76/rule.spec.tsx b/packages/alfa-rules/test/sia-r76/rule.spec.tsx new file mode 100644 index 0000000000..71dd3ca7ed --- /dev/null +++ b/packages/alfa-rules/test/sia-r76/rule.spec.tsx @@ -0,0 +1,173 @@ +/// +import { h } from "@siteimprove/alfa-dom"; +import { test } from "@siteimprove/alfa-test"; + +import R76, { Outcomes } from "../../src/sia-r76/rule"; + +import { evaluate } from "../common/evaluate"; +import { passed, failed, inapplicable } from "../common/outcome"; + +test(`evaluate() passes implicit row headers`, async (t) => { + const target1 = Mon-Fri; + const target2 = Sat-Sun; + + const document = h.document([ + + + + {target1} + + + + {target2} + + +
Opening hours
8-17
10-14
, + ]); + + t.deepEqual(await evaluate(R76, { document }), [ + passed(R76, target1, { + 1: Outcomes.HasHeaderRole, + }), + passed(R76, target2, { + 1: Outcomes.HasHeaderRole, + }), + ]); +}); + +test(`evaluate() passes explicit headers`, async (t) => { + const target1 = Morning; + const target2 = Afternoon; + const target3 = Mon-Fri; + const target4 = Sat-Sun; + + const document = h.document([ + + + + + {target1} + {target2} + + + {target3} + + + + + {target4} + + + +
Opening hours
Foo
8-1213-17
10-13Closed
, + ]); + t.deepEqual(await evaluate(R76, { document }), [ + passed(R76, target1, { + 1: Outcomes.HasHeaderRole, + }), + passed(R76, target2, { + 1: Outcomes.HasHeaderRole, + }), + passed(R76, target3, { + 1: Outcomes.HasHeaderRole, + }), + passed(R76, target4, { + 1: Outcomes.HasHeaderRole, + }), + ]); +}); + +test(`evaluate() fails headers with neither scope nor role`, async (t) => { + const target1 = Morning; + const target2 = Afternoon; + const target3 = Mon-Fri; + const target4 = Sat-Sun; + + const document = h.document([ + + + + {target1} + {target2} + + + {target3} + + + + + {target4} + + + +
Open
8-1213-17
10-13Closed
, + ]); + + t.deepEqual(await evaluate(R76, { document }), [ + failed(R76, target1, { + 1: Outcomes.HasNoHeaderRole, + }), + failed(R76, target2, { + 1: Outcomes.HasNoHeaderRole, + }), + failed(R76, target3, { + 1: Outcomes.HasNoHeaderRole, + }), + failed(R76, target4, { + 1: Outcomes.HasNoHeaderRole, + }), + ]); +}); + +test(`evaluate() fails headers whose role has been changed`, async (t) => { + const target1 = Mon-Fri; + const target2 = Sat-Sun; + + const document = h.document([ + + + + {target1} + + + + {target2} + + +
Opening hours
8-17
10-14
, + ]); + + t.deepEqual(await evaluate(R76, { document }), [ + failed(R76, target1, { + 1: Outcomes.HasNoHeaderRole, + }), + failed(R76, target2, { + 1: Outcomes.HasNoHeaderRole, + }), + ]); +}); + +test(`evaluate() is inapplicable to headers in hidden tables`, async (t) => { + const document = h.document([ + + + + + + + + + + + + + + + + + + , + ]); + + t.deepEqual(await evaluate(R76, { document }), [inapplicable(R76)]); +}); diff --git a/packages/alfa-rules/tsconfig.json b/packages/alfa-rules/tsconfig.json index e606404275..718a62ec0a 100644 --- a/packages/alfa-rules/tsconfig.json +++ b/packages/alfa-rules/tsconfig.json @@ -221,6 +221,7 @@ "test/sia-r73/rule.spec.tsx", "test/sia-r74/rule.spec.tsx", "test/sia-r75/rule.spec.tsx", + "test/sia-r76/rule.spec.tsx", "test/sia-r78/rule.spec.tsx", "test/sia-r80/rule.spec.tsx", "test/sia-r81/rule.spec.tsx", From 55e839a04eaf5cbdd552ecea4c7866f328a5f623 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 2 Sep 2021 14:31:58 +0200 Subject: [PATCH 5/6] Activate R76 --- packages/alfa-rules/src/rules.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/alfa-rules/src/rules.ts b/packages/alfa-rules/src/rules.ts index 65d56f4e44..d49669f262 100644 --- a/packages/alfa-rules/src/rules.ts +++ b/packages/alfa-rules/src/rules.ts @@ -70,6 +70,7 @@ export { default as R72 } from "./sia-r72/rule"; export { default as R73 } from "./sia-r73/rule"; export { default as R74 } from "./sia-r74/rule"; export { default as R75 } from "./sia-r75/rule"; +export { default as R76 } from "./sia-r75/rule"; export { default as R78 } from "./sia-r78/rule"; export { default as R80 } from "./sia-r80/rule"; export { default as R81 } from "./sia-r81/rule"; From 0dca788bec8dc185636d9254350a6083c0cee7d2 Mon Sep 17 00:00:00 2001 From: Jean-Yves Moyen Date: Thu, 2 Sep 2021 14:43:28 +0200 Subject: [PATCH 6/6] Optimise code --- packages/alfa-rules/src/sia-r76/rule.ts | 56 +++++++++++++------------ 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/alfa-rules/src/sia-r76/rule.ts b/packages/alfa-rules/src/sia-r76/rule.ts index 3acc2bd207..6fa47a1224 100644 --- a/packages/alfa-rules/src/sia-r76/rule.ts +++ b/packages/alfa-rules/src/sia-r76/rule.ts @@ -1,8 +1,8 @@ import { Diagnostic, Rule } from "@siteimprove/alfa-act"; -import { Element, Namespace } from "@siteimprove/alfa-dom"; +import { Element, Namespace, Node } from "@siteimprove/alfa-dom"; import { Predicate } from "@siteimprove/alfa-predicate"; +import { Refinement } from "@siteimprove/alfa-refinement"; import { Err, Ok } from "@siteimprove/alfa-result"; -import { Cell, Scope, Table } from "@siteimprove/alfa-table"; import { Criterion } from "@siteimprove/alfa-wcag"; import { Page } from "@siteimprove/alfa-web"; @@ -10,42 +10,44 @@ import { expectation } from "../common/expectation"; import { hasRole, isIgnored, isPerceivable } from "../common/predicate"; const { isElement, hasName, hasNamespace } = Element; -const { and, not, test } = Predicate; +const { not } = Predicate; +const { and, test } = Refinement; export default Rule.Atomic.of({ uri: "https://alfa.siteimprove.com/rules/sia-r76", requirements: [Criterion.of("1.3.1")], evaluate({ device, document }) { return { - *applicability() { - const tables = document - .descendants() - .filter(isElement) - .filter( - and( - hasNamespace(Namespace.HTML), - hasName("table"), - not(isIgnored(device)) - ) - ); + applicability() { + return visit(document); - for (const table of tables) { - const headers = table - .descendants() - .filter(isElement) - .filter( - and( - hasNamespace(Namespace.HTML), - hasName("th"), - isPerceivable(device) - ) - ); + function* visit( + node: Node, + collect: boolean = false + ): Iterable { + if (test(and(isElement, hasNamespace(Namespace.HTML)), node)) { + if (test(hasName("table"), node)) { + // only collect cells of accessible tables + collect = test(not(isIgnored(device)), node); + } - for (const header of headers) { - yield header; + if ( + collect && + test(and(hasName("th"), isPerceivable(device)), node) + ) { + yield node; + } + } + + for (const child of node.children({ + flattened: true, + nested: true, + })) { + yield* visit(child, collect); } } }, + expectations(target) { return { 1: expectation(