diff --git a/README.md b/README.md index b64c0a7..c204cf6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Rules that do not necessarily conform to WCAG success criterion but are industry | :---------- | :---------------------------------- | :-------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------- | :----------------- | :---------------------------------------------- | :------------------------- | :------------------------------------------------- | | ❌ | accesskeys | https://dequeuniversity.com/rules/axe/4.4/accesskeys?application=RuleDescription | Ensures every accesskey attribute value is unique | Serious | cat.keyboard, best-practice | failure | | | ❌ | aria-allowed-role | https://dequeuniversity.com/rules/axe/4.4/aria-allowed-role?application=RuleDescription | Ensures role attribute has an appropriate value for the element | Minor | cat.aria, best-practice | failure, needs review | | -| ❌ | aria-dialog-name | https://dequeuniversity.com/rules/axe/4.4/aria-dialog-name?application=RuleDescription | Ensures every ARIA dialog and alertdialog node has an accessible name | Serious | cat.aria, best-practice | failure, needs review | | +| ✅ | aria-dialog-name | https://dequeuniversity.com/rules/axe/4.4/aria-dialog-name?application=RuleDescription | Ensures every ARIA dialog and alertdialog node has an accessible name | Serious | cat.aria, best-practice | failure, needs review | | | ❌ | aria-text | https://dequeuniversity.com/rules/axe/4.4/aria-text?application=RuleDescription | Ensures "role=text" is used on elements with no focusable descendants | Serious | cat.aria, best-practice | failure, needs review | | | ❌ | aria-treeitem-name | https://dequeuniversity.com/rules/axe/4.4/aria-treeitem-name?application=RuleDescription | Ensures every ARIA treeitem node has an accessible name | Serious | cat.aria, best-practice | failure, needs review | | | ❌ | empty-heading | https://dequeuniversity.com/rules/axe/4.4/empty-heading?application=RuleDescription | Ensures headings have discernible text | Minor | cat.name-role-value, best-practice | failure, needs review | [ffd0e9](https://act-rules.github.io/rules/ffd0e9) | diff --git a/src/rules/aria-dialog-name.ts b/src/rules/aria-dialog-name.ts new file mode 100644 index 0000000..0d37090 --- /dev/null +++ b/src/rules/aria-dialog-name.ts @@ -0,0 +1,30 @@ +import { AccessibilityError } from "../scanner"; +import { hasAccessibleText, querySelectorAll } from "../utils"; + +// Metadata +const id = "aria-dialog-name"; +const text = "ARIA dialog and alertdialog must have an accessible name"; +const url = `https://dequeuniversity.com/rules/axe/4.4/${id}?application=RuleDescription`; + +/** + * Ensure that each element with role="dialog" or role="alertdialog" has one of the following characteristics: + * + * - Non-empty aria-label attribute. + * - aria-labelledby pointing to element with text which is discernible to screen reader users. + */ +export function ariaDialogName(el: Element): AccessibilityError[] { + const errors = []; + const selector = "[role=dialog],[role=alertdialog]"; + const dialogs = querySelectorAll(selector, el); + if (el.matches(selector)) dialogs.push(el); + for (const dialog of dialogs) { + if (!hasAccessibleText(dialog)) { + errors.push({ + element: dialog, + url, + text, + }); + } + } + return errors; +} diff --git a/src/rules/aria-tooltip-name.ts b/src/rules/aria-tooltip-name.ts index a2ee52a..c9e3e83 100644 --- a/src/rules/aria-tooltip-name.ts +++ b/src/rules/aria-tooltip-name.ts @@ -1,36 +1,11 @@ import { AccessibilityError } from "../scanner"; -import { labelledByIsValid, querySelectorAll } from "../utils"; +import { hasAccessibleText, querySelectorAll } from "../utils"; // Metadata const id = "aria-tooltip-name"; const text = "ARIA tooltip must have an accessible name"; const url = `https://dequeuniversity.com/rules/axe/4.4/${id}?application=RuleDescription`; -/** - * Make sure that a elements text is "visible" to a screenreader user. - * - * - Inner text that is discernible to screen reader users. - * - Non-empty aria-label attribute. - * - aria-labelledby pointing to element with text which is discernible to screen reader users. - */ -function hasAccessibleText(el: Element): boolean { - if (el.hasAttribute("aria-label")) { - return el.getAttribute("aria-label")!.trim() !== ""; - } - - if (!labelledByIsValid(el)) return false; - - if (el.getAttribute("title")) { - return el.getAttribute("title")!.trim() !== ""; - } - - if (el.textContent) { - return el.textContent.trim() !== ""; - } - - return true; -} - export function ariaTooltipName(el: Element): AccessibilityError[] { const errors = []; const tooltips = querySelectorAll("[role=tooltip]", el); diff --git a/src/utils.ts b/src/utils.ts index c400466..4f9efa4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,31 @@ export function isVisible(el: HTMLElement): boolean { return el.style.display !== "none"; } +/** + * Make sure that a elements text is "visible" to a screenreader user. + * + * - Inner text that is discernible to screen reader users. + * - Non-empty aria-label attribute. + * - aria-labelledby pointing to element with text which is discernible to screen reader users. + */ +export function hasAccessibleText(el: Element): boolean { + if (el.hasAttribute("aria-label")) { + return el.getAttribute("aria-label")!.trim() !== ""; + } + + if (!labelledByIsValid(el)) return false; + + if (el.getAttribute("title")) { + return el.getAttribute("title")!.trim() !== ""; + } + + if (el.textContent) { + return el.textContent.trim() !== ""; + } + + return true; +} + /** * Given a element, make sure that it's `aria-labelledby` has a value and it's * value maps to a element in the DOM that has valid text diff --git a/tests/aria-dialog-name.ts b/tests/aria-dialog-name.ts new file mode 100644 index 0000000..ed76cc8 --- /dev/null +++ b/tests/aria-dialog-name.ts @@ -0,0 +1,53 @@ +import { fixture, expect } from "@open-wc/testing"; +import { Scanner } from "../src/scanner"; +import { ariaDialogName } from "../src/rules/aria-dialog-name"; + +const scanner = new Scanner([ariaDialogName]); + +const passes = [ + `
+ +
My dialog!
+
`, + `
Name
`, + ``, +]; + +const violations = [ + ``, + `
`, + ``, + `
+ +
+
`, +]; + +describe("aria-dialog-name", 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 await (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: "ARIA tooltip must have an accessible name", + url: "https://dequeuniversity.com/rules/axe/4.4/aria-tooltip-name?application=RuleDescription", + }, + ]); + }); + } +}); diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index f113677..ac0d04f 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -8,30 +8,12 @@ import { junitReporter } from "@web/test-runner-junit-reporter"; const browsers = [playwrightLauncher({ product: "chromium" })]; -if (env.CI) { - browsers.push( - playwrightLauncher({ product: "firefox" }), - playwrightLauncher({ product: "webkit" }), - ); -} - -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 +26,22 @@ export default { return true; }, }; + +if (env.CI) { + config.browsers.push( + playwrightLauncher({ product: "firefox" }), + playwrightLauncher({ product: "webkit" }), + ); + + config.reporters = [ + summaryReporter(), + env.CI + ? junitReporter({ + outputPath: "./test-results.xml", + reportLogs: true, + }) + : null, + ]; +} + +export default config;