diff --git a/src/rules/aria-required-children.ts b/src/rules/aria-required-children.ts new file mode 100644 index 0000000..132c32f --- /dev/null +++ b/src/rules/aria-required-children.ts @@ -0,0 +1,127 @@ +import { AccessibilityError } from "../scanner"; +import { querySelectorAll } from "../utils"; + +// TODO: This list is incomplete! +type Role = + | "article" + | "cell" + | "checkbox" + | "columnheader" + | "combobox" + | "deletion" + | "feed" + | "grid" + | "gridcell" + | "group" + | "heading" + | "insertion" + | "list" + | "listbox" + | "listitem" + | "menu" + | "menubar" + | "menuitem" + | "menuitemcheckbox" + | "menuitemradio" + | "meter" + | "option" + | "radio" + | "row" + | "rowgroup" + | "rowheader" + | "scrollbar" + | "separator" + | "seperator" + | "slider" + | "suggestion" + | "switch" + | "tab" + | "table" + | "tablist" + | "tree" + | "treegrid" + | "treeitem"; + +/** + * Required States and Properties: + * + * @see https://w3c.github.io/aria/#authorErrorDefaultValuesTable + */ +const roleToRequiredChildRoleMapping: Partial> = { + feed: ["article"], + grid: ["rowgroup", "row"], + list: ["listitem"], + listbox: ["group", "option"], + menu: [ + "group", + "menuitemradio", + "menuitem", + "menuitemcheckbox", + "menu", + "separator", + ], + menubar: [ + "group", + "menuitemradio", + "menuitem", + "menuitemcheckbox", + "menu", + "separator", + ], + row: ["cell", "columnheader", "gridcell", "rowheader"], + rowgroup: ["row"], + suggestion: ["insertion", "deletion"], + table: ["rowgroup", "row"], + tablist: ["tab"], + tree: ["group", "treeitem"], + treegrid: ["rowgroup", "row"], +}; + +export const references = { + act: { + id: "ff89c9", + text: "ARIA required context role", + url: "https://act-rules.github.io/rules/ff89c9", + }, + axe: { + id: "aria-required-children", + text: "Certain ARIA roles must contain particular children", + url: `https://dequeuniversity.com/rules/axe/4.4/aria-required-children?application=RuleDescription`, + }, +}; + +export function ariaRequiredChildren(el: Element): AccessibilityError[] { + const errors = []; + + // Loop over all the different rules. + for (const [role, requiredChildren] of Object.entries( + roleToRequiredChildRoleMapping, + )) { + // Find all the elements with a role that we are interested in. + for (const parent of querySelectorAll(`[role=${role}]`, el)) { + let isValid = false; + + // Look for children of the parents with the correct roles. + // TODO: Probably special case `aria-owns` as that allows you to not have + // the items as descendants. + const childSelector = requiredChildren.reduce((selector, childRole) => { + if (!selector) return `[role=${childRole}]`; + return `${selector},[role=${childRole}]`; + }, ""); + for (const child of querySelectorAll(childSelector, parent)) { + if (!child) continue; + // TODO: Check if child is valid somehow + isValid = true; + } + + if (isValid) continue; + errors.push({ + element: parent, + text: references.axe.text, + url: references.axe.text, + }); + } + } + + return errors; +} diff --git a/src/scanner.ts b/src/scanner.ts index 0f09989..586042a 100644 --- a/src/scanner.ts +++ b/src/scanner.ts @@ -11,6 +11,7 @@ import label from "./rules/label"; import linkName from "./rules/link-name"; import nestedInteractive from "./rules/nested-interactive"; import validLang from "./rules/valid-lang"; +import { ariaRequiredChildren } from "./rules/aria-required-children"; import { Logger } from "./logger"; @@ -38,6 +39,7 @@ export const allRules: Rule[] = [ linkName, nestedInteractive, validLang, + ariaRequiredChildren, ]; export async function requestIdleScan( diff --git a/tests/act/act.js b/tests/act/act.js index 1b021d0..a268f4e 100644 --- a/tests/act/act.js +++ b/tests/act/act.js @@ -108,7 +108,6 @@ const rulesToIgnore = [ "efbfc7", "f51b46", "fd3a94", - "ff89c9", "ffbc54", "ffd0e9", "m6b1q3", @@ -117,6 +116,7 @@ const rulesToIgnore = [ "ucwvc8", "ye5d6e", "bf051a", + "ff89c9", ]; const ignoredExamples = [ @@ -126,6 +126,9 @@ const ignoredExamples = [ "https://act-rules.github.io/testcases/qt1vmo/0ef4f516db9ed70cb25f39c99637272808b8e60f.html", ]; +// TODO: Instead of dynamic tests which behave weird in Web Test Runner, we +// should generate the tests from the HTML testcases. It would be great if it's +// easy to regenerate so we don't accidentally change the tests. describe("ACT Rules", function () { for (const rule of applicableRules) { const { diff --git a/tests/aria-required-children.ts b/tests/aria-required-children.ts new file mode 100644 index 0000000..16d64ae --- /dev/null +++ b/tests/aria-required-children.ts @@ -0,0 +1,202 @@ +import { fixture, expect } from "@open-wc/testing"; +import { Scanner } from "../src/scanner"; +import { ariaRequiredChildren } from "../src/rules/aria-required-children"; + +const scanner = new Scanner([ariaRequiredChildren]); + +// Just to get prettier working ;) +const html = String.raw; + +const passes = [ + html`
+
Item 1
+
`, + html`
+
+
+
`, + html`
+
+
+
+
+
`, + html`
+
+ Item 1 +
+
`, + html`
+ +
`, + html``, + html`
+ option + option +
`, + html`
+
+
option
+
+
`, + html`
`, + html`
`, + html``, + html`
`, + html`
+ + + +
Item 1
+
item 2
+
item 2
+
item 2
+
`, + html``, + html``, + html``, + html`
+
+
ignore
+ +
  • item 1
  • +
  • item 2
  • +
    +
    `, +]; + +const violations = [ + html`
    + +
    `, + html`
    `, + html`
    `, + html``, + html`
    +
    +
    List item 1
    +
    +
    `, + html`
    +
    + Item 1 +
    +
    `, + html`
    `, + html`
    +
    +
    `, + html`
    `, + html`
    +
  • Item 1
  • + Item 2 +
    `, + html`
    +
    +
    List item 1
    +
    List item 2
    +
    +
    `, + html`
    +
    +
    List item 1
    +
    List item 2
    +
    +
    `, + html`
    +
    +
    `, + html`
    +
    unallowed role
    +
    `, +]; + +const incomplete = [ + `
    `, + `
    `, + `
    `, + `
    `, + `
    `, + `
    `, + `
    `, + `
    `, + `
    +
    +
    `, + ``, + '', +]; + +const inapplicable = [ + `
    `, + `
    `, + `
    +
    Heading
    + +
    `, +]; + +describe("aria-required-attr", async function () { + for (const markup of passes) { + const el = await fixture(markup); + it(el.outerHTML, async () => { + const results = (await scanner.scan(el)).map(({ text, url }) => { + return { text, url }; + }); + expect(results).to.be.empty; + }); + } + for (const markup of violations) { + const el = await fixture(markup); + it(el.outerHTML, async () => { + const results = (await scanner.scan(el)).map(({ text, url }) => { + return { text, url }; + }); + expect(results).to.eql([ + { + text: "TODO", + url: "TODO", + }, + ]); + }); + } +}); diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index f113677..f76f83b 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -1,7 +1,6 @@ // eslint-disable-next-line foo import { env } from "node:process"; -import { summaryReporter } from "@web/test-runner"; import { esbuildPlugin } from "@web/dev-server-esbuild"; import { playwrightLauncher } from "@web/test-runner-playwright"; import { junitReporter } from "@web/test-runner-junit-reporter"; @@ -15,23 +14,12 @@ if (env.CI) { ); } -const reporters = [ - summaryReporter(), - env.CI - ? junitReporter({ - outputPath: "./test-results.xml", - reportLogs: true, - }) - : null, -]; - -export default { +const config = { nodeResolve: true, coverage: true, files: ["tests/**/*.ts", "tests/**/*.js"], plugins: [esbuildPlugin({ ts: true, target: "esnext" })], browsers, - reporters, filterBrowserLogs(log) { if ( typeof log.args[0] === "string" && @@ -44,3 +32,16 @@ export default { return true; }, }; + +if (env.CI) { + config.reporters = [ + env.CI + ? junitReporter({ + outputPath: "./test-results.xml", + reportLogs: true, + }) + : null, + ]; +} + +export default config;