├── .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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/ui/aligntopright.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/ui/alignbottomleft.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/ui/alignbottomright.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/ui/showall.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/ui/close.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/ui/help.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /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 | 2 | 3 | 4 | 5 | 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 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/ui/muteall.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 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 `