├── .prettierrc.json
├── .browserslistrc
├── .prettierignore
├── devtools
├── devtools.html
├── manifest.json
├── devtools.js
├── content.js
├── inject.js
└── background.js
├── src
├── ui
│ ├── aligntopleft.svg
│ ├── aligntopright.svg
│ ├── alignbottomleft.svg
│ ├── alignbottomright.svg
│ ├── log.svg
│ ├── showall.svg
│ ├── close.svg
│ ├── help.svg
│ ├── hideall.svg
│ ├── reveal.svg
│ ├── muteall.svg
│ ├── highlighter.css
│ ├── bug.svg
│ ├── domBuilder.ts
│ ├── ui.css
│ └── ui.ts
├── rules
│ ├── notify.ts
│ ├── find.ts
│ ├── existingid.ts
│ ├── atomic.ts
│ ├── badfocus.ts
│ ├── tabindex.ts
│ ├── base.ts
│ ├── nestedInteractive.ts
│ ├── focuslost.ts
│ ├── label.ts
│ ├── contrast.ts
│ └── requiredparent.ts
├── index.ts
├── utils.ts
└── core.ts
├── .gitignore
├── tests
├── pages
│ ├── index.html
│ ├── page1.html
│ ├── contrast.ts
│ ├── contrast.html
│ └── page1.ts
├── tabindex
│ ├── tabindex.ts
│ └── tabindex.html
├── contrastRule
│ ├── contrast.ts
│ ├── contrast.html
│ └── contrast.test.ts
├── labelRule
│ ├── label.ts
│ ├── label.html
│ └── label.test.ts
├── requiredParent
│ ├── requiredParent.ts
│ └── requiredParent.html
├── headlessMode
│ ├── headless.html
│ ├── headless.ts
│ └── headless.test.ts
├── nestedInteractive
│ ├── nestedInteractive.html
│ └── nestedInteractive.ts
└── utils.ts
├── CredScanSuppressions.json
├── CODE_OF_CONDUCT.md
├── playwright.config.ts
├── .github
└── workflows
│ └── ci.yml
├── CODEOWNERS
├── tsconfig.json
├── LICENSE
├── SUPPORT.md
├── vite.config.ts
├── transformsvg.ts
├── tsup.config.ts
├── package.json
├── SECURITY.md
├── azure-pipelines.yml
├── eslint.config.mjs
└── README.md
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.browserslistrc:
--------------------------------------------------------------------------------
1 | defaults
2 | not IE 11
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | dist
3 | node_modules
4 | *.yml
--------------------------------------------------------------------------------
/devtools/devtools.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/ui/aligntopleft.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/aligntopright.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/alignbottomleft.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/ui/alignbottomright.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | tsconfig.tsbuildinfo
4 |
5 | # VScode devcontainer
6 | .devcontainer
7 |
8 | # Playwright
9 | /test-results/
10 | /playwright-report/
11 | /blob-report/
12 | /playwright/.cache/
13 | /logs/
14 |
--------------------------------------------------------------------------------
/tests/pages/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | AbleDOM Test Root
5 |
6 |
7 | Welcome to AbleDOM
8 | This is the root test page.
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/ui/log.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/showall.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/ui/close.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/ui/help.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/tabindex/tabindex.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, TabIndexRule } from "abledom";
7 | import { initIdleProp } from "../utils";
8 |
9 | const ableDOM = new AbleDOM(window);
10 | initIdleProp(ableDOM);
11 | ableDOM.addRule(new TabIndexRule());
12 | ableDOM.start();
13 |
--------------------------------------------------------------------------------
/tests/contrastRule/contrast.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, ContrastRule } from "abledom";
7 | import { initIdleProp } from "../utils";
8 |
9 | const ableDOM = new AbleDOM(window);
10 | initIdleProp(ableDOM);
11 | ableDOM.addRule(new ContrastRule());
12 | ableDOM.start();
13 |
--------------------------------------------------------------------------------
/src/ui/hideall.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/CredScanSuppressions.json:
--------------------------------------------------------------------------------
1 | {
2 | "tool": "Credential Scanner",
3 | "suppressions": [
4 | {
5 | "file": ".git/config",
6 | "_justification": "Standard token for CI pipeline"
7 | },
8 | {
9 | "file": "node_modules/proxy-agent/test/ssl-cert-snakeoil.key",
10 | "_justification": "External dependency proxy-agent, not shipping these files"
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/tests/labelRule/label.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, FocusableElementLabelRule } from "abledom";
7 | import { initIdleProp } from "../utils";
8 |
9 | const ableDOM = new AbleDOM(window);
10 | initIdleProp(ableDOM);
11 | ableDOM.addRule(new FocusableElementLabelRule());
12 | ableDOM.start();
13 |
--------------------------------------------------------------------------------
/tests/requiredParent/requiredParent.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, RequiredParentRule } from "abledom";
7 | import { initIdleProp } from "../utils";
8 |
9 | const ableDOM = new AbleDOM(window);
10 | initIdleProp(ableDOM);
11 | ableDOM.addRule(new RequiredParentRule());
12 | ableDOM.start();
13 |
--------------------------------------------------------------------------------
/src/ui/reveal.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/ui/muteall.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/tests/headlessMode/headless.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Headless Mode
7 |
8 |
9 |
10 | Headless Mode
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/tests/tabindex/tabindex.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Tab Index
7 |
8 |
9 |
10 | Tab Index Rule
11 |
12 |
13 | Flag
14 |
15 |
16 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Microsoft Open Source Code of Conduct
2 |
3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
4 |
5 | Resources:
6 |
7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
10 |
--------------------------------------------------------------------------------
/tests/headlessMode/headless.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, FocusableElementLabelRule } from "abledom";
7 | import { initIdleProp, getAbleDOMCallbacks } from "../utils";
8 |
9 | const ableDOM = new AbleDOM(window, {
10 | headless: true,
11 | callbacks: getAbleDOMCallbacks(),
12 | });
13 | initIdleProp(ableDOM);
14 | ableDOM.addRule(new FocusableElementLabelRule());
15 | ableDOM.start();
16 |
--------------------------------------------------------------------------------
/src/rules/notify.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { ValidationRule, ValidationRuleType } from "./base";
6 |
7 | export class CustomNotifyRule extends ValidationRule {
8 | type = ValidationRuleType.Info;
9 | name = "custom-notify";
10 | anchored = false;
11 |
12 | customNotify(message: string, element?: HTMLElement): void {
13 | this.notify({
14 | id: "custom-notify",
15 | message,
16 | element,
17 | });
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from "@playwright/test";
2 |
3 | export default defineConfig({
4 | testDir: "./tests",
5 | timeout: 30000,
6 | retries: 0,
7 | use: {
8 | baseURL: "http://localhost:5173",
9 | trace: "on-first-retry",
10 | headless: true,
11 | },
12 | projects: [
13 | {
14 | name: "Desktop Chrome",
15 | use: { ...devices["Desktop Chrome"] },
16 | },
17 | {
18 | name: "Desktop Firefox",
19 | use: { ...devices["Desktop Firefox"] },
20 | },
21 | ],
22 | });
23 |
--------------------------------------------------------------------------------
/tests/labelRule/label.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Label Rule
7 |
8 |
9 |
10 | Label Rule
11 |
12 |
13 |
14 | Button3
15 |
16 |
17 |
--------------------------------------------------------------------------------
/tests/nestedInteractive/nestedInteractive.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Test Page 1
6 |
7 |
8 | Test Page 1
9 |
10 |
11 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/tests/pages/page1.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Test Page 1
7 |
8 |
9 | Test Page 1
10 |
11 |
12 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/nestedInteractive/nestedInteractive.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, NestedInteractiveElementRule } from "abledom";
7 |
8 | const ableDOM = new AbleDOM(window, {
9 | bugReport: {
10 | isVisible: (issue) => {
11 | return issue.id === "focusable-element-label";
12 | },
13 | onClick: (issue) => {
14 | alert(issue.id);
15 | },
16 | getTitle(issue) {
17 | return `Custom report bug button title for ${issue.id}`;
18 | },
19 | },
20 | });
21 | ableDOM.addRule(new NestedInteractiveElementRule());
22 |
23 | ableDOM.start();
24 |
--------------------------------------------------------------------------------
/tests/pages/contrast.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { AbleDOM, ContrastRule } from "abledom";
7 |
8 | console.log("Test Page 1 script loaded");
9 |
10 | const ableDOM = new AbleDOM(window, {
11 | bugReport: {
12 | isVisible: (issue) => {
13 | return issue.id === "focusable-element-label";
14 | },
15 | onClick: (issue) => {
16 | alert(issue.id);
17 | },
18 | getTitle(issue) {
19 | return `Custom report bug button title for ${issue.id}`;
20 | },
21 | },
22 | });
23 | ableDOM.addRule(new ContrastRule());
24 |
25 | ableDOM.start();
26 |
--------------------------------------------------------------------------------
/devtools/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "name": "AbleDOM Devtools Extension",
4 | "version": "1.0",
5 | "description": "Integrates AbleDOM with devtools for easier debugging.",
6 | "devtools_page": "devtools.html",
7 | "background": {
8 | "service_worker": "background.js"
9 | },
10 | "permissions": ["scripting", "tabs"],
11 | "host_permissions": [""],
12 | "content_scripts": [
13 | {
14 | "matches": [""],
15 | "js": ["content.js"],
16 | "run_at": "document_start"
17 | }
18 | ],
19 | "web_accessible_resources": [
20 | {
21 | "resources": ["inject.js"],
22 | "matches": [""]
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/src/ui/highlighter.css:
--------------------------------------------------------------------------------
1 | .abledom-highlight {
2 | background-color: yellow;
3 | box-sizing: border-box;
4 | display: none;
5 | opacity: 0.6;
6 | position: fixed;
7 | z-index: 100499;
8 | }
9 |
10 | .abledom-highlight-border1 {
11 | border-top: 2px solid red;
12 | border-bottom: 2px solid red;
13 | box-sizing: border-box;
14 | position: absolute;
15 | top: -2px;
16 | width: calc(100% + 20px);
17 | height: calc(100% + 4px);
18 | margin: 0 -10px;
19 | }
20 |
21 | .abledom-highlight-border2 {
22 | border-left: 2px solid red;
23 | border-right: 2px solid red;
24 | box-sizing: border-box;
25 | position: absolute;
26 | width: calc(100% + 4px);
27 | left: -2px;
28 | height: calc(100% + 20px);
29 | margin: -10px 0;
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | env:
10 | CI: true
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Use Node.js 22
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: 22.x
23 |
24 | - run: npm install
25 | - run: npx playwright install
26 | - run: npm run type-check
27 | - run: npm run build
28 | - run: npm run lint
29 | - run: npm run format
30 | - name: "Run tests"
31 | run: npm run test
32 | - name: "Check for unstaged changes"
33 | run: |
34 | git status --porcelain
35 | git diff-index --quiet HEAD -- || exit 1
36 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # This is a comment.
2 | # Each line is a file pattern followed by one or more owners.
3 |
4 | # These owners will be the default owners for everything in the repo.
5 | # Unless a later match takes precedence, @global-owner1 and @global-owner2
6 | # will be requested for review when someone opens a pull request.
7 | # * @global-owner1 @global-owner2
8 |
9 | # Order is important; the last matching pattern takes the most precedence.
10 | # When someone opens a pull request that only modifies JS files, only @js-owner
11 | # and not the global owner(s) will be requested for a review.
12 | # *.js@js-owner
13 |
14 | # You can also use email addresses if you prefer. They'll be used to look up
15 | # users just like we do for commit author emails.
16 | # docs/ docs@example.com
17 |
18 | #### Meta and License stuff
19 | /LICENSE.md @jurokapsiar
20 |
21 | ### Code
22 | * @microsoft/teams-prg @mshoho
23 |
--------------------------------------------------------------------------------
/src/ui/bug.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/tests/contrastRule/contrast.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Contrast Rule
7 |
8 |
9 |
10 | Contrast Rule
11 |
14 |
17 |
20 |
26 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/tests/contrastRule/contrast.test.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { test, expect } from "@playwright/test";
6 | import { loadTestPage, awaitIdle, getIssuesCount } from "../utils";
7 |
8 | test("Contrast Rule", async ({ page }) => {
9 | await loadTestPage(page, "tests/contrastRule/contrast.html");
10 |
11 | await awaitIdle(page);
12 |
13 | const issues = await getIssuesCount(page);
14 |
15 | // There will be 2 issues:
16 | // - contrast-text-1 (white on white)
17 | // - contrast-text-2 (Bad contrast: not enough contrast #000 on #000)
18 | expect(issues).toBe(2);
19 |
20 | // Make #good-contrast to have bad contrast
21 | await page.evaluate(() => {
22 | const el = document.getElementById("good-contrast");
23 | if (el) {
24 | el.style.color = "#000";
25 | el.style.backgroundColor = "#000";
26 | }
27 | });
28 | await awaitIdle(page);
29 | expect(await getIssuesCount(page)).toBe(3);
30 | });
31 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "moduleResolution": "bundler",
9 | "allowImportingTsExtensions": true,
10 | "resolveJsonModule": true,
11 | "isolatedModules": false,
12 | "noEmit": true,
13 | "strict": true,
14 | "noUnusedLocals": true,
15 | "noUnusedParameters": true,
16 | "noFallthroughCasesInSwitch": true,
17 | "noImplicitAny": true,
18 | "noImplicitReturns": true,
19 | "noImplicitThis": true,
20 | "strictNullChecks": true,
21 | "strictPropertyInitialization": true,
22 | "forceConsistentCasingInFileNames": true,
23 | "baseUrl": ".",
24 | "paths": {
25 | "abledom": ["src/"]
26 | }
27 | },
28 | "include": [
29 | "src",
30 | "tests",
31 | "vite.config.ts",
32 | "tsup.config.ts",
33 | "tests/pages/page1.ts"
34 | ],
35 | "exclude": ["dist", "node_modules"]
36 | }
37 |
--------------------------------------------------------------------------------
/tests/pages/contrast.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Test Page 1
7 |
8 |
9 | Test Page 1
10 |
11 |
12 |
13 |
14 |
24 |
25 |
26 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/rules/find.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { ValidationRule, ValidationResult, ValidationRuleType } from "./base";
6 |
7 | export class FindElementRule extends ValidationRule {
8 | type = ValidationRuleType.Warning;
9 | name = "find-element";
10 | anchored = true;
11 |
12 | private _conditions: { [name: string]: (element: HTMLElement) => boolean } =
13 | {};
14 |
15 | addCondition(
16 | name: string,
17 | condition: (element: HTMLElement) => boolean,
18 | ): void {
19 | this._conditions[name] = condition;
20 | }
21 |
22 | removeCondition(name: string): void {
23 | delete this._conditions[name];
24 | }
25 |
26 | validate(element: HTMLElement): ValidationResult | null {
27 | for (const name of Object.keys(this._conditions)) {
28 | if (this._conditions[name](element)) {
29 | return {
30 | issue: {
31 | id: "find-element",
32 | message: `Element found: ${name}.`,
33 | element,
34 | },
35 | };
36 | }
37 | }
38 |
39 | return null;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | export { AbleDOM } from "./core";
7 | export type { AbleDOMProps } from "./core";
8 | export { ValidationRule, ValidationRuleType } from "./rules/base";
9 | export type {
10 | ValidationResult,
11 | ValidationIssue,
12 | BlurIssue,
13 | } from "./rules/base";
14 | export { AtomicRule } from "./rules/atomic";
15 | export { FocusableElementLabelRule } from "./rules/label";
16 | export { ContrastRule } from "./rules/contrast";
17 | export { ExistingIdRule } from "./rules/existingid";
18 | export { FocusLostRule } from "./rules/focuslost";
19 | export { BadFocusRule } from "./rules/badfocus";
20 | export { FindElementRule } from "./rules/find";
21 | export { CustomNotifyRule } from "./rules/notify";
22 | export { RequiredParentRule } from "./rules/requiredparent";
23 | export { NestedInteractiveElementRule } from "./rules/nestedInteractive";
24 | export { TabIndexRule } from "./rules/tabindex";
25 | export {
26 | isAccessibilityAffectingElement,
27 | hasAccessibilityAttribute,
28 | matchesSelector,
29 | isDisplayNone,
30 | isElementVisible,
31 | } from "./utils";
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Microsoft Corporation.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE
22 |
--------------------------------------------------------------------------------
/SUPPORT.md:
--------------------------------------------------------------------------------
1 | # TODO: The maintainer of this repo has not yet edited this file
2 |
3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project?
4 |
5 | - **No CSS support:** Fill out this template with information about how to file issues and get help.
6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps.
7 | - **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide.
8 |
9 | _Then remove this first heading from this SUPPORT.MD file before publishing your repo._
10 |
11 | # Support
12 |
13 | ## How to file issues and get help
14 |
15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing
16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or
17 | feature request as a new Issue.
18 |
19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE
20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER
21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**.
22 |
23 | ## Microsoft Support Policy
24 |
25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above.
26 |
--------------------------------------------------------------------------------
/devtools/devtools.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | /* global chrome */
7 |
8 | let port;
9 |
10 | function retryPostMessage(msg, retryCount = 0, error = undefined) {
11 | if (retryCount > 3) {
12 | console.error("Failed to post message after 3 retries:", msg, error);
13 | return;
14 | }
15 |
16 | try {
17 | if (!port) {
18 | port = chrome.runtime.connect({ name: "devtools" });
19 | port.onMessage.addListener(listenerFunction);
20 | }
21 |
22 | port.postMessage(msg);
23 | } catch (e) {
24 | port = null;
25 | retryPostMessage(msg, retryCount + 1, e);
26 | }
27 | }
28 |
29 | retryPostMessage({
30 | type: "init",
31 | tabId: chrome.devtools.inspectedWindow.tabId,
32 | });
33 |
34 | function listenerFunction(msg) {
35 | if (msg.type === "reveal" && msg.elementId) {
36 | chrome.devtools.inspectedWindow.eval(`
37 | (() => {
38 | const el = window.__ableDOMDevtools?.revealRegistry?.["${msg.elementId}"];
39 | if (el) {
40 | inspect(el);
41 | }
42 | delete window.__ableDOMDevtools?.revealRegistry?.["${msg.elementId}"];
43 | })()
44 | `);
45 |
46 | retryPostMessage({
47 | type: "revealed",
48 | tabId: chrome.devtools.inspectedWindow.tabId,
49 | });
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { defineConfig } from "vite";
7 | import { transformSVG } from "./transformsvg";
8 |
9 | export default defineConfig({
10 | define: {
11 | "process.env.PKG_VERSION": JSON.stringify("local"),
12 | },
13 | resolve: {
14 | alias: {
15 | abledom: "/src",
16 | },
17 | },
18 | server: {
19 | port: 5173,
20 | },
21 | build: {
22 | rollupOptions: {},
23 | },
24 | plugins: [
25 | {
26 | name: "postprocess-raw",
27 | enforce: "post",
28 | transform(code, id) {
29 | if (id.endsWith(".svg?raw")) {
30 | // Your postprocessing logic here
31 | // const processed = code.replace(/body/g, 'html body'); // Example
32 | return {
33 | code: `import {DOMBuilder} from "./domBuilder"; export default ${transformSVG(code)};`,
34 | map: null,
35 | };
36 | }
37 | return undefined;
38 | },
39 | },
40 | {
41 | name: "custom-index-html",
42 | configureServer(server) {
43 | server.middlewares.use((req, _, next) => {
44 | if (req.url === "/" || req.url === "/index.html") {
45 | req.url = "/tests/pages/index.html";
46 | }
47 | next();
48 | });
49 | },
50 | },
51 | ],
52 | });
53 |
--------------------------------------------------------------------------------
/transformsvg.ts:
--------------------------------------------------------------------------------
1 | import { DOMParser, Element, Node } from "@xmldom/xmldom";
2 |
3 | export function transformSVG(svgText: string): string {
4 | if (svgText.startsWith('export default "')) {
5 | svgText = JSON.parse(svgText.substring("export default ".length));
6 | }
7 |
8 | const doc = new DOMParser().parseFromString(svgText, "image/svg+xml");
9 | const ret: string[] = [];
10 |
11 | ret.push("(function buildSVG(parent) {");
12 | ret.push("const builder = new DOMBuilder(parent);");
13 |
14 | function traverse(node: Element): void {
15 | const attributes: Record = {};
16 |
17 | for (const attr of Array.from(node.attributes)) {
18 | attributes[attr.name] = attr.value;
19 | }
20 |
21 | ret.push(
22 | `builder.openTag("${node.tagName}", ${JSON.stringify(attributes)}, undefined, "http://www.w3.org/2000/svg");`,
23 | );
24 |
25 | for (const child of Array.from(node.childNodes)) {
26 | if (child.nodeType === Node.ELEMENT_NODE) {
27 | traverse(child as Element);
28 | } else if (
29 | child.nodeType === Node.TEXT_NODE &&
30 | child.textContent?.trim()
31 | ) {
32 | ret.push(`builder.text(${JSON.stringify(child.textContent.trim())});`);
33 | }
34 | }
35 |
36 | ret.push("builder.closeTag();");
37 | }
38 |
39 | const root = doc.documentElement;
40 |
41 | if (root) {
42 | traverse(root);
43 | }
44 |
45 | ret.push("return parent.firstElementChild; });");
46 |
47 | return ret.join("\n");
48 | }
49 |
--------------------------------------------------------------------------------
/src/rules/existingid.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { ValidationRule, ValidationResult, ValidationRuleType } from "./base";
7 |
8 | export class ExistingIdRule extends ValidationRule {
9 | type = ValidationRuleType.Error;
10 | name = "existing-id";
11 | anchored = true;
12 |
13 | accept(element: HTMLElement): boolean {
14 | return (
15 | element.hasAttribute("aria-labelledby") ||
16 | element.hasAttribute("aria-describedby") ||
17 | (element.tagName === "LABEL" && !!(element as HTMLLabelElement).htmlFor)
18 | );
19 | }
20 |
21 | validate(element: HTMLElement): ValidationResult | null {
22 | const ids = [
23 | ...(element.getAttribute("aria-labelledby")?.split(" ") || []),
24 | ...(element.getAttribute("aria-describedby")?.split(" ") || []),
25 | ...(element.tagName === "LABEL"
26 | ? [(element as HTMLLabelElement).htmlFor]
27 | : []),
28 | ].filter((id) => !!id);
29 |
30 | if (ids.length === 0) {
31 | return null;
32 | }
33 |
34 | for (const id of ids) {
35 | if (element.ownerDocument.getElementById(id)) {
36 | return {
37 | dependsOnIds: new Set(ids),
38 | };
39 | }
40 | }
41 |
42 | return {
43 | issue: {
44 | id: "missing-id",
45 | message: `Elements with referenced ids do not extist.`,
46 | element,
47 | },
48 | dependsOnIds: new Set(ids),
49 | };
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/requiredParent/requiredParent.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Required Parent ( Invalid HTML Structure )
7 |
8 |
9 |
10 | Required Parent ( Invalid HTML Structure )
11 |
12 | List Item without a parent list
13 | Definition Term without a parent dl
14 | Definition Description without a parent dl
15 |
16 |
17 |
19 |
20 |
21 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/devtools/content.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | /* global chrome */
7 |
8 | const script = document.createElement("script");
9 | script.src = chrome.runtime.getURL("inject.js");
10 | script.type = "text/javascript";
11 | script.onload = () => script.remove();
12 | (document.head || document.documentElement).appendChild(script);
13 |
14 | // Connect to background
15 | let port; // = chrome.runtime.connect({ name: 'content' });
16 |
17 | function retryPostMessage(msg, retryCount = 0, error = undefined) {
18 | if (retryCount > 3) {
19 | console.error("Failed to post message after 3 retries:", msg, error);
20 | return;
21 | }
22 |
23 | try {
24 | if (!port) {
25 | port = chrome.runtime.connect({ name: "content" });
26 | port.onMessage.addListener(listenerFunction);
27 | }
28 |
29 | port.postMessage(msg);
30 | } catch (e) {
31 | port = null;
32 | retryPostMessage(msg, retryCount + 1, e);
33 | }
34 | }
35 |
36 | window.addEventListener("abledom:reveal-element", (e) => {
37 | const { elementId } = e.detail;
38 | retryPostMessage({ type: "reveal", elementId });
39 | });
40 |
41 | setInterval(() => {
42 | retryPostMessage({ type: "ping" });
43 | }, 3000);
44 |
45 | function listenerFunction(msg /*, sender, sendResponse*/) {
46 | // console.error(11111, msg);
47 |
48 | if (msg.type === "ololo") {
49 | window.dispatchEvent(
50 | new CustomEvent("ololo", {
51 | detail: msg.payload,
52 | }),
53 | );
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/tests/pages/page1.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import {
7 | AbleDOM,
8 | AtomicRule,
9 | FocusableElementLabelRule,
10 | ExistingIdRule,
11 | FocusLostRule,
12 | } from "abledom";
13 |
14 | console.log("Test Page 1 script loaded");
15 |
16 | const ableDOM = new AbleDOM(window, {
17 | bugReport: {
18 | isVisible: (issue) => {
19 | return issue.id === "focusable-element-label";
20 | },
21 | onClick: (issue) => {
22 | alert(issue.id);
23 | },
24 | getTitle(issue) {
25 | return `Custom report bug button title for ${issue.id}`;
26 | },
27 | },
28 | });
29 | ableDOM.addRule(new FocusableElementLabelRule());
30 | ableDOM.addRule(new AtomicRule());
31 | ableDOM.addRule(new ExistingIdRule());
32 | ableDOM.addRule(new FocusLostRule());
33 | ableDOM.start();
34 |
35 | const b = document.createElement("button");
36 | b.innerText = "Click me";
37 | b.setAttribute("aria-label", "Piupiu");
38 | b.title = "Tititt";
39 | b.setAttribute("aria-labelledby", "labelledby");
40 | b.setAttribute("aria-hidden", "true");
41 |
42 | const img = document.createElement("img");
43 | img.src = "aaa";
44 | img.alt = "Alt";
45 | img.setAttribute("hidden", "");
46 |
47 | const i = document.createElement("input");
48 | i.type = "submit";
49 | i.value = "Input";
50 | // i.setAttribute("aria-hidden", "true");
51 |
52 | document.getElementById("button")?.appendChild(b);
53 | // document.getElementById("button")?.appendChild(img);
54 | document.getElementById("button")?.appendChild(i);
55 |
--------------------------------------------------------------------------------
/devtools/inject.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | (function () {
7 | // Minimal registry to map elements without polluting them
8 | const registry = {};
9 | let counter = 0;
10 | let currentResolve = undefined;
11 | let currentResolveTimeout = undefined;
12 |
13 | window.__ableDOMDevtools = {};
14 | window.__ableDOMDevtools.revealRegistry = registry;
15 |
16 | window.__ableDOMDevtools.revealElement = function (element) {
17 | if (!(element instanceof Element)) {
18 | return;
19 | }
20 | const id = "el_" + ++counter;
21 | registry[id] = element;
22 |
23 | window.dispatchEvent(
24 | new CustomEvent("abledom:reveal-element", {
25 | detail: { elementId: id },
26 | }),
27 | );
28 |
29 | return new Promise((resolve) => {
30 | if (currentResolveTimeout) {
31 | clearTimeout(currentResolveTimeout);
32 | currentResolveTimeout = undefined;
33 | }
34 |
35 | if (currentResolve) {
36 | currentResolve(true);
37 | currentResolve = undefined;
38 | }
39 |
40 | currentResolve = resolve;
41 |
42 | currentResolveTimeout = setTimeout(() => {
43 | currentResolveTimeout = undefined;
44 | currentResolve = undefined;
45 |
46 | resolve(false);
47 | }, 200);
48 | });
49 | };
50 |
51 | window.addEventListener("ololo", () => {
52 | if (currentResolveTimeout) {
53 | clearTimeout(currentResolveTimeout);
54 | currentResolveTimeout = undefined;
55 | }
56 |
57 | if (currentResolve) {
58 | currentResolve(true);
59 | currentResolve = undefined;
60 | }
61 | });
62 | })();
63 |
--------------------------------------------------------------------------------
/src/rules/atomic.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { focusableElementSelector, matchesSelector } from "../utils";
6 | import { ValidationRule, ValidationResult, ValidationRuleType } from "./base";
7 |
8 | export class AtomicRule extends ValidationRule {
9 | type = ValidationRuleType.Error;
10 | name = "atomic";
11 | anchored = true;
12 |
13 | accept(element: HTMLElement): boolean {
14 | return matchesSelector(element, focusableElementSelector);
15 | }
16 |
17 | validate(element: HTMLElement): ValidationResult | null {
18 | const parentAtomic = element.ownerDocument
19 | .evaluate(
20 | `ancestor::*[
21 | @role = 'button' or
22 | @role = 'checkbox' or
23 | @role = 'link' or
24 | @role = 'menuitem' or
25 | @role = 'menuitemcheckbox' or
26 | @role = 'menuitemradio' or
27 | @role = 'option' or
28 | @role = 'radio' or
29 | @role = 'switch' or
30 | @role = 'tab' or
31 | @role = 'treeitem' or
32 | self::a or
33 | self::button or
34 | self::input or
35 | self::option or
36 | self::textarea
37 | ][1]`,
38 | element,
39 | null,
40 | XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
41 | null,
42 | )
43 | .snapshotItem(0);
44 |
45 | if (parentAtomic) {
46 | return {
47 | issue: {
48 | id: "focusable-in-atomic",
49 | message: "Focusable element inside atomic focusable.",
50 | element,
51 | rel: parentAtomic,
52 | },
53 | };
54 | }
55 |
56 | return null;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/rules/badfocus.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { isElementVisible, getStackTrace } from "../utils";
7 | import { ValidationRule, ValidationRuleType } from "./base";
8 |
9 | export class BadFocusRule extends ValidationRule {
10 | type = ValidationRuleType.Error;
11 | name = "bad-focus";
12 | anchored = false;
13 |
14 | private _lastFocusStack: string[] | undefined;
15 | private _lastBlurStack: string[] | undefined;
16 | private _clearCheckTimer: (() => void) | undefined;
17 |
18 | focused(): null {
19 | this._lastFocusStack = getStackTrace();
20 | return null;
21 | }
22 |
23 | blurred(): null {
24 | const win = this.window;
25 |
26 | if (!win) {
27 | return null;
28 | }
29 |
30 | const doc = win.document;
31 |
32 | this._lastBlurStack = getStackTrace();
33 |
34 | this._clearCheckTimer?.();
35 |
36 | const checkTimer = win.setTimeout(() => {
37 | delete this._clearCheckTimer;
38 |
39 | if (
40 | doc.activeElement &&
41 | !isElementVisible(doc.activeElement as HTMLElement)
42 | ) {
43 | this.notify({
44 | id: "bad-focus",
45 | message: "Focused stolen by invisible element.",
46 | element: doc.activeElement as HTMLElement,
47 | stack: this._lastBlurStack,
48 | relStack: this._lastFocusStack,
49 | });
50 | }
51 | }, 100);
52 |
53 | this._clearCheckTimer = () => {
54 | delete this._clearCheckTimer;
55 | win.clearTimeout(checkTimer);
56 | };
57 |
58 | return null;
59 | }
60 |
61 | stop(): void {
62 | this._clearCheckTimer?.();
63 | this._clearCheckTimer = undefined;
64 | this._lastFocusStack = undefined;
65 | this._lastBlurStack = undefined;
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import fs from "fs";
7 | import path from "path";
8 | import { defineConfig, Options } from "tsup";
9 | import type { Plugin } from "esbuild";
10 | import pkg from "./package.json";
11 | import { transformSVG } from "./transformsvg";
12 |
13 | type TSUPPlugin = NonNullable[number];
14 | const disablePlugin = (name: string): TSUPPlugin => ({
15 | name: `disable-plugin-${name}`,
16 | esbuildOptions: (options) => {
17 | const plugin = options.plugins?.find(({ name }) => name === "postcss");
18 | if (plugin) {
19 | plugin.setup = () => Promise.resolve();
20 | }
21 | },
22 | });
23 |
24 | const inlineRawPlugin: Plugin = {
25 | name: "inline-raw-plugin",
26 | setup(build) {
27 | build.onResolve({ filter: /\?raw$|\?inline$/ }, (args) => {
28 | const cleanPath = args.path.replace(/\?raw$|\?inline$/, "");
29 | const fullPath = path.resolve(args.resolveDir, cleanPath);
30 | return { path: fullPath, namespace: "inline-file" };
31 | });
32 |
33 | build.onLoad({ filter: /.*/, namespace: "inline-file" }, async (args) => {
34 | const contents = await fs.promises.readFile(args.path, "utf-8");
35 | return {
36 | contents: args.path.endsWith(".svg")
37 | ? `import {DOMBuilder} from "./domBuilder"; export default ${transformSVG(contents)};`
38 | : `export default ${JSON.stringify(contents)};`,
39 | loader: "ts",
40 | resolveDir: path.dirname(args.path),
41 | };
42 | });
43 | },
44 | };
45 |
46 | export default defineConfig({
47 | entry: ["src/index.ts"],
48 | format: ["cjs", "esm"],
49 | target: "es2019",
50 | legacyOutput: true,
51 |
52 | env: {
53 | PKG_VERSION: pkg.version,
54 | },
55 |
56 | clean: true,
57 | dts: true,
58 | splitting: false,
59 | sourcemap: true,
60 |
61 | esbuildPlugins: [inlineRawPlugin],
62 |
63 | plugins: [disablePlugin("postcss")],
64 | });
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "abledom",
3 | "version": "0.6.1",
4 | "author": "Marat Abdullin ",
5 | "description": "Continuous detection of typical web application accessibility problems.",
6 | "license": "MIT",
7 | "type": "module",
8 | "sideEffects": false,
9 | "main": "./dist/index.js",
10 | "module": "./dist/esm/index.js",
11 | "typings": "./dist/index.d.ts",
12 | "typesVersions": {
13 | "<4.0": {
14 | "dist/index.d.ts": [
15 | "dist/ts3.9/index.d.ts"
16 | ]
17 | }
18 | },
19 | "files": [
20 | "dist"
21 | ],
22 | "scripts": {
23 | "dev": "vite",
24 | "build": "tsup && npx downlevel-dts ./dist ./dist/ts3.9",
25 | "clean": "rimraf dist",
26 | "format": "prettier --check .",
27 | "format:fix": "prettier --write .",
28 | "lint": "eslint src/ tests/ devtools/",
29 | "lint:fix": "npm run lint -- --fix",
30 | "type-check": "tsc -b tsconfig.json",
31 | "prepublishOnly": "npm run lint && npm run format && npm run build",
32 | "release": "release-it",
33 | "test": "start-server-and-test dev http://localhost:5173 'playwright test'"
34 | },
35 | "repository": {
36 | "type": "git",
37 | "url": "git+https://github.com/microsoft/abledom.git"
38 | },
39 | "bugs": {
40 | "url": "https://github.com/microsoft/abledom/issues"
41 | },
42 | "homepage": "https://github.com/microsoft/abledom#readme",
43 | "devDependencies": {
44 | "@playwright/test": "^1.56.1",
45 | "@types/node": "^24.9.2",
46 | "@typescript-eslint/eslint-plugin": "^8.46.2",
47 | "@typescript-eslint/parser": "^8.46.2",
48 | "@xmldom/xmldom": "^0.9.8",
49 | "downlevel-dts": "^0.11.0",
50 | "esbuild-plugin-inline-import": "^1.1.0",
51 | "eslint": "^9.38.0",
52 | "eslint-config-prettier": "^10.1.8",
53 | "eslint-plugin-header": "^3.1.1",
54 | "eslint-plugin-import": "^2.32.0",
55 | "playwright": "^1.56.1",
56 | "prettier": "^3.6.2",
57 | "release-it": "^19.0.5",
58 | "rimraf": "^6.1.0",
59 | "start-server-and-test": "^2.1.2",
60 | "tsup": "^8.5.0",
61 | "typescript": "^5.9.3",
62 | "vite": "^7.1.12"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/devtools/background.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | /* global chrome */
7 |
8 | const portsByTab = {}; // { [tabId]: { devtools, content } }
9 |
10 | chrome.runtime.onConnect.addListener((port) => {
11 | if (port.name === "devtools") {
12 | let currentTabId = null;
13 |
14 | port.onMessage.addListener((msg) => {
15 | if (msg.type === "init" && msg.tabId != null) {
16 | currentTabId = msg.tabId;
17 | if (!portsByTab[currentTabId]) {
18 | portsByTab[currentTabId] = {};
19 | }
20 | portsByTab[currentTabId].devtools = port;
21 | }
22 |
23 | if (msg.type === "revealed" && portsByTab[msg.tabId]?.content) {
24 | // console.error(9394994, portsByTab[msg.tabId].content)
25 | portsByTab[msg.tabId].content.postMessage({
26 | type: "ololo",
27 | payload: { message: "Hi from background!" },
28 | });
29 | }
30 | });
31 |
32 | port.onDisconnect.addListener(() => {
33 | if (currentTabId && portsByTab[currentTabId]?.devtools === port) {
34 | portsByTab[currentTabId].devtools = null;
35 | }
36 | });
37 | } else if (port.name === "content") {
38 | const tabId = port.sender?.tab?.id;
39 | if (tabId == null) {
40 | return;
41 | }
42 |
43 | if (!portsByTab[tabId]) {
44 | portsByTab[tabId] = {};
45 | }
46 | portsByTab[tabId].content = port;
47 |
48 | port.onMessage.addListener((msg) => {
49 | if (msg.type === "reveal" && portsByTab[tabId]?.devtools) {
50 | portsByTab[tabId].devtools.postMessage(msg);
51 | }
52 | });
53 |
54 | port.onDisconnect.addListener(() => {
55 | if (portsByTab[tabId]?.content === port) {
56 | portsByTab[tabId].content = null;
57 | }
58 | });
59 | }
60 | });
61 |
62 | // Inject into page when DevTools opens
63 | chrome.runtime.onMessage.addListener((msg) => {
64 | if (msg.type === "devtools-opened" && msg.tabId) {
65 | chrome.scripting.executeScript({
66 | target: { tabId: msg.tabId },
67 | files: ["inject.js"],
68 | });
69 | }
70 | });
71 |
--------------------------------------------------------------------------------
/tests/labelRule/label.test.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { test, expect } from "@playwright/test";
6 | import { loadTestPage, awaitIdle, getIssuesCount } from "../utils";
7 |
8 | test("Label Rule", async ({ page }) => {
9 | await loadTestPage(page, "tests/labelRule/label.html");
10 |
11 | expect(await getIssuesCount(page)).toBe(0);
12 |
13 | await page.evaluate(() => {
14 | const btn = document.getElementById("button-1");
15 | if (btn) {
16 | btn.innerText = "";
17 | }
18 | });
19 | await page.evaluate(() => {
20 | const btn = document.getElementById("button-2");
21 | if (btn) {
22 | btn.title = "";
23 | }
24 | });
25 | await page.evaluate(() => {
26 | const label = document.getElementById("button-3-label");
27 | if (label) {
28 | label.innerText = "";
29 | }
30 | });
31 |
32 | expect(await awaitIdle(page)).toEqual([
33 | {
34 | element: '',
35 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
36 | id: "focusable-element-label",
37 | message: "Focusable element must have a non-empty text label.",
38 | },
39 | {
40 | element: '',
41 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
42 | id: "focusable-element-label",
43 | message: "Focusable element must have a non-empty text label.",
44 | },
45 | {
46 | element:
47 | '',
48 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
49 | id: "focusable-element-label",
50 | message: "Focusable element must have a non-empty text label.",
51 | },
52 | ]);
53 |
54 | expect(await getIssuesCount(page)).toBe(3);
55 |
56 | await page.evaluate(() => {
57 | const btn = document.getElementById("button-1");
58 | if (btn) {
59 | btn.innerText = "Button1";
60 | }
61 | });
62 | await page.evaluate(() => {
63 | const btn = document.getElementById("button-2");
64 | if (btn) {
65 | btn.title = "Button2";
66 | }
67 | });
68 | await page.evaluate(() => {
69 | const label = document.getElementById("button-3-label");
70 | if (label) {
71 | label.innerText = "Button3";
72 | }
73 | });
74 |
75 | expect(await awaitIdle(page)).toEqual([]);
76 |
77 | expect(await getIssuesCount(page)).toBe(0);
78 | });
79 |
--------------------------------------------------------------------------------
/tests/headlessMode/headless.test.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { test, expect } from "@playwright/test";
6 | import {
7 | loadTestPage,
8 | awaitIdle,
9 | getIssuesCount,
10 | getIssuesFromCallbacks,
11 | } from "../utils";
12 |
13 | test("Headless Mode", async ({ page }) => {
14 | await loadTestPage(page, "tests/headlessMode/headless.html");
15 |
16 | expect(await getIssuesCount(page)).toBe(0);
17 |
18 | await page.evaluate(() => {
19 | const btn = document.getElementById("button-1");
20 | if (btn) {
21 | btn.innerText = "";
22 | }
23 | });
24 | await page.evaluate(() => {
25 | const btn = document.getElementById("button-2");
26 | if (btn) {
27 | btn.title = "";
28 | }
29 | });
30 |
31 | expect(await awaitIdle(page)).toEqual([
32 | {
33 | element: '',
34 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
35 | id: "focusable-element-label",
36 | message: "Focusable element must have a non-empty text label.",
37 | },
38 | {
39 | element: '',
40 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
41 | id: "focusable-element-label",
42 | message: "Focusable element must have a non-empty text label.",
43 | },
44 | ]);
45 |
46 | expect(await getIssuesCount(page)).toBe(0);
47 | expect((await getIssuesFromCallbacks(page)).length).toBe(2);
48 |
49 | await page.evaluate(() => {
50 | const btn = document.getElementById("button-1");
51 | if (btn) {
52 | btn.innerText = "Button1";
53 | }
54 | });
55 |
56 | expect(await awaitIdle(page)).toEqual([
57 | {
58 | element: '',
59 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
60 | id: "focusable-element-label",
61 | message: "Focusable element must have a non-empty text label.",
62 | },
63 | ]);
64 |
65 | expect(await getIssuesCount(page)).toBe(0);
66 | expect((await getIssuesFromCallbacks(page)).length).toBe(1);
67 |
68 | await page.evaluate(() => {
69 | const btn = document.getElementById("button-2");
70 | if (btn) {
71 | btn.title = "Button2";
72 | }
73 | });
74 |
75 | expect(await awaitIdle(page)).toEqual([]);
76 |
77 | expect(await getIssuesCount(page)).toBe(0);
78 | expect((await getIssuesFromCallbacks(page)).length).toBe(0);
79 | });
80 |
--------------------------------------------------------------------------------
/src/ui/domBuilder.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | export interface HTMLElementWithAbleDOMUIFlag extends HTMLElement {
7 | // A flag to quickly test that the element should be ignored by the validator.
8 | __abledomui?: boolean;
9 | }
10 |
11 | export interface TextNodeWithAbleDOMUIFlag extends Text {
12 | // A flag to quickly test that the element should be ignored by the validator.
13 | __abledomui?: boolean;
14 | }
15 |
16 | export class DOMBuilder {
17 | private _doc: Document | undefined;
18 | private _stack: (HTMLElement | DocumentFragment)[];
19 |
20 | constructor(parent: HTMLElement | DocumentFragment) {
21 | this._doc = parent.ownerDocument;
22 | this._stack = [parent];
23 | }
24 |
25 | openTag(
26 | tagName: string,
27 | attributes?: Record,
28 | callback?: (element: HTMLElement) => void,
29 | namespace?: string,
30 | ): DOMBuilder {
31 | const parent = this._stack[0];
32 | const element = (
33 | namespace
34 | ? this._doc?.createElementNS(namespace, tagName)
35 | : this._doc?.createElement(tagName)
36 | ) as HTMLElementWithAbleDOMUIFlag;
37 |
38 | if (parent && element) {
39 | element.__abledomui = true;
40 |
41 | if (attributes) {
42 | for (const [key, value] of Object.entries(attributes)) {
43 | if (key === "class") {
44 | element.className = value;
45 | } else if (key === "style") {
46 | element.style.cssText = value;
47 | } else {
48 | element.setAttribute(key, value);
49 | }
50 | }
51 | }
52 |
53 | if (callback) {
54 | callback(element);
55 | }
56 |
57 | parent.appendChild(element);
58 |
59 | this._stack.unshift(element);
60 | }
61 |
62 | return this;
63 | }
64 |
65 | closeTag(): DOMBuilder {
66 | if (this._stack.length <= 1) {
67 | throw new Error("Nothing to close");
68 | }
69 |
70 | this._stack.shift();
71 |
72 | return this;
73 | }
74 |
75 | text(text: string): DOMBuilder {
76 | const textNode: TextNodeWithAbleDOMUIFlag | undefined =
77 | this._doc?.createTextNode(text);
78 |
79 | if (textNode) {
80 | textNode.__abledomui = true;
81 | this._stack[0]?.appendChild(textNode);
82 | }
83 |
84 | return this;
85 | }
86 |
87 | element(
88 | callback: (element: HTMLElement | DocumentFragment) => void,
89 | ): DOMBuilder {
90 | callback(this._stack[0]);
91 | return this;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Security
4 |
5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin).
6 |
7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below.
8 |
9 | ## Reporting Security Issues
10 |
11 | **Please do not report security vulnerabilities through public GitHub issues.**
12 |
13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report).
14 |
15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp).
16 |
17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc).
18 |
19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue:
20 |
21 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.)
22 | - Full paths of source file(s) related to the manifestation of the issue
23 | - The location of the affected source code (tag/branch/commit or direct URL)
24 | - Any special configuration required to reproduce the issue
25 | - Step-by-step instructions to reproduce the issue
26 | - Proof-of-concept or exploit code (if possible)
27 | - Impact of the issue, including how an attacker might exploit the issue
28 |
29 | This information will help us triage your report more quickly.
30 |
31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs.
32 |
33 | ## Preferred Languages
34 |
35 | We prefer all communications to be in English.
36 |
37 | ## Policy
38 |
39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd).
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/rules/tabindex.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import { ValidationRule, ValidationResult, ValidationRuleType } from "./base";
6 |
7 | const INTERACTIVE_ELEMENTS = new Set([
8 | "A",
9 | "BUTTON",
10 | "INPUT",
11 | "SELECT",
12 | "TEXTAREA",
13 | "DETAILS",
14 | "SUMMARY",
15 | "AUDIO",
16 | "VIDEO",
17 | ]);
18 |
19 | const INTERACTIVE_ROLES = new Set([
20 | "button",
21 | "link",
22 | "checkbox",
23 | "radio",
24 | "textbox",
25 | "combobox",
26 | "listbox",
27 | "menu",
28 | "menubar",
29 | "menuitem",
30 | "menuitemcheckbox",
31 | "menuitemradio",
32 | "option",
33 | "searchbox",
34 | "slider",
35 | "spinbutton",
36 | "switch",
37 | "tab",
38 | "tablist",
39 | "tree",
40 | "treegrid",
41 | "treeitem",
42 | "grid",
43 | "gridcell",
44 | ]);
45 |
46 | export class TabIndexRule extends ValidationRule {
47 | type = ValidationRuleType.Warning;
48 | name = "tabindex";
49 | anchored = true;
50 |
51 | accept(element: HTMLElement): boolean {
52 | return element.hasAttribute("tabindex");
53 | }
54 |
55 | private isInteractiveElement(element: HTMLElement): boolean {
56 | if (INTERACTIVE_ELEMENTS.has(element.tagName)) {
57 | if (element.hasAttribute("disabled")) {
58 | return false;
59 | }
60 |
61 | if (element.tagName === "A" && !element.hasAttribute("href")) {
62 | return false;
63 | }
64 | return true;
65 | }
66 |
67 | const role = element.getAttribute("role");
68 | if (role && INTERACTIVE_ROLES.has(role)) {
69 | return true;
70 | }
71 |
72 | if (element.isContentEditable) {
73 | return true;
74 | }
75 |
76 | return false;
77 | }
78 |
79 | validate(element: HTMLElement): ValidationResult | null {
80 | const tabindex = parseInt(element.getAttribute("tabindex") || "0", 10);
81 |
82 | if (tabindex > 0 && this.isInteractiveElement(element)) {
83 | return {
84 | issue: {
85 | id: "tabindex",
86 | message: `Avoid positive tabindex values (found: ${tabindex})`,
87 | element,
88 | help: "https://dequeuniversity.com/rules/axe/4.2/tabindex",
89 | },
90 | };
91 | }
92 |
93 | if (!this.isInteractiveElement(element)) {
94 | return {
95 | issue: {
96 | id: "tabindex-non-interactive",
97 | message: `Avoid using tabindex on non-interactive elements (<${element.tagName.toLowerCase()}>). Consider adding an interactive role or making the element naturally interactive.`,
98 | element,
99 | help: "https://dequeuniversity.com/rules/axe/4.2/tabindex",
100 | },
101 | };
102 | }
103 |
104 | return null;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/rules/base.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | export enum ValidationRuleType {
7 | Error = 1,
8 | Warning,
9 | Info,
10 | }
11 |
12 | export interface ValidationIssue {
13 | id: string;
14 | message: string;
15 | element?: HTMLElement;
16 | rel?: Node;
17 | help?: string;
18 | stack?: string[];
19 | relStack?: string[];
20 | }
21 |
22 | export interface ValidationResult {
23 | issue?: ValidationIssue;
24 | dependsOnIds?: Set;
25 | }
26 |
27 | export interface BlurIssue extends ValidationIssue {
28 | position?: string[];
29 | }
30 |
31 | export abstract class ValidationRule<
32 | N extends ValidationIssue = ValidationIssue,
33 | > {
34 | abstract type: ValidationRuleType;
35 | abstract name: string;
36 | private _window?: Window;
37 | private _exceptions: ((element: HTMLElement) => boolean)[] = [];
38 | private _onIssue: ((rule: ValidationRule, issue: N) => void) | undefined;
39 |
40 | static init(
41 | instance: ValidationRule,
42 | window: Window,
43 | onIssue: (rule: ValidationRule, issue: ValidationIssue) => void,
44 | ): void {
45 | instance._window = window;
46 | instance._onIssue = onIssue;
47 | }
48 |
49 | static dispose(instance: ValidationRule): void {
50 | instance.dispose();
51 | }
52 |
53 | static checkExceptions(
54 | instance: ValidationRule,
55 | element: HTMLElement,
56 | ): boolean {
57 | for (const exception of instance._exceptions) {
58 | if (exception(element)) {
59 | return true;
60 | }
61 | }
62 |
63 | return false;
64 | }
65 |
66 | private dispose(): void {
67 | this._window = undefined;
68 | this._onIssue = undefined;
69 | this._exceptions = [];
70 | }
71 |
72 | addException(checkException: (element: HTMLElement) => boolean): void {
73 | this._exceptions?.push(checkException);
74 | }
75 |
76 | removeException(checkException: (element: HTMLElement) => boolean): void {
77 | const index = this._exceptions.indexOf(checkException);
78 |
79 | if (index >= 0) {
80 | this._exceptions.splice(index, 1);
81 | }
82 | }
83 |
84 | /**
85 | * If true, the rule violation will be anchored to the currently present
86 | * in DOM element it is applied to, otherwise the error message will show
87 | * till it is dismissed.
88 | */
89 | abstract anchored: boolean;
90 |
91 | /**
92 | * Window is set when the rule is added to the AbleDOM instance.
93 | */
94 | get window(): Window | undefined {
95 | return this._window;
96 | }
97 |
98 | // Called when the parent AbleDOM instance is started.
99 | start?(): void;
100 | // Called when the parent AbleDOM instance is stopped.
101 | stop?(): void;
102 |
103 | // Called before validation. If returns false, the rule will not be applied to
104 | // the element.
105 | accept?(element: HTMLElement): boolean;
106 |
107 | validate?(element: HTMLElement): ValidationResult | null;
108 |
109 | notify(issue: N): void {
110 | this._onIssue?.(this, issue);
111 | }
112 |
113 | focused?(event: FocusEvent): ValidationIssue | null;
114 | blurred?(event: FocusEvent): BlurIssue | null;
115 | }
116 |
--------------------------------------------------------------------------------
/src/rules/nestedInteractive.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { matchesSelector, isElementVisible } from "../utils";
7 | import { ValidationRule, ValidationResult, ValidationRuleType } from "./base";
8 |
9 | const interactiveElementSelector = [
10 | "a[href]",
11 | "button",
12 | "input:not([type='hidden'])",
13 | "select",
14 | "textarea",
15 | "details",
16 | "audio[controls]",
17 | "video[controls]",
18 | "*[role='button']",
19 | "*[role='link']",
20 | "*[role='checkbox']",
21 | "*[role='radio']",
22 | "*[role='switch']",
23 | "*[role='tab']",
24 | "*[role='menuitem']",
25 | "*[role='menuitemcheckbox']",
26 | "*[role='menuitemradio']",
27 | "*[role='option']",
28 | "*[role='treeitem']",
29 | ].join(", ");
30 |
31 | export class NestedInteractiveElementRule extends ValidationRule {
32 | type = ValidationRuleType.Error;
33 | name = "NestedInteractiveElementRule";
34 | anchored = true;
35 |
36 | private _isAriaHidden(element: HTMLElement): boolean {
37 | return element.ownerDocument.evaluate(
38 | `ancestor-or-self::*[@aria-hidden = 'true' or @hidden]`,
39 | element,
40 | null,
41 | XPathResult.BOOLEAN_TYPE,
42 | null,
43 | ).booleanValue;
44 | }
45 |
46 | private _isInteractive(element: HTMLElement): boolean {
47 | return matchesSelector(element, interactiveElementSelector);
48 | }
49 |
50 | private _findNestedInteractive(element: HTMLElement): HTMLElement | null {
51 | const descendants = element.querySelectorAll(interactiveElementSelector);
52 |
53 | for (let i = 0; i < descendants.length; i++) {
54 | const descendant = descendants[i] as HTMLElement;
55 |
56 | if (this._isAriaHidden(descendant)) {
57 | continue;
58 | }
59 |
60 | return descendant;
61 | }
62 |
63 | return null;
64 | }
65 |
66 | accept(element: HTMLElement): boolean {
67 | return this._isInteractive(element);
68 | }
69 |
70 | validate(element: HTMLElement): ValidationResult | null {
71 | if (this._isAriaHidden(element)) {
72 | return null;
73 | }
74 |
75 | const nestedElement = this._findNestedInteractive(element);
76 |
77 | if (nestedElement) {
78 | const elementTag = element.tagName.toLowerCase();
79 | const elementRole = element.getAttribute("role");
80 | const nestedTag = nestedElement.tagName.toLowerCase();
81 | const nestedRole = nestedElement.getAttribute("role");
82 |
83 | const elementDesc = elementRole
84 | ? `${elementTag}[role="${elementRole}"]`
85 | : elementTag;
86 | const nestedDesc = nestedRole
87 | ? `${nestedTag}[role="${nestedRole}"]`
88 | : nestedTag;
89 |
90 | return {
91 | issue: isElementVisible(element)
92 | ? {
93 | id: "nested-interactive",
94 | message: `Interactive element <${elementDesc}> contains a nested interactive element <${nestedDesc}>. This can confuse users and assistive technologies.`,
95 | element,
96 | rel: nestedElement,
97 | help: "https://dequeuniversity.com/rules/axe/4.4/nested-interactive",
98 | }
99 | : undefined,
100 | };
101 | }
102 |
103 | return null;
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/azure-pipelines.yml:
--------------------------------------------------------------------------------
1 | # Variable 'prerelease' was defined in the Variables tab
2 | # Variable 'prereleaseTag' was defined in the Variables tab
3 | # Variable 'publishVersion' was defined in the Variables tab
4 | # Variable 'skipPublish' was defined in the Variables tab with default value 'false'
5 |
6 | variables:
7 | - group: 'Github and NPM secrets'
8 | - name: tags
9 | value: production,externalfacing
10 |
11 | resources:
12 | repositories:
13 | - repository: 1esPipelines
14 | type: git
15 | name: 1ESPipelineTemplates/1ESPipelineTemplates
16 | ref: refs/tags/release
17 |
18 | extends:
19 | template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines
20 | parameters:
21 | pool:
22 | name: Azure-Pipelines-1ESPT-ExDShared
23 | image: windows-latest
24 | os: windows # We need windows because compliance task only run on windows.
25 | stages:
26 | - stage: main
27 | jobs:
28 | - job: Release
29 | pool:
30 | name: '1ES-Host-Ubuntu'
31 | image: '1ES-PT-Ubuntu-20.04'
32 | os: linux
33 | workspace:
34 | clean: all
35 | templateContext:
36 | outputs:
37 | - output: pipelineArtifact
38 | targetPath: $(System.DefaultWorkingDirectory)
39 | artifactName: output
40 | steps:
41 | - checkout: self
42 | clean: true
43 |
44 | - task: CmdLine@2
45 | displayName: Re-attach head
46 | inputs:
47 | script: |
48 | git checkout --track "origin/${BUILD_SOURCEBRANCH//refs\/heads\/}"
49 | git pull
50 |
51 | - task: NodeTool@0
52 | displayName: Use Node 22.x
53 | inputs:
54 | versionSpec: 22.x
55 | - script: |
56 | npm install
57 | displayName: npm install
58 |
59 | - task: CmdLine@2
60 | displayName: Authenticate git for pushes
61 | inputs:
62 | script: >-
63 | git config user.name "AbleDOM Build"
64 |
65 | git config user.email "fluentui-internal@service.microsoft.com"
66 |
67 | git remote set-url origin https://$(githubUser):$(githubPAT)@github.com/microsoft/abledom.git
68 |
69 | - task: CmdLine@2
70 | displayName: Write npmrc for publish token
71 | inputs:
72 | script: echo '//registry.npmjs.org/:_authToken=${NPM_TOKEN}' > .npmrc
73 | condition: eq(variables.skipPublish, false)
74 |
75 | - task: CmdLine@2
76 | displayName: Publish (official)
77 | condition: and(eq(variables.skipPublish, false), eq(variables.prerelease, false))
78 | inputs:
79 | script: 'npm run release -- $(publishVersion) --ci '
80 | env:
81 | NPM_TOKEN: $(npmToken)
82 |
83 | - task: CmdLine@2
84 | displayName: Publish (prerelease)
85 | condition: and(eq(variables.skipPublish, false), eq(variables.prerelease, true))
86 | inputs:
87 | script: npm run release -- $(publishVersion) --preRelease $(prereleaseTag) --ci
88 | env:
89 | NPM_TOKEN: $(npmToken)
--------------------------------------------------------------------------------
/tests/utils.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 | import type {
6 | AbleDOM,
7 | AbleDOMProps,
8 | ValidationRule,
9 | ValidationIssue,
10 | } from "abledom";
11 | import { Page } from "@playwright/test";
12 |
13 | export const issueSelector = "#abledom-report .abledom-issue";
14 |
15 | interface WindowWithAbleDOMData extends Window {
16 | __ableDOMIdle?: () => Promise;
17 | __ableDOMIssuesFromCallbacks?: Map<
18 | HTMLElement | null,
19 | Map, ValidationIssue>
20 | >;
21 | }
22 |
23 | export interface ValidationIssueForTestsIdle
24 | extends Omit {
25 | element?: string;
26 | }
27 |
28 | export function getAbleDOMCallbacks(): AbleDOMProps["callbacks"] {
29 | (window as WindowWithAbleDOMData).__ableDOMIssuesFromCallbacks = new Map();
30 |
31 | return {
32 | onIssueAdded: (element, rule, issue) => {
33 | let issues = (
34 | element?.ownerDocument.defaultView as WindowWithAbleDOMData | undefined
35 | )?.__ableDOMIssuesFromCallbacks;
36 |
37 | if (!issues) {
38 | return;
39 | }
40 |
41 | let elementIssues = issues.get(element);
42 |
43 | if (!elementIssues) {
44 | elementIssues = new Map();
45 | issues.set(element, elementIssues);
46 | }
47 |
48 | elementIssues.set(rule, issue);
49 | },
50 |
51 | onIssueUpdated: (element, rule, issue) => {
52 | (
53 | element?.ownerDocument.defaultView as WindowWithAbleDOMData | undefined
54 | )?.__ableDOMIssuesFromCallbacks
55 | ?.get(element)
56 | ?.set(rule, issue);
57 | },
58 |
59 | onIssueRemoved: (element, rule) => {
60 | const issues = (
61 | element?.ownerDocument.defaultView as WindowWithAbleDOMData | undefined
62 | )?.__ableDOMIssuesFromCallbacks;
63 |
64 | if (issues) {
65 | const elementIssues = issues.get(element);
66 |
67 | if (elementIssues) {
68 | elementIssues.delete(rule);
69 |
70 | if (elementIssues.size === 0) {
71 | issues.delete(element);
72 | }
73 | }
74 | }
75 | },
76 | };
77 | }
78 |
79 | export function initIdleProp(ableDOM: AbleDOM) {
80 | (window as WindowWithAbleDOMData).__ableDOMIdle = () => ableDOM.idle();
81 | }
82 |
83 | export async function loadTestPage(page: Page, uri: string): Promise {
84 | await page.goto(uri, {
85 | waitUntil: "domcontentloaded",
86 | });
87 | }
88 |
89 | export async function awaitIdle(
90 | page: Page,
91 | ): Promise {
92 | return (
93 | (await page.evaluate(async () => {
94 | return (await (window as WindowWithAbleDOMData).__ableDOMIdle?.())?.map(
95 | (issue) => ({
96 | ...issue,
97 | element: issue.element?.outerHTML,
98 | }),
99 | );
100 | })) || []
101 | );
102 | }
103 |
104 | export async function getIssuesCount(page: Page): Promise {
105 | const issues = await page.$$(issueSelector);
106 |
107 | return issues.length;
108 | }
109 |
110 | export async function getIssuesFromCallbacks(
111 | page: Page,
112 | ): Promise {
113 | return await page.evaluate(() => {
114 | const issues: ValidationIssue[] = [];
115 | (window as WindowWithAbleDOMData).__ableDOMIssuesFromCallbacks?.forEach(
116 | (elementIssues) => {
117 | elementIssues.forEach((issue) => {
118 | issues.push(issue);
119 | });
120 | },
121 | );
122 | return issues;
123 | });
124 | }
125 |
--------------------------------------------------------------------------------
/src/ui/ui.css:
--------------------------------------------------------------------------------
1 | #abledom-report {
2 | bottom: 20px;
3 | display: flex;
4 | flex-direction: column;
5 | left: 10px;
6 | max-height: 80%;
7 | max-width: 60%;
8 | padding: 4px 8px;
9 | position: fixed;
10 | z-index: 100500;
11 | }
12 |
13 | #abledom-report :focus-visible {
14 | outline: 3px solid red;
15 | mix-blend-mode: difference;
16 | }
17 |
18 | #abledom-report.abledom-align-left {
19 | left: 10px;
20 | right: auto;
21 | }
22 |
23 | #abledom-report.abledom-align-right {
24 | left: auto;
25 | right: 10px;
26 | }
27 |
28 | #abledom-report.abledom-align-bottom {
29 | bottom: 20px;
30 | top: auto;
31 | }
32 |
33 | #abledom-report.abledom-align-top {
34 | /* flex-direction: column-reverse; */
35 | bottom: auto;
36 | top: 10px;
37 | }
38 |
39 | .abledom-menu-container {
40 | backdrop-filter: blur(3px);
41 | border-radius: 8px;
42 | box-shadow: 0px 0px 4px rgba(127, 127, 127, 0.5);
43 | display: inline-block;
44 | margin: 2px auto 2px 0;
45 | }
46 |
47 | #abledom-report.abledom-align-right .abledom-menu-container {
48 | margin: 2px 0 2px auto;
49 | }
50 |
51 | .abledom-menu {
52 | background-color: rgba(140, 10, 121, 0.7);
53 | border-radius: 8px;
54 | color: white;
55 | display: inline flex;
56 | font-family: Arial, Helvetica, sans-serif;
57 | font-size: 16px;
58 | line-height: 26px;
59 | padding: 4px;
60 | }
61 |
62 | .abledom-menu .issues-count {
63 | margin: 0 8px;
64 | display: inline-block;
65 | }
66 |
67 | .abledom-menu .button {
68 | all: unset;
69 | color: #000;
70 | cursor: pointer;
71 | background: linear-gradient(
72 | 180deg,
73 | rgba(255, 255, 255, 1) 0%,
74 | rgba(200, 200, 200, 1) 100%
75 | );
76 | border-radius: 6px;
77 | border: 1px solid rgba(255, 255, 255, 0.4);
78 | box-sizing: border-box;
79 | line-height: 0px;
80 | margin-right: 4px;
81 | max-height: 26px;
82 | padding: 2px;
83 | text-decoration: none;
84 | }
85 |
86 | .abledom-menu .align-button {
87 | border-right-color: rgba(0, 0, 0, 0.4);
88 | border-radius: 0;
89 | margin: 0;
90 | }
91 |
92 | .abledom-menu .align-button:active,
93 | #abledom-report .pressed {
94 | background: linear-gradient(
95 | 180deg,
96 | rgba(130, 130, 130, 1) 0%,
97 | rgba(180, 180, 180, 1) 100%
98 | );
99 | }
100 |
101 | .abledom-menu .align-button-first {
102 | border-top-left-radius: 6px;
103 | border-bottom-left-radius: 6px;
104 | margin-left: 8px;
105 | }
106 | .abledom-menu .align-button-last {
107 | border-top-right-radius: 6px;
108 | border-bottom-right-radius: 6px;
109 | border-right-color: rgba(255, 255, 255, 0.4);
110 | }
111 |
112 | .abledom-issues-container {
113 | overflow: auto;
114 | max-height: calc(100vh - 100px);
115 | }
116 |
117 | #abledom-report.abledom-align-right .abledom-issues-container {
118 | text-align: right;
119 | }
120 |
121 | .abledom-issue-container {
122 | backdrop-filter: blur(3px);
123 | border-radius: 8px;
124 | box-shadow: 0px 0px 4px rgba(127, 127, 127, 0.5);
125 | display: inline-flex;
126 | margin: 2px 0;
127 | }
128 |
129 | .abledom-issue {
130 | background-color: rgba(164, 2, 2, 0.7);
131 | border-radius: 8px;
132 | color: white;
133 | display: inline flex;
134 | font-family: Arial, Helvetica, sans-serif;
135 | font-size: 16px;
136 | line-height: 26px;
137 | padding: 4px;
138 | }
139 | .abledom-issue_warning {
140 | background-color: rgba(163, 82, 1, 0.7);
141 | }
142 | .abledom-issue_info {
143 | background-color: rgba(0, 0, 255, 0.7);
144 | }
145 |
146 | .abledom-issue .button {
147 | all: unset;
148 | color: #000;
149 | cursor: pointer;
150 | background: linear-gradient(
151 | 180deg,
152 | rgba(255, 255, 255, 1) 0%,
153 | rgba(200, 200, 200, 1) 100%
154 | );
155 | border-radius: 6px;
156 | border: 1px solid rgba(255, 255, 255, 0.4);
157 | box-sizing: border-box;
158 | line-height: 0px;
159 | margin-right: 4px;
160 | max-height: 26px;
161 | padding: 2px;
162 | text-decoration: none;
163 | }
164 |
165 | .abledom-issue .button:hover {
166 | opacity: 0.7;
167 | }
168 |
169 | .abledom-issue .button.close {
170 | background: none;
171 | border-color: transparent;
172 | color: #fff;
173 | margin: 0;
174 | }
175 |
--------------------------------------------------------------------------------
/src/rules/focuslost.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import { isElementVisible, getStackTrace } from "../utils";
7 | import { BlurIssue, ValidationRule, ValidationRuleType } from "./base";
8 |
9 | export class FocusLostRule extends ValidationRule {
10 | type = ValidationRuleType.Error;
11 | name = "focus-lost";
12 | anchored = false;
13 |
14 | private _focusLostTimeout = 2000; // For now reporting lost focus after 2 seconds of it being lost.
15 | private _clearScheduledFocusLost: (() => void) | undefined;
16 | private _focusedElement: HTMLElement | undefined;
17 | private _focusedElementPosition: string[] | undefined;
18 | private _lastFocusStack: string[] | undefined;
19 | private _lastBlurStack: string[] | undefined;
20 | private _mouseEventTimer: number | undefined;
21 | private _releaseMouseEvent: (() => void) | undefined;
22 |
23 | private _serializeElementPosition(element: HTMLElement): string[] {
24 | const position: string[] = [];
25 | const parentElement = element.parentElement;
26 |
27 | if (!parentElement) {
28 | return position;
29 | }
30 |
31 | for (
32 | let el: HTMLElement | null = parentElement;
33 | el;
34 | el = el.parentElement
35 | ) {
36 | const tagName = el.tagName.toLowerCase();
37 | position.push(tagName);
38 | }
39 |
40 | return position;
41 | }
42 |
43 | focused(event: FocusEvent): null {
44 | const target = event.target as HTMLElement | null;
45 |
46 | this._clearScheduledFocusLost?.();
47 |
48 | if (target) {
49 | this._lastFocusStack = getStackTrace();
50 |
51 | this._focusedElement = target;
52 | this._focusedElementPosition = this._serializeElementPosition(target);
53 | }
54 |
55 | return null;
56 | }
57 |
58 | blurred(event: FocusEvent): null {
59 | const target = event.target as HTMLElement | null;
60 | const win = this.window;
61 |
62 | this._clearScheduledFocusLost?.();
63 |
64 | if (!target || !win || event.relatedTarget || this._mouseEventTimer) {
65 | return null;
66 | }
67 |
68 | const doc = win.document;
69 |
70 | const targetPosition =
71 | this._focusedElement === target
72 | ? this._focusedElementPosition
73 | : undefined;
74 |
75 | this._lastBlurStack = getStackTrace();
76 |
77 | // Make sure to not hold the reference once the element is not focused anymore.
78 | this._focusedElement = undefined;
79 | this._focusedElementPosition = undefined;
80 |
81 | const focusLostTimer = win.setTimeout(() => {
82 | delete this._clearScheduledFocusLost;
83 |
84 | if (
85 | doc.body &&
86 | (!doc.activeElement || doc.activeElement === doc.body) &&
87 | (!doc.body.contains(target) || !isElementVisible(target))
88 | ) {
89 | this.notify({
90 | element: target,
91 | id: "focus-lost",
92 | message: "Focus lost.",
93 | stack: this._lastBlurStack,
94 | relStack: this._lastFocusStack,
95 | position: targetPosition || [],
96 | });
97 | }
98 | }, this._focusLostTimeout);
99 |
100 | this._clearScheduledFocusLost = () => {
101 | delete this._clearScheduledFocusLost;
102 | win.clearTimeout(focusLostTimer);
103 | };
104 |
105 | return null;
106 | }
107 |
108 | start(): void {
109 | const win = this.window;
110 |
111 | if (!win) {
112 | return;
113 | }
114 |
115 | const onMouseEvent = () => {
116 | if (!this._mouseEventTimer) {
117 | this._mouseEventTimer = win.setTimeout(() => {
118 | this._mouseEventTimer = undefined;
119 | }, 0);
120 | }
121 | };
122 |
123 | win.addEventListener("mousedown", onMouseEvent, true);
124 | win.addEventListener("mouseup", onMouseEvent, true);
125 | win.addEventListener("mousemove", onMouseEvent, true);
126 |
127 | this._releaseMouseEvent = () => {
128 | delete this._releaseMouseEvent;
129 |
130 | if (this._mouseEventTimer) {
131 | win.clearTimeout(this._mouseEventTimer);
132 | delete this._mouseEventTimer;
133 | }
134 |
135 | win.removeEventListener("mousedown", onMouseEvent, true);
136 | win.removeEventListener("mouseup", onMouseEvent, true);
137 | win.removeEventListener("mousemove", onMouseEvent, true);
138 | };
139 | }
140 |
141 | stop(): void {
142 | this._releaseMouseEvent?.();
143 | this._clearScheduledFocusLost?.();
144 | this._focusedElement = undefined;
145 | this._focusedElementPosition = undefined;
146 | this._lastFocusStack = undefined;
147 | this._lastBlurStack = undefined;
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/src/rules/label.ts:
--------------------------------------------------------------------------------
1 | /*!
2 | * Copyright (c) Microsoft Corporation. All rights reserved.
3 | * Licensed under the MIT License.
4 | */
5 |
6 | import {
7 | matchesSelector,
8 | focusableElementSelector,
9 | isElementVisible,
10 | } from "../utils";
11 | import { ValidationRule, ValidationResult, ValidationRuleType } from "./base";
12 |
13 | const _keyboardEditableInputTypes = new Set([
14 | "text",
15 | "password",
16 | "email",
17 | "search",
18 | "tel",
19 | "url",
20 | "number",
21 | "date",
22 | "month",
23 | "week",
24 | "time",
25 | "datetime-local",
26 | ]);
27 |
28 | export class FocusableElementLabelRule extends ValidationRule {
29 | type = ValidationRuleType.Error;
30 | name = "FocusableElementLabelRule";
31 | anchored = true;
32 |
33 | private _isAriaHidden(element: HTMLElement): boolean {
34 | return element.ownerDocument.evaluate(
35 | `ancestor-or-self::*[@aria-hidden = 'true' or @hidden]`,
36 | element,
37 | null,
38 | XPathResult.BOOLEAN_TYPE,
39 | null,
40 | ).booleanValue;
41 | }
42 |
43 | private _hasLabel(element: HTMLElement): boolean {
44 | const labels = (element as HTMLInputElement).labels;
45 |
46 | if (labels && labels.length > 0) {
47 | for (let i = 0; i < labels.length; i++) {
48 | const label = labels[i];
49 | if (label.innerText.trim()) {
50 | return true;
51 | }
52 | }
53 | }
54 |
55 | if (element.tagName === "IMG") {
56 | if ((element as HTMLImageElement).alt.trim()) {
57 | return true;
58 | }
59 | }
60 |
61 | if (element.textContent?.trim()) {
62 | return true;
63 | }
64 |
65 | const labelNodes = element.ownerDocument.evaluate(
66 | `(
67 | .//@aria-label |
68 | .//text() |
69 | .//@title |
70 | .//img/@alt |
71 | .//input[@type = 'image']/@alt |
72 | .//input[@type != 'hidden'][@type = 'submit' or @type = 'reset' or @type = 'button']/@value
73 | )[not(ancestor-or-self::*[@aria-hidden = 'true' or @hidden])]`,
74 | element,
75 | null,
76 | XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
77 | null,
78 | );
79 |
80 | for (let i = 0; i < labelNodes.snapshotLength; i++) {
81 | const val = labelNodes.snapshotItem(i)?.nodeValue?.trim();
82 |
83 | if (val) {
84 | return true;
85 | }
86 | }
87 |
88 | return false;
89 | }
90 |
91 | accept(element: HTMLElement): boolean {
92 | return matchesSelector(element, focusableElementSelector);
93 | }
94 |
95 | validate(element: HTMLElement): ValidationResult | null {
96 | const doc = element.ownerDocument;
97 |
98 | if (element.tagName === "INPUT") {
99 | const type = (element as HTMLInputElement).type;
100 |
101 | if (type === "hidden") {
102 | return null;
103 | }
104 |
105 | if (_keyboardEditableInputTypes.has(type)) {
106 | return null;
107 | }
108 |
109 | if (type === "image") {
110 | if ((element as HTMLInputElement).alt.trim()) {
111 | return null;
112 | }
113 | }
114 |
115 | if (type === "submit" || type === "reset" || type === "button") {
116 | if ((element as HTMLInputElement).value.trim()) {
117 | return null;
118 | }
119 | }
120 | }
121 |
122 | if (this._isAriaHidden(element)) {
123 | return null;
124 | }
125 |
126 | if (this._hasLabel(element)) {
127 | return null;
128 | }
129 |
130 | const labelledByNodes = doc.evaluate(
131 | `.//@aria-labelledby[not(ancestor-or-self::*[@aria-hidden = 'true' or @hidden])]`,
132 | element,
133 | null,
134 | XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
135 | null,
136 | );
137 |
138 | const labelledByValues: string[] = [];
139 |
140 | for (let i = 0; i < labelledByNodes.snapshotLength; i++) {
141 | const val = (labelledByNodes.snapshotItem(i) as Attr)?.value
142 | ?.trim()
143 | .split(" ");
144 |
145 | if (val?.length) {
146 | labelledByValues.push(...val);
147 | }
148 | }
149 |
150 | for (const id of labelledByValues) {
151 | const labelElement = doc.getElementById(id);
152 |
153 | if (labelElement && this._hasLabel(labelElement)) {
154 | return {
155 | dependsOnIds: new Set(labelledByValues),
156 | };
157 | }
158 | }
159 |
160 | return {
161 | issue: isElementVisible(element)
162 | ? {
163 | id: "focusable-element-label",
164 | message: "Focusable element must have a non-empty text label.",
165 | element,
166 | help: "https://www.w3.org/WAI/tutorials/forms/labels/",
167 | }
168 | : undefined,
169 | dependsOnIds: new Set(labelledByValues),
170 | };
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import _import from "eslint-plugin-import";
3 | import header from "eslint-plugin-header";
4 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
5 | import tsParser from "@typescript-eslint/parser";
6 | import globals from "globals";
7 |
8 | header.rules.header.meta.schema = false;
9 |
10 | export default [
11 | {
12 | ignores: ["**/*.config.js", "**/dist", "**/node_modules"],
13 | },
14 | {
15 | files: ["**/*.js"],
16 | languageOptions: {
17 | ecmaVersion: "latest",
18 | sourceType: "module",
19 | globals: {
20 | ...globals.browser,
21 | },
22 | },
23 | plugins: {
24 | import: _import,
25 | header,
26 | },
27 | rules: {
28 | ...js.configs.recommended.rules,
29 | curly: "error",
30 | eqeqeq: ["error", "smart"],
31 | "guard-for-in": "error",
32 | "id-denylist": "off",
33 | "id-match": "off",
34 | "import/order": "error",
35 | "no-bitwise": "off",
36 | "no-caller": "error",
37 | "no-console": [
38 | "error",
39 | {
40 | allow: [
41 | "log",
42 | "warn",
43 | "dir",
44 | "timeLog",
45 | "assert",
46 | "clear",
47 | "count",
48 | "countReset",
49 | "group",
50 | "groupEnd",
51 | "table",
52 | "dirxml",
53 | "error",
54 | "groupCollapsed",
55 | "Console",
56 | "profile",
57 | "profileEnd",
58 | "timeStamp",
59 | "context",
60 | ],
61 | },
62 | ],
63 | "no-debugger": "error",
64 | "no-empty": "error",
65 | "no-empty-function": "error",
66 | "no-eval": "error",
67 | "no-fallthrough": "error",
68 | "no-new-wrappers": "error",
69 | "no-underscore-dangle": "off",
70 | "no-unused-expressions": "off",
71 | "no-unused-labels": "error",
72 | radix: "error",
73 |
74 | "header/header": [
75 | 1,
76 | "block",
77 | [
78 | "!",
79 | " * Copyright (c) Microsoft Corporation. All rights reserved.",
80 | " * Licensed under the MIT License.",
81 | " ",
82 | ],
83 | 1,
84 | ],
85 | },
86 | },
87 | {
88 | files: ["src/**/*.{js,ts}", "tests/**/*.{js,ts}"],
89 | languageOptions: {
90 | ecmaVersion: 5,
91 | sourceType: "module",
92 | globals: {
93 | ...globals.browser,
94 | ...globals.webextensions,
95 | },
96 | parser: tsParser,
97 | parserOptions: {
98 | project: "tsconfig.json",
99 | },
100 | },
101 | plugins: {
102 | import: _import,
103 | header,
104 | "@typescript-eslint": typescriptEslint,
105 | },
106 | rules: {
107 | ...typescriptEslint.configs.recommended.rules,
108 |
109 | "@typescript-eslint/no-empty-function": "error",
110 | "@typescript-eslint/no-unused-expressions": [
111 | "error",
112 | {
113 | allowTernary: true,
114 | allowShortCircuit: true,
115 | },
116 | ],
117 |
118 | curly: "error",
119 | eqeqeq: ["error", "smart"],
120 | "guard-for-in": "error",
121 | "id-denylist": "off",
122 | "id-match": "off",
123 | "import/order": "error",
124 | "no-bitwise": "off",
125 | "no-caller": "error",
126 | "no-console": [
127 | "error",
128 | {
129 | allow: [
130 | "log",
131 | "warn",
132 | "dir",
133 | "timeLog",
134 | "assert",
135 | "clear",
136 | "count",
137 | "countReset",
138 | "group",
139 | "groupEnd",
140 | "table",
141 | "dirxml",
142 | "error",
143 | "groupCollapsed",
144 | "Console",
145 | "profile",
146 | "profileEnd",
147 | "timeStamp",
148 | "context",
149 | ],
150 | },
151 | ],
152 | "no-debugger": "error",
153 | "no-empty": "error",
154 | "no-empty-function": "error",
155 | "no-eval": "error",
156 | "no-fallthrough": "error",
157 | "no-new-wrappers": "error",
158 | "no-underscore-dangle": "off",
159 | "no-unused-expressions": "off",
160 | "no-unused-labels": "error",
161 | radix: "error",
162 |
163 | "header/header": [
164 | 1,
165 | "block",
166 | [
167 | "!",
168 | " * Copyright (c) Microsoft Corporation. All rights reserved.",
169 | " * Licensed under the MIT License.",
170 | " ",
171 | ],
172 | 1,
173 | ],
174 | },
175 | },
176 | ];
177 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AbleDOM
2 |
3 | A continuous accessibility (a11y) monitor for modern web applications.
4 |
5 | AbleDOM is a lightweight JavaScript/TypeScript library that observes your DOM in real-time and detects common accessibility issues as they appear.
6 |
7 | _Here be dragons_.
8 |
9 | ## Installation
10 |
11 | ```bash
12 | npm install abledom
13 | # or
14 | yarn add abledom
15 | # or
16 | pnpm add abledom
17 | ```
18 |
19 | ## Quick start
20 |
21 | ```typescript
22 | import { AbleDOM } from "abledom";
23 |
24 | const _ableDOM = new AbleDOM(window, { log: window.console?.error });
25 |
26 | // ...Create and add rules and exceptions
27 | ```
28 |
29 | ### Using rules
30 |
31 | ```typescript
32 | import { AbleDOM, ContrastRule } from "abledom";
33 |
34 | const contrastRule = new ContrastRule();
35 | this._ableDOM.addRule(contrastRule);
36 | ```
37 |
38 | ### Adding valid exceptions
39 |
40 | ```typescript
41 | import { AbleDOM, ContrastRule } from "abledom";
42 |
43 | const contrastExceptions: ((element: HTMLElement) => boolean)[] = [
44 | (element: HTMLElement) => {
45 | return element.style?.display === "none";
46 | },
47 | (element: HTMLElement) => {
48 | return element.datalist?.ignore === "true";
49 | },
50 | ];
51 |
52 | const contrastRule = new ContrastRule();
53 |
54 | contrastExceptions.forEach((exception) => contrastRule.addException(exception));
55 |
56 | this._ableDOM.addRule(contrastRule);
57 | ```
58 |
59 | ## Rules
60 |
61 | ### AtomicRule
62 |
63 | Detects focusable elements nested inside other atomic focusable elements (like buttons, links, or inputs). Prevents confusing interactive hierarchies that can break keyboard navigation and assistive technology functionality.
64 |
65 | ### BadFocusRule
66 |
67 | Monitors focus changes to detect when focus is stolen by invisible elements. Helps identify scenarios where focus moves to elements that users cannot see, creating a poor accessibility experience.
68 |
69 | ### ContrastRule
70 |
71 | Validates color contrast ratios between text and background colors according to WCAG standards. Ensures text meets minimum contrast requirements (4.5:1 for normal text, 3:1 for large text) for readability.
72 |
73 | ### ExistingIdRule
74 |
75 | Verifies that elements referenced by `aria-labelledby`, `aria-describedby`, or `