diff --git a/.github/actions/install-deps/action.yml b/.github/actions/install-deps/action.yml index b7d220b7f..40c9aa43d 100644 --- a/.github/actions/install-deps/action.yml +++ b/.github/actions/install-deps/action.yml @@ -56,7 +56,8 @@ runs: id: setup-chrome uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0 with: - chrome-version: ${{ inputs.nightly == 'true' && 'beta' || 'stable' }} + # @see https://github.com/dequelabs/axe-core/issues/5027 + chrome-version: ${{ inputs.nightly == 'true' && 'beta' || 145 }} install-chromedriver: true install-dependencies: true - name: Install Firefox diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fda5a9dec..867f60a82 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -173,6 +173,8 @@ jobs: - name: Make Release Script Executable run: chmod +x ./node-github-release.sh - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: ./node-github-release.sh validate-deploy: name: Validate Deployment diff --git a/.github/workflows/update-generated-files.yaml b/.github/workflows/update-generated-files.yaml index 1968e89c5..6ac667002 100644 --- a/.github/workflows/update-generated-files.yaml +++ b/.github/workflows/update-generated-files.yaml @@ -29,7 +29,7 @@ jobs: - name: Check for changes id: changes run: | - changes=$(git status --porcelain) + changes=$(git status --porcelain | tr -d '\n') # see https://unix.stackexchange.com/a/509498 echo $changes | grep . && echo "Changes detected" || echo "No changes" echo "changes=$changes" >> "$GITHUB_OUTPUT" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9b2b8f0..8fab22519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [4.11.4](https://github.com/dequelabs/axe-core/compare/v4.11.3...v4.11.4) (2026-04-23) + +### Bug Fixes + +- **commons/text:** exclude natively hidden elements from aria-labelledby accessible name ([#5076](https://github.com/dequelabs/axe-core/issues/5076)) ([df34adf](https://github.com/dequelabs/axe-core/commit/df34adfc1967919d667d40a76ab5c85b6e47ddfe)), closes [#4704](https://github.com/dequelabs/axe-core/issues/4704) +- **utils/getAncestry:** escape node name ([#5079](https://github.com/dequelabs/axe-core/issues/5079)) ([6e68d0a](https://github.com/dequelabs/axe-core/commit/6e68d0a5d26999b996152df82238bc3f3a041cb3)), closes [#5078](https://github.com/dequelabs/axe-core/issues/5078) + +### [4.11.3](https://github.com/dequelabs/axe-core/compare/v4.11.2...v4.11.3) (2026-04-13) + +### Bug Fixes + +- **aria-allowed-attr:** restrict br and wbr elements to aria-hidden only ([#4974](https://github.com/dequelabs/axe-core/issues/4974)) ([1d80163](https://github.com/dequelabs/axe-core/commit/1d801636f058f2abd885c488baff954872b13846)) +- **target-size:** ignore position: fixed elements that are offscreen when page is scrolled ([#5066](https://github.com/dequelabs/axe-core/issues/5066)) ([5906273](https://github.com/dequelabs/axe-core/commit/5906273841cbd7ac9e08af730dffc244cf42b39b)), closes [#5065](https://github.com/dequelabs/axe-core/issues/5065) + +### [4.11.2](https://github.com/dequelabs/axe-core/compare/v4.11.1...v4.11.2) (2026-03-30) + +### Bug Fixes + +- **aria-valid-attr-value:** handle multiple aria-errormessage IDs ([#4973](https://github.com/dequelabs/axe-core/issues/4973)) ([9322148](https://github.com/dequelabs/axe-core/commit/9322148b69924d6ae2d2dd46e6109bc2fc53abd3)) +- **aria:** prevent getOwnedVirtual from returning duplicate nodes ([#4987](https://github.com/dequelabs/axe-core/issues/4987)) ([99d1e77](https://github.com/dequelabs/axe-core/commit/99d1e77f351db586c79020372efc59608e604a1c)), closes [#4840](https://github.com/dequelabs/axe-core/issues/4840) +- **DqElement:** avoid calling constructors with cloneNode ([#5013](https://github.com/dequelabs/axe-core/issues/5013)) ([88bc57f](https://github.com/dequelabs/axe-core/commit/88bc57fd3de12cd69d365b3f385ce9a2e30b7bd5)) +- **existing-rule:** aria-busy now shows an error message for a use with unallowed children ([#5017](https://github.com/dequelabs/axe-core/issues/5017)) ([dded75a](https://github.com/dequelabs/axe-core/commit/dded75a9acc9a2350926f46f04e0f1de522f43d6)) +- **scrollable-region-focusable:** clarify the issue is in safari ([#4995](https://github.com/dequelabs/axe-core/issues/4995)) ([2567afd](https://github.com/dequelabs/axe-core/commit/2567afd5c32398c6a488240b066bb0d335f6dc6a)), closes [WebKit#190870](https://github.com/dequelabs/WebKit/issues/190870) [WebKit#277290](https://github.com/dequelabs/WebKit/issues/277290) +- **scrollable-region-focusable:** do not fail scroll areas when all content is visible without scrolling ([#4993](https://github.com/dequelabs/axe-core/issues/4993)) ([240f8b5](https://github.com/dequelabs/axe-core/commit/240f8b53ad168521a63b54d0053b96ce430c7184)) +- **target-size:** determine offset using clientRects if target is display:inline ([#5012](https://github.com/dequelabs/axe-core/issues/5012)) ([69d81c1](https://github.com/dequelabs/axe-core/commit/69d81c1cbb6a61a272884516c7983dcd17d28a42)) +- **target-size:** ignore widgets that are inline with other inline elements ([#5000](https://github.com/dequelabs/axe-core/issues/5000)) ([cf8a3c0](https://github.com/dequelabs/axe-core/commit/cf8a3c039b121c0c64a1eb8805c586659b69f3c1)) + ### [4.11.1](https://github.com/dequelabs/axe-core/compare/v4.11.0...v4.11.1) (2026-01-06) ### Bug Fixes diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8a81ac332 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# axe-core — Claude Code Context + +**Last updated:** 2026-03-19 + +## 0. Fundamental Standards + +- **Formatting:** Run `npm run fmt` (Prettier) and `npm run eslint` before every commit. No exceptions. +- **Zero-Exception Testing:** 100% coverage goal. Run `npm test` before completion. `feat`/`fix` and other behavior-changing PRs require unit + integration tests where applicable; see `doc/code-submission-guidelines.md` for exceptions (e.g., some `chore`/docs changes). +- **Import Rule:** Directory-level import restrictions are strictly enforced. See `eslint.config.js`. +- **Commits:** Angular commit convention is mandatory. PRs with non-conforming commits will be rejected. See `doc/code-submission-guidelines.md`. +- **Issues:** All unresolved issues tracked in [GitHub Issues](https://github.com/dequelabs/axe-core/issues). + +## 1. Technical Guidelines + +### Code Structure + +- **Return Early:** Keep the happy path left-aligned. Handle errors/edge cases first with early returns — never nest when you can exit. +- **Exports:** Default export at the top of the file, immediately after imports. +- **JSDoc:** Add JSDoc/DocBlock comments where appropriate, especially for exported APIs and complex logic. Document parameters and return types. See `doc/code-submission-guidelines.md` (source of truth) and `doc/developer-guide.md` for additional guidance. +- **Naming:** Files and rule/check IDs in kebab-case. Functions in camelCase. Booleans prefixed `is`/`has`/`should`. Constants in `UPPER_SNAKE_CASE`. +- **Variables:** Declare at point of use, not at the top of the function. + +### Virtual Nodes vs. HTMLElement + +- **Prefer Virtual Nodes** for attribute access and property reads (`virtualNode.attr()`, `virtualNode.props`). +- **Use real DOM** only when you need DOM APIs (e.g., `getBoundingClientRect`, `getRootNode`). +- **`core/utils/` functions** should default to real DOM inputs/outputs, except utilities that are explicitly documented as operating on `VirtualNode` (for example, tree or selector helpers). Avoid introducing new `VirtualNode`-dependent utilities unless there is a clear performance or API benefit. +- **Conversion:** Use `getNodeFromTree()` from `core/utils` when you receive an ambiguous input (such as a `VirtualNode`, selector, or mixed type) and need to resolve it to a real DOM `Node`. + +### Import Restrictions (Hard Rules) + +- `standards/` → nothing (pure data, no imports). +- `core/utils/` → other `core/utils`, `core/base`, `standards` via **direct file paths only** (no index). +- `core/imports/` → node modules **only** (the only place npm imports are allowed). +- `commons/` → other `commons` (direct paths), `core/utils` (index OK). +- `checks/` and `rules/` → any directory (index OK). +- **Never** import `commons` from `core/utils`. This is the most commonly rejected violation. + +### Checks & Rules + +- **Check evaluate functions:** Return `true` (pass), `false` (fail), or `undefined` (incomplete). Use `this.data()` to pass values to message templates and to provide incomplete-result detail. +- **Rule JSON:** Use `all`, `any`, `none` check arrays. `selector` + optional `matches` to scope candidates. Valid `impact` values: `"minor"`, `"moderate"`, `"serious"`, `"critical"`. +- **Standards data:** Never hardcode ARIA/HTML lists in checks. Query from `standards/` via `commons/standards` functions. +- **Messages:** All user-facing strings live in check/rule JSON `metadata.messages`. Use `${data.property}` templates. Support singular/plural variants. Update `locales/_template.json` whenever messages change (auto-synced on `npm run build`). + +### High-Risk Areas (Extra Scrutiny Required) + +- **Color contrast:** Handles all CSS color formats, opacity, blend modes, stacking contexts, text-shadow. Return `undefined` when background cannot be determined. See `doc/developer-guide.md`. +- **ARIA validation:** Must stay current with spec. Query roles/attrs from `standards/aria-roles.js` and `standards/aria-attrs.js`. Handle implicit vs. explicit roles. See `doc/rule-development.md`. +- **Hidden elements:** Use `isVisibleToScreenReaders()`, not CSS visibility alone. Account for `aria-hidden="true"` and Shadow DOM boundaries. +- **i18n:** Update `locales/_template.json` on every message change and commit the generated file alongside source. + +## 2. Testing + +- **Structure:** Mirror `lib/` exactly under `test/`. File `lib/commons/text/sanitize.js` → `test/commons/text/sanitize.js`. +- **Checks:** Use `axe.testUtils.MockCheckContext()`. Only reset `checkContext` in `afterEach` — `fixture` and `axe._tree` are auto-cleared. +- **Integration tests:** All rule changes require an HTML + JSON pair. Use `test/integration/rules//` for mocha-hosted tests or `test/integration/full//` when the rule requires a full HTML page. JSON selectors must use axe array format (`["#id"]`; iframes: `["iframe", "#id"]`). Also update or create virtual-rules tests where appropriate. +- **Shadow DOM:** Every relevant check/rule must include an open Shadow DOM test case using `queryShadowFixture`. +- **Logging:** Do not commit `console.log` statements. + +## 3. Build & Commits + +- **Bundles (`axe.js`, `axe.min.js`)** are auto-generated for releases/publishing and are not committed to the repo (they are gitignored). +- **Locales template (`locales/_template.json`)** is auto-generated. When message strings change, regenerate this file and commit it in the same commit as the source changes — never in a separate commit. +- **One change per PR.** Do not mix refactoring with feature work. +- **Commit format:** `(): ` — imperative, lowercase, no period, ≤100 chars total. Body explains motivation. Footer: `Closes issue #123` or full URL. See `doc/code-submission-guidelines.md` for the full type list. + +**Example:** + +``` +fix(aria-valid-attr-value): handle multiple aria-errormessage IDs + +When aria-errormessage contains multiple space-separated IDs, verify +all IDs exist in aria-describedby instead of matching the full string. + +Closes issue #4957 +``` + +## 4. Documentation & API Changes + +- **New rules:** Update `CHANGELOG.md`. `doc/rule-descriptions.md` is auto-generated by `npm run build`. +- **API changes:** Update `doc/API.md` and `axe.d.ts` TypeScript definitions. +- **Breaking changes:** Avoid breaking changes — prefer supporting both old and new formats simultaneously. If unavoidable, add `BREAKING CHANGE: description` to commit footer, include migration guide in `CHANGELOG.md`, and tag deprecated code with `@deprecated` JSDoc. + +## 5. Examples (Copy-Paste Reference) + +- **Code patterns:** `doc/examples/code-patterns.md` — return early, default export, imports, JSDoc, Virtual Node usage +- **Test patterns:** `doc/examples/test-patterns.md` — unit tests, check tests, Shadow DOM tests, integration test HTML+JSON +- **Rule & check templates:** `doc/examples/rule-check-templates.md` — JSON templates for rules and checks, evaluate function pattern +- **PR review patterns:** `doc/examples/pr-review-patterns.md` — common reviewer feedback, anti-patterns, what reviewers look for + +## 6. Reference Docs & Help + +- **Contributing guide:** `CONTRIBUTING.md` +- **Import rules detail:** `eslint.config.js` +- **Code submission standards:** `doc/code-submission-guidelines.md` +- **Developer guide:** `doc/developer-guide.md` +- **Rule development:** `doc/rule-development.md` +- **API reference:** `doc/API.md` +- **Pull Request Checklist:** `doc/pull-request-checklist.md` +- **Slack:** [axe-community](https://accessibility.deque.com/axe-community) +- **Issues:** [GitHub Issues](https://github.com/dequelabs/axe-core/issues) diff --git a/axe.d.ts b/axe.d.ts index 82722edd8..20291d3e9 100644 --- a/axe.d.ts +++ b/axe.d.ts @@ -465,6 +465,10 @@ declare namespace axe { } interface Utils { + getElementSource: ( + element: Node | null | undefined, + options?: { maxLength?: number; attrLimit?: number } + ) => string; getFrameContexts: ( context?: ElementContext, options?: RunOptions diff --git a/bower.json b/bower.json index d1d2ba54d..16da022c3 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "axe-core", - "version": "4.11.1", + "version": "4.11.4", "deprecated": true, "contributors": [ { diff --git a/build/cherry-pick.js b/build/cherry-pick.js index f8040807b..c5575f364 100755 --- a/build/cherry-pick.js +++ b/build/cherry-pick.js @@ -5,6 +5,9 @@ const conventionalCommitsParser = require('conventional-commits-parser'); const chalk = require('chalk'); const { version } = require('../package.json'); +// Node's default execSync buffer (~1 MiB) is too small for `git log` on long histories. +const GIT_LOG_EXEC_OPTS = { maxBuffer: 50 * 1024 * 1024 }; + const releaseType = process.argv[2]; let ignoreCommits = process.argv[3]; @@ -50,7 +53,8 @@ function getCommits(branch) { // all commits are too large for execSync buffer size so we'll just get since the last 3 years const date = new Date(new Date().setFullYear(new Date().getFullYear() - 3)); const stdout = execSync( - `git log ${branch || ''} --abbrev-commit --since=${date.getFullYear()}` + `git log ${branch || ''} --no-merges --abbrev-commit --since=${date.getFullYear()}`, + GIT_LOG_EXEC_OPTS ).toString(); const allCommits = stdout .split(/commit (?=[\w\d]{8}[\n\r])/) diff --git a/doc/examples/code-patterns.md b/doc/examples/code-patterns.md new file mode 100644 index 000000000..e342b8f1e --- /dev/null +++ b/doc/examples/code-patterns.md @@ -0,0 +1,152 @@ +# Code Pattern Examples + +Quick-reference examples for axe-core coding conventions. + +## Default Export at Top + +```javascript +// GOOD: Default export right after imports +import { getRole } from '../../commons/aria'; +import { isVisible } from '../../commons/dom'; + +export default function myFunction(node, options) { + // function body +} + +// BAD: Export buried at bottom of file +import { getRole } from '../../commons/aria'; + +function myFunction(node, options) { + // body +} + +// ... more code ... + +export default myFunction; // Too far from top +``` + +## Return Early Pattern + +```javascript +// GOOD: Main path left-aligned, edge cases exit early +export default function processValue(value) { + if (!value) { + return null; + } + + if (value.length < 3) { + throw new Error('Value too short'); + } + + const normalized = normalize(value); + const result = transform(normalized); + return result; +} + +// BAD: Nested conditionals +export default function processValue(value) { + let result; + if (value) { + if (value.length >= 3) { + const normalized = normalize(value); + result = transform(normalized); + } else { + throw new Error('Value too short'); + } + } else { + result = null; + } + return result; +} +``` + +## Import Restrictions + +```javascript +// GOOD: commons importing from core/utils via index +import { getNodeFromTree } from '../../core/utils'; + +// GOOD: commons importing other commons directly +import getExplicitRole from '../aria/get-explicit-role'; + +// BAD: core/utils importing from commons — NEVER DO THIS +import { isDisabled } from '../../commons/forms'; + +// BAD: importing from index in core/utils — use direct path +import { someUtil } from './index'; // Use: import someUtil from './some-util'; +``` + +## JSDoc Comments + +### Standard function + +```javascript +/** + * Determines if an element is a native select element + * @method isNativeSelect + * @memberof axe.commons.forms + * @param {VirtualNode|Element} node Node to determine if select + * @returns {Boolean} + */ +import nodeLookup from '../../core/utils/node-lookup'; + +function isNativeSelect(node) { + const { vNode } = nodeLookup(node); + const nodeName = vNode.props.nodeName; + return nodeName === 'select'; +} +``` + +### Check evaluate function + +```javascript +/** + * Check if an element's `role` attribute uses any abstract role values. + * + * Abstract roles are taken from the `ariaRoles` standards object from the roles `type` property. + * + * ##### Data: + * + * + * + * + * + * + * + * + * + * + * + * + * + *
TypeDescription
String[]List of all abstract roles
+ * + * @memberof checks + * @return {Boolean} True if the element uses an `abstract` role. False otherwise. + */ +function abstractroleEvaluate(node, options, virtualNode) { + // implementation +} +``` + +## Virtual Node vs HTMLElement + +```javascript +// Use Virtual Node for attribute access and property reads +function myCheck(node, options, virtualNode) { + const role = virtualNode.attr('role'); // Cached attribute access + const nodeName = virtualNode.props.nodeName; + + // Only access real node when you need DOM APIs + const rect = node.getBoundingClientRect(); + const rootNode = node.getRootNode(); +} + +// Convert ambiguous input using nodeLookup +import nodeLookup from '../../core/utils/node-lookup'; + +function myFunction(nodeOrVirtual) { + const { vNode, domNode } = nodeLookup(nodeOrVirtual); + // vNode = VirtualNode, domNode = real DOM node +} +``` diff --git a/doc/examples/pr-review-patterns.md b/doc/examples/pr-review-patterns.md new file mode 100644 index 000000000..a56324054 --- /dev/null +++ b/doc/examples/pr-review-patterns.md @@ -0,0 +1,69 @@ +# PR Review Patterns + +Common feedback and anti-patterns observed in axe-core code reviews. + +## What Gets Called Out + +### 1. Missing Tests + +- Every behavior-changing code change needs unit tests +- Rule changes need integration tests (HTML + JSON pair) +- Shadow DOM test coverage required for relevant checks/rules + +### 2. Commit Message Format + +- Wrong type/scope +- Not imperative present tense +- Subject too long or capitalized +- Missing issue reference in footer + +### 3. Import Violations + +- `core/utils` importing from `commons` (forbidden) +- Using index imports where direct file paths are required +- Importing node modules outside `core/imports` + +### 4. Code Style Issues + +- Not using return early pattern +- Default export not at top of file +- Nested conditionals when early return would work +- Missing JSDoc comments + +### 5. Performance Concerns + +- Unnecessary DOM queries in loops +- Not caching Virtual Node properties +- Computing same value repeatedly instead of storing it + +### 6. Incomplete Results + +- Not returning `undefined` when a check can't determine the result +- Missing `incomplete` message variants in check JSON +- Not setting appropriate `this.data()` for incomplete cases + +### 7. Accessibility Edge Cases + +- Not considering all ARIA states +- Missing edge cases for hidden elements +- Not handling Shadow DOM properly + +## What Reviewers Love + +1. Comprehensive test coverage including edge cases +2. Clear, detailed commit messages with motivation +3. JSDoc comments that explain "why" not just "what" +4. Performance-conscious code that caches appropriately +5. Following existing patterns in similar files +6. Integration tests that cover both pass and fail cases + +## Common PR Mistakes + +1. Not running `npm test` locally before pushing +2. Not committing auto-generated `locales/_template.json` in the same commit as message source changes +3. Changing multiple unrelated things in one PR — split refactoring from feature work +4. Not updating integration tests when changing rule behavior +5. `console.log` statements should not be committed +6. Not handling `null` or `undefined` gracefully +7. Hardcoding strings that should come from `standards/` data +8. Changing public APIs without `BREAKING CHANGE` in commit footer diff --git a/doc/examples/rule-check-templates.md b/doc/examples/rule-check-templates.md new file mode 100644 index 000000000..5bfd25109 --- /dev/null +++ b/doc/examples/rule-check-templates.md @@ -0,0 +1,112 @@ +# Rule & Check JSON Templates + +Quick-reference templates for creating axe-core rules and checks. + +## Rule JSON + +```json +{ + "id": "aria-input-field-name", + "impact": "serious", + "selector": "[role=\"textbox\"], [role=\"combobox\"]", + "matches": "no-naming-method-matches", + "tags": ["cat.aria", "wcag2a", "wcag412"], + "metadata": { + "description": "Ensure every ARIA input field has an accessible name", + "help": "ARIA input fields must have an accessible name" + }, + "all": [], + "any": ["aria-label", "aria-labelledby", "non-empty-title"], + "none": ["no-implicit-explicit-label"] +} +``` + +**Key properties:** + +| Property | Description | +| ----------- | ------------------------------------------------------------- | +| `selector` | CSS selector for candidate elements | +| `matches` | Optional function to further filter elements | +| `all` | All checks must pass | +| `any` | At least one check must pass | +| `none` | All checks must fail (for the rule to pass) | +| `impact` | `"minor"`, `"moderate"`, `"serious"`, `"critical"` | +| `enabled` | Defaults to `true`; set `false` for disabled/deprecated rules | +| `tags` | Grouping: `wcag2a`, `wcag2aa`, `best-practice`, `cat.*`, etc. | +| `pageLevel` | `true` if rule should only run on the top-level window | + +## Check JSON + +```json +{ + "id": "aria-allowed-attr", + "evaluate": "aria-allowed-attr-evaluate", + "options": { + "validTreeRowAttrs": ["aria-posinset", "aria-setsize"] + }, + "metadata": { + "impact": "critical", + "messages": { + "pass": "ARIA attributes are used correctly for the defined role", + "fail": { + "singular": "ARIA attribute is not allowed: ${data.values}", + "plural": "ARIA attributes are not allowed: ${data.values}" + }, + "incomplete": "Check that there is no problem if the ARIA attribute is ignored: ${data.values}" + } + } +} +``` + +**Message templates:** + +- Use `${data.property}` for dynamic values set via `this.data()` +- Support singular/plural variants via object with `singular`/`plural` keys +- `locales/_template.json` is auto-generated by `npm run build` — do not edit it manually, but commit the regenerated file alongside source changes + +## Check Evaluate Function + +```javascript +export default function myCheckEvaluate(node, options, virtualNode) { + // node: HTMLElement + // options: from check JSON or runtime config + // virtualNode: Virtual Node representation + + // Early returns for edge cases + if (someEdgeCase) { + return true; // Pass + } + + // Compute what you need + const relevantData = computeStuff(virtualNode); + + // Set data for messages (optional) + // this.data() sets an arbitrary object passed to message templates + if (relevantData.hasIssues) { + this.data({ + messageKey: 'someIssue', // Selects which message variant to use (e.g., singular/plural) + values: relevantData.issueList // ${data.values} in templates resolves arrays + }); + } + + // Return boolean or undefined + if (cannotDetermine) { + this.data({ reason: 'could not determine background color' }); + return undefined; // Incomplete + } + + return isValid; // true = pass, false = fail +} +``` + +**Return values:** + +- `true` — check passed +- `false` — check failed +- `undefined` — incomplete (cannot determine result) + +**`this.data()` usage:** + +- Sets an arbitrary data object that gets passed to message templates via `${data.propertyName}` +- `messageKey` — selects which message variant to use (e.g., `singular`/`plural`) +- Any other keys are available in templates as `${data.keyName}` — arrays are automatically joined diff --git a/doc/examples/test-patterns.md b/doc/examples/test-patterns.md new file mode 100644 index 000000000..e373ee0ce --- /dev/null +++ b/doc/examples/test-patterns.md @@ -0,0 +1,94 @@ +# Test Pattern Examples + +Quick-reference examples for axe-core test conventions. + +## Unit Test — Commons Function + +```javascript +describe('text.sanitize', function () { + it('should collapse whitespace and trim', function () { + assert.equal(axe.commons.text.sanitize('\thi\t'), 'hi'); + assert.equal(axe.commons.text.sanitize('\t\nhi \t'), 'hi'); + assert.equal(axe.commons.text.sanitize('hello\u00A0there'), 'hello there'); + }); + + it('should accept null', function () { + assert.equal(axe.commons.text.sanitize(null), ''); + }); +}); +``` + +## Unit Test — Check Evaluate + +```javascript +describe('aria-allowed-attr', function () { + const fixture = document.getElementById('fixture'); + const checkContext = axe.testUtils.MockCheckContext(); + + afterEach(function () { + checkContext.reset(); + }); + + it('should return true if all ARIA attributes are allowed', function () { + const vNode = queryFixture( + '
' + ); + + assert.isTrue( + axe.testUtils + .getCheckEvaluate('aria-allowed-attr') + .call(checkContext, vNode.actualNode, {}, vNode) + ); + }); +}); +``` + +## Shadow DOM Test + +```javascript +it('should work with Shadow DOM', function () { + const vNode = queryShadowFixture( + '
', + '
Test
' + ); + // Test your function against the shadow DOM content +}); +``` + +## Integration Test — Rule + +Each rule change requires an HTML + JSON pair in `test/integration/rules//` (mocha-hosted) or `test/integration/full//` (full HTML page). Virtual-rules tests in `test/virtual-rules/` should also be updated or created for appropriate rules. See sections below for details. + +### HTML file (`aria-allowed-attr.html`) + +```html +
Valid
+
Invalid
+``` + +### JSON file (`aria-allowed-attr.json`) + +```json +{ + "description": "aria-allowed-attr test", + "rule": "aria-allowed-attr", + "violations": [["#fail1"]], + "passes": [["#pass1"]] +} +``` + +IDs use axe selector array format. For elements inside iframes: + +```json +{ + "violations": [["iframe", "#fail-inside-iframe"]] +} +``` + +### Full-page integration tests + +For rules that need a complete HTML page (e.g., landmark rules, page-level rules), use `test/integration/full/` instead. These tests run against a full HTML document rather than injected fragments. + +### Virtual rules tests + +Rules that can run without a DOM (using only virtual nodes) should have tests in `test/virtual-rules/`. These tests verify that rules work correctly with `axe.run()` on serialized node data. diff --git a/doc/pull-request-checklist.md b/doc/pull-request-checklist.md new file mode 100644 index 000000000..c36a6e05d --- /dev/null +++ b/doc/pull-request-checklist.md @@ -0,0 +1,42 @@ +# axe-core — PR Checklist + +Complete all applicable items before opening a PR. For items that do not apply to this PR (for example, tests for a `chore`-only change), mark them as "N/A" and follow `doc/code-submission-guidelines.md`. Reviewers will not merge until all required boxes are checked. + +## Code + +- [ ] Default export is at the top of the file, immediately after imports +- [ ] Return early pattern used — no nested conditionals where an early return works +- [ ] DocBlock comments where appropriate (especially for public/exported APIs), per `doc/code-submission-guidelines.md` +- [ ] No `console.log` statements committed +- [ ] No hardcoded ARIA/HTML lists — queried from `standards/` via `commons/standards` +- [ ] Imports follow directory restrictions — especially no `commons` from `core/utils` + +## Tests + +- [ ] Unit tests cover all code paths (`null`/`undefined` input handling only needed for public API functions) +- [ ] Integration test HTML + JSON pair added/updated for any rule changes — use `integration/rules` for mocha-hosted tests or `integration/full` when the rule requires a full HTML page +- [ ] Virtual-rules tests updated or created for appropriate rules +- [ ] Shadow DOM test case added where relevant +- [ ] Code built with `npm run build` before testing +- [ ] All applicable tests pass locally: `npm test` (unit), `test:integration`, `test:virtual-rules`, `test:act`, `test:apg`, `test:examples`, `test:node`, `test:jsdom` + +## Formatting & Build + +- [ ] `npm run fmt` passes (Prettier) +- [ ] `npm run eslint` passes +- [ ] `npm run build` run — generated artifacts that are tracked in git (for example `locales/_template.json`) are committed in the same commit as source changes + +## Commits + +- [ ] All commits follow Angular format: `(): ` +- [ ] Subject is imperative, lowercase, no trailing period, ≤100 chars total +- [ ] Commit body explains motivation (not just what changed) +- [ ] Footer includes issue reference: `Closes issue: #123` or full URL +- [ ] Breaking changes avoided — prefer supporting both old and new formats simultaneously. If unavoidable, document in footer: `BREAKING CHANGE: description` + +## Docs + +- [ ] `doc/rule-descriptions.md` is auto-generated by `npm run build` — verify it regenerates correctly for new rules +- [ ] `doc/API.md` and `axe.d.ts` updated for API changes +- [ ] `locales/_template.json` updated if messages changed +- [ ] `CHANGELOG.md` updated with migration guide for breaking changes diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index d00f1344b..472727b55 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -67,7 +67,7 @@ | [object-alt](https://dequeuniversity.com/rules/axe/4.11/object-alt?application=RuleDescription) | Ensure <object> elements have alternative text | Serious | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a, EN-301-549, EN-9.1.1.1, RGAAv4, RGAA-1.1.6 | failure, needs review | [8fc3b6](https://act-rules.github.io/rules/8fc3b6) | | [p-as-heading](https://dequeuniversity.com/rules/axe/4.11/p-as-heading?application=RuleDescription) | Ensure bold, italic text and font-size is not used to style <p> elements as a heading | Serious | cat.semantics, wcag2a, wcag131, EN-301-549, EN-9.1.3.1, a11y-engine, a11y-engine-experimental, RGAAv4, RGAA-9.1.3, high-accuracy | failure, needs review | | | [role-img-alt](https://dequeuniversity.com/rules/axe/4.11/role-img-alt?application=RuleDescription) | Ensure [role="img"] elements have alternative text | Serious | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a, TTv5, TT7.a, EN-301-549, EN-9.1.1.1, ACT, RGAAv4, RGAA-1.1.1 | failure, needs review | [23a2a8](https://act-rules.github.io/rules/23a2a8) | -| [scrollable-region-focusable](https://dequeuniversity.com/rules/axe/4.11/scrollable-region-focusable?application=RuleDescription) | Ensure elements that have scrollable content are accessible by keyboard | Serious | cat.keyboard, wcag2a, wcag211, wcag213, TTv5, TT4.a, EN-301-549, EN-9.2.1.1, EN-9.2.1.3, RGAAv4, RGAA-7.3.2 | failure | [0ssw9k](https://act-rules.github.io/rules/0ssw9k) | +| [scrollable-region-focusable](https://dequeuniversity.com/rules/axe/4.11/scrollable-region-focusable?application=RuleDescription) | Ensure elements that have scrollable content are accessible by keyboard in Safari | Serious | cat.keyboard, wcag2a, wcag211, wcag213, TTv5, TT4.a, EN-301-549, EN-9.2.1.1, EN-9.2.1.3, RGAAv4, RGAA-7.3.2 | failure | [0ssw9k](https://act-rules.github.io/rules/0ssw9k) | | [select-name](https://dequeuniversity.com/rules/axe/4.11/select-name?application=RuleDescription) | Ensure select element has an accessible name | Critical | cat.forms, wcag2a, wcag412, section508, section508.22.n, TTv5, TT5.c, EN-301-549, EN-9.4.1.2, ACT, RGAAv4, RGAA-11.1.1 | failure, needs review | [e086e5](https://act-rules.github.io/rules/e086e5) | | [server-side-image-map](https://dequeuniversity.com/rules/axe/4.11/server-side-image-map?application=RuleDescription) | Ensure that server-side image maps are not used | Minor | cat.text-alternatives, wcag2a, wcag211, section508, section508.22.f, TTv5, TT4.a, EN-301-549, EN-9.2.1.1, RGAAv4, RGAA-1.1.4 | needs review | | | [summary-name](https://dequeuniversity.com/rules/axe/4.11/summary-name?application=RuleDescription) | Ensure summary elements have discernible text | Serious | cat.name-role-value, wcag2a, wcag412, section508, section508.22.a, TTv5, TT6.a, EN-301-549, EN-9.4.1.2 | failure, needs review | | diff --git a/doc/standards-object.md b/doc/standards-object.md index 42766eca3..eb460c179 100644 --- a/doc/standards-object.md +++ b/doc/standards-object.md @@ -95,7 +95,7 @@ The [`htmlElms`](../lib/standards/html-elms.js) object defines valid HTML elemen ### Used by Rules -- `aria-allowed-attr` - Checks if the attribute can be used on the element from the `noAriaAttrs` property. +- `aria-allowed-attr` - Checks if the attribute can be used on the element from the `noAriaAttrs` and `allowedAriaAttrs` properties. - `aria-allowed-role` - Checks if the role can be used on the HTML element from the `allowedRoles` property. - `aria-required-attrs` - Checks if any required attrs are defined implicitly on the element from the `implicitAttrs` property. @@ -110,6 +110,7 @@ The [`htmlElms`](../lib/standards/html-elms.js) object defines valid HTML elemen - `interactive` - `allowedRoles` - boolean or array(required). If element is allowed to use ARIA roles, a value of `true` means any role while a list of roles means only those are allowed. A value of `false` means no roles are allowed. - `noAriaAttrs` - boolean(optional. Defaults `true`). If the element is allowed to use global ARIA attributes and any allowed for the elements role. +- `allowedAriaAttrs` - array(optional). If specified, restricts which ARIA attributes may be used with this element (when no explicit role is set). Used by the `aria-allowed-attr` rule. - `shadowRoot` - boolean(optional. Default `false`). If the element is allowed to have a shadow root. - `implicitAttrs` - object(optional. Default `{}`). Any implicit ARIA attributes for the element and their default value. - `namingMethods` - array(optional. Default `[]`). The [native text method](../lib/commons/text/native-text-methods.js) used to calculate the accessible name of the element. diff --git a/lib/checks/aria/aria-allowed-attr-elm-evaluate.js b/lib/checks/aria/aria-allowed-attr-elm-evaluate.js new file mode 100644 index 000000000..01af3b4f2 --- /dev/null +++ b/lib/checks/aria/aria-allowed-attr-elm-evaluate.js @@ -0,0 +1,45 @@ +import { getExplicitRole } from '../../commons/aria'; +import { getElementSpec, getGlobalAriaAttrs } from '../../commons/standards'; + +export default function ariaAllowedAttrElmEvaluate(node, options, virtualNode) { + const elmSpec = getElementSpec(virtualNode); + + // If no allowedAriaAttrs restriction, this check doesn't apply + if (!elmSpec.allowedAriaAttrs) { + return true; + } + + // If element has an explicit role, defer to the role-based check + const explicitRole = getExplicitRole(virtualNode); + if (explicitRole) { + return true; + } + + const { allowedAriaAttrs } = elmSpec; + const globalAriaAttrs = getGlobalAriaAttrs(); + const invalid = []; + + for (const attrName of virtualNode.attrNames) { + if ( + globalAriaAttrs.includes(attrName) && + !allowedAriaAttrs.includes(attrName) + ) { + invalid.push(attrName); + } + } + + if (!invalid.length) { + return true; + } + + const messageKey = invalid.length > 1 ? 'plural' : 'singular'; + this.data({ + messageKey, + nodeName: virtualNode.props.nodeName, + values: invalid + .map(attrName => attrName + '="' + virtualNode.attr(attrName) + '"') + .join(', ') + }); + + return false; +} diff --git a/lib/checks/aria/aria-allowed-attr-elm.json b/lib/checks/aria/aria-allowed-attr-elm.json new file mode 100644 index 000000000..ebbdf67e9 --- /dev/null +++ b/lib/checks/aria/aria-allowed-attr-elm.json @@ -0,0 +1,13 @@ +{ + "id": "aria-allowed-attr-elm", + "evaluate": "aria-allowed-attr-elm-evaluate", + "metadata": { + "messages": { + "pass": "ARIA attributes are allowed for this element", + "fail": { + "singular": "ARIA attribute is not allowed on ${data.nodeName} elements: ${data.values}", + "plural": "ARIA attributes are not allowed on ${data.nodeName} elements: ${data.values}" + } + } + } +} diff --git a/lib/checks/aria/aria-errormessage-evaluate.js b/lib/checks/aria/aria-errormessage-evaluate.js index 2ef53f35e..977b2cce7 100644 --- a/lib/checks/aria/aria-errormessage-evaluate.js +++ b/lib/checks/aria/aria-errormessage-evaluate.js @@ -42,6 +42,12 @@ export default function ariaErrormessageEvaluate(node, options, virtualNode) { if (attr.trim() === '') { return standards.ariaAttrs['aria-errormessage'].allowEmpty; } + + const errormessageTokens = tokenList(attr); + if (errormessageTokens.length > 1) { + this.data({ messageKey: 'unsupported', values: errormessageTokens }); + return false; + } let idref; try { @@ -49,7 +55,7 @@ export default function ariaErrormessageEvaluate(node, options, virtualNode) { } catch { this.data({ messageKey: 'idrefs', - values: tokenList(attr) + values: errormessageTokens }); return undefined; } @@ -58,15 +64,16 @@ export default function ariaErrormessageEvaluate(node, options, virtualNode) { if (!isVisibleToScreenReaders(idref)) { this.data({ messageKey: 'hidden', - values: tokenList(attr) + values: errormessageTokens }); return false; } + const describedbyTokens = tokenList(virtualNode.attr('aria-describedby')); return ( getExplicitRole(idref) === 'alert' || idref.getAttribute('aria-live') === 'assertive' || idref.getAttribute('aria-live') === 'polite' || - tokenList(virtualNode.attr('aria-describedby')).indexOf(attr) > -1 + errormessageTokens.some(token => describedbyTokens.includes(token)) ); } diff --git a/lib/checks/aria/aria-errormessage.json b/lib/checks/aria/aria-errormessage.json index e238d7f65..bfc90e7f6 100644 --- a/lib/checks/aria/aria-errormessage.json +++ b/lib/checks/aria/aria-errormessage.json @@ -8,6 +8,7 @@ "fail": { "singular": "aria-errormessage value `${data.values}` must use a technique to announce the message (e.g., aria-live, aria-describedby, role=alert, etc.)", "plural": "aria-errormessage values `${data.values}` must use a technique to announce the message (e.g., aria-live, aria-describedby, role=alert, etc.)", + "unsupported": "Multiple IDs in aria-errormessage is not widely supported in assistive technologies", "hidden": "aria-errormessage value `${data.values}` cannot reference a hidden element" }, "incomplete": { diff --git a/lib/checks/aria/aria-required-children-evaluate.js b/lib/checks/aria/aria-required-children-evaluate.js index 1e7512d96..403cd4979 100644 --- a/lib/checks/aria/aria-required-children-evaluate.js +++ b/lib/checks/aria/aria-required-children-evaluate.js @@ -41,8 +41,11 @@ export default function ariaRequiredChildrenEvaluate( if (unallowed.length) { this.relatedNodes(unallowed.map(({ vNode }) => vNode)); + const messageKey = + virtualNode.attr('aria-busy') === 'true' ? 'aria-busy-fail' : 'unallowed'; + this.data({ - messageKey: 'unallowed', + messageKey, values: unallowed .map(({ vNode, attr }) => getUnallowedSelector(vNode, attr)) .filter((selector, index, array) => array.indexOf(selector) === index) diff --git a/lib/checks/aria/aria-required-children.json b/lib/checks/aria/aria-required-children.json index 16c67dbd5..016f56b5e 100644 --- a/lib/checks/aria/aria-required-children.json +++ b/lib/checks/aria/aria-required-children.json @@ -27,7 +27,8 @@ "fail": { "singular": "Required ARIA child role not present: ${data.values}", "plural": "Required ARIA children role not present: ${data.values}", - "unallowed": "Element has children which are not allowed: ${data.values}" + "unallowed": "Element has children which are not allowed: ${data.values}", + "aria-busy-fail": "Element has children which are not allowed: ${data.values}; Having aria-busy=\"true\" does not allow children with roles that are not allowed" }, "incomplete": { "singular": "Expecting ARIA child role to be added: ${data.values}", diff --git a/lib/checks/mobile/target-size-evaluate.js b/lib/checks/mobile/target-size-evaluate.js index 1fb76e775..a87935d54 100644 --- a/lib/checks/mobile/target-size-evaluate.js +++ b/lib/checks/mobile/target-size-evaluate.js @@ -66,7 +66,11 @@ export default function targetSizeEvaluate(node, options, vNode) { return true; } - const largestInnerRect = getLargestUnobscuredArea(vNode, obscuredWidgets); + const largestInnerRect = getLargestUnobscuredArea( + vNode, + obscuredWidgets, + minSize + ); if (!largestInnerRect) { this.data({ minSize, messageKey: 'tooManyRects' }); return undefined; @@ -127,11 +131,16 @@ function filterByElmsOverlap(vNode, nearbyElms) { } // Find areas of the target that are not obscured -function getLargestUnobscuredArea(vNode, obscuredNodes) { +function getLargestUnobscuredArea(vNode, obscuredNodes, minSize) { const nodeRect = vNode.boundingClientRect; - const obscuringRects = obscuredNodes.map( - ({ boundingClientRect: rect }) => rect - ); + const obscuringRects = obscuredNodes + .map(obscuredNode => { + const display = obscuredNode.getComputedStylePropertyValue('display'); + return display === 'inline' + ? obscuredNode.clientRects + : obscuredNode.boundingClientRect; + }) + .flat(Infinity); let unobscuredRects; try { unobscuredRects = splitRects(nodeRect, obscuringRects); @@ -140,7 +149,7 @@ function getLargestUnobscuredArea(vNode, obscuredNodes) { } // Of the unobscured inner rects, work out the largest - return getLargestRect(unobscuredRects); + return getLargestRect(unobscuredRects, minSize); } // Find the largest rectangle in the array, prioritize ones that meet a minimum size diff --git a/lib/commons/aria/get-owned-virtual.js b/lib/commons/aria/get-owned-virtual.js index 5b24b7bdb..c65bc9ae9 100644 --- a/lib/commons/aria/get-owned-virtual.js +++ b/lib/commons/aria/get-owned-virtual.js @@ -18,7 +18,15 @@ function getOwnedVirtual(virtualNode) { const owns = idrefs(actualNode, 'aria-owns') .filter(element => !!element) .map(element => axe.utils.getNodeFromTree(element)); - return [...children, ...owns]; + + // Deduplicates by first occurrence to match browser accessibility tree behavior + // See: https://github.com/dequelabs/axe-core/pull/4987 + const uniqueOwns = owns.filter((own, index) => owns.indexOf(own) === index); + const nativeChildren = children.filter( + child => !uniqueOwns.includes(child) + ); + + return [...nativeChildren, ...uniqueOwns]; } return [...children]; diff --git a/lib/commons/dom/find-nearby-elms.js b/lib/commons/dom/find-nearby-elms.js index a0e421137..b6e69e52b 100644 --- a/lib/commons/dom/find-nearby-elms.js +++ b/lib/commons/dom/find-nearby-elms.js @@ -1,5 +1,5 @@ import getNodeGrid from './get-node-grid'; -import { memoize } from '../../core/utils'; +import isFixedPosition from './is-fixed-position'; export default function findNearbyElms(vNode, margin = 0) { const grid = getNodeGrid(vNode); @@ -7,7 +7,7 @@ export default function findNearbyElms(vNode, margin = 0) { return []; // Elements not in the grid don't have ._grid } const rect = vNode.boundingClientRect; - const selfIsFixed = hasFixedPosition(vNode); + const selfIsFixed = isFixedPosition(vNode); const gridPosition = grid.getGridPositionOfRect(rect, margin); const neighbors = []; @@ -17,7 +17,7 @@ export default function findNearbyElms(vNode, margin = 0) { vNeighbor && vNeighbor !== vNode && !neighbors.includes(vNeighbor) && - selfIsFixed === hasFixedPosition(vNeighbor) + selfIsFixed === isFixedPosition(vNeighbor) ) { neighbors.push(vNeighbor); } @@ -26,13 +26,3 @@ export default function findNearbyElms(vNode, margin = 0) { return neighbors; } - -const hasFixedPosition = memoize(vNode => { - if (!vNode) { - return false; - } - if (vNode.getComputedStylePropertyValue('position') === 'fixed') { - return true; - } - return hasFixedPosition(vNode.parent); -}); diff --git a/lib/commons/dom/get-target-rects.js b/lib/commons/dom/get-target-rects.js index a2bc2eec1..ebebb2c7f 100644 --- a/lib/commons/dom/get-target-rects.js +++ b/lib/commons/dom/get-target-rects.js @@ -13,7 +13,9 @@ export default memoize(getTargetRects); * @return {DOMRect[]} */ function getTargetRects(vNode) { - const nodeRect = vNode.boundingClientRect; + const display = vNode.getComputedStylePropertyValue('display'); + const nodeRects = + display === 'inline' ? vNode.clientRects : [vNode.boundingClientRect]; const overlappingVNodes = findNearbyElms(vNode).filter(vNeighbor => { return ( hasVisualOverlap(vNode, vNeighbor) && @@ -23,13 +25,19 @@ function getTargetRects(vNode) { }); if (!overlappingVNodes.length) { - return [nodeRect]; + return nodeRects; } - const obscuringRects = overlappingVNodes.map( - ({ boundingClientRect: rect }) => rect - ); - return splitRects(nodeRect, obscuringRects); + const obscuringRects = overlappingVNodes + .map(overlappingVNode => { + const overlappingDisplay = + overlappingVNode.getComputedStylePropertyValue('display'); + return overlappingDisplay === 'inline' + ? overlappingVNode.clientRects + : overlappingVNode.boundingClientRect; + }) + .flat(Infinity); + return splitRects(nodeRects, obscuringRects); } function isDescendantNotInTabOrder(vAncestor, vNode) { diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js index 8e7c34414..f8ee6c510 100644 --- a/lib/commons/dom/index.js +++ b/lib/commons/dom/index.js @@ -29,6 +29,7 @@ export { default as hasLangText } from './has-lang-text'; export { default as idrefs } from './idrefs'; export { default as insertedIntoFocusOrder } from './inserted-into-focus-order'; export { default as isCurrentPageLink } from './is-current-page-link'; +export { default as isFixedPosition } from './is-fixed-position'; export { default as isFocusable } from './is-focusable'; export { default as isHiddenWithCSS } from './is-hidden-with-css'; export { default as isHiddenForEveryone } from './is-hidden-for-everyone'; diff --git a/lib/commons/dom/is-fixed-position.js b/lib/commons/dom/is-fixed-position.js new file mode 100644 index 000000000..d065694cf --- /dev/null +++ b/lib/commons/dom/is-fixed-position.js @@ -0,0 +1,45 @@ +import memoize from '../../core/utils/memoize'; +import { nodeLookup } from '../../core/utils'; + +/** + * Determines if an element is inside a position:fixed subtree, even if the element itself is positioned differently. + * @param {VirtualNode|Element} node + * @param {Boolean} [options.skipAncestors] If the ancestor tree should not be used + * @return {Boolean} The element's position state + */ +export default function isFixedPosition(node, { skipAncestors } = {}) { + const { vNode } = nodeLookup(node); + + // detached element + if (!vNode) { + return false; + } + + if (skipAncestors) { + return isFixedSelf(vNode); + } + + return isFixedAncestors(vNode); +} + +/** + * Check the element for position:fixed + */ +const isFixedSelf = memoize(function isFixedSelfMemoized(vNode) { + return vNode.getComputedStylePropertyValue('position') === 'fixed'; +}); + +/** + * Check the element and ancestors for position:fixed + */ +const isFixedAncestors = memoize(function isFixedAncestorsMemoized(vNode) { + if (isFixedSelf(vNode)) { + return true; + } + + if (!vNode.parent) { + return false; + } + + return isFixedAncestors(vNode.parent); +}); diff --git a/lib/commons/dom/is-in-text-block.js b/lib/commons/dom/is-in-text-block.js index e00732772..74500ce5f 100644 --- a/lib/commons/dom/is-in-text-block.js +++ b/lib/commons/dom/is-in-text-block.js @@ -1,43 +1,11 @@ import getComposedParent from './get-composed-parent'; import sanitize from '../text/sanitize'; -import { getNodeFromTree } from '../../core/utils'; +import { getNodeFromTree, nodeLookup } from '../../core/utils'; import getRoleType from '../aria/get-role-type'; -function walkDomNode(node, functor) { - if (functor(node.actualNode) !== false) { - // a11y-critical : avoid processing STYLE elements - if ( - functor(node.actualNode) !== false && - node.actualNode.nodeName !== 'STYLE' - ) { - node.children.forEach(child => walkDomNode(child, functor)); - } - } -} +const blockLike = ['block', 'list-item', 'table', 'flex', 'grid']; -const blockLike = [ - 'block', - 'list-item', - 'table', - 'flex', - 'grid', - 'inline-block' -]; - -function isBlock(elm) { - const display = window.getComputedStyle(elm).getPropertyValue('display'); - return blockLike.includes(display) || display.substr(0, 6) === 'table-'; -} - -function getBlockParent(node) { - // Find the closest parent - let parentBlock = getComposedParent(node); - while (parentBlock && !isBlock(parentBlock)) { - parentBlock = getComposedParent(parentBlock); - } - - return getNodeFromTree(parentBlock); -} +const inlineBlockLike = ['inline-block', 'inline-flex', 'inline-grid']; /** * Determines if an element is within a text block @@ -47,16 +15,21 @@ function getBlockParent(node) { * @param {Element} node [description] * @param {Object} options Optional * @property {Bool} noLengthCompare + * @property {Bool} includeInlineBlock * @return {Boolean} [description] */ -function isInTextBlock(node, options) { - if (isBlock(node)) { - // Ignore if the link is a block +function isInTextBlock( + node, + { noLengthCompare, includeInlineBlock = false } = {} +) { + const { vNode, domNode } = nodeLookup(node); + if (isBlock(domNode) || (!includeInlineBlock && isInlineBlockLike(vNode))) { + // Ignore if the element is a block return false; } // Find all the text part of the parent block not in a link, and all the text in a link - const virtualParent = getBlockParent(node); + const virtualParent = getBlockParent(domNode); let parentText = ''; let widgetText = ''; let inBrBlock = 0; @@ -64,6 +37,10 @@ function isInTextBlock(node, options) { // We want to ignore hidden text, and if br / hr is used, only use the section of the parent // that has the link we're looking at walkDomNode(virtualParent, currNode => { + if (currNode === virtualParent.actualNode) { + return true; // Skip the element itself + } + // We're already passed it, skip everything else if (inBrBlock === 2) { return false; @@ -79,11 +56,12 @@ function isInTextBlock(node, options) { } const nodeName = (currNode.nodeName || '').toUpperCase(); - if (currNode === node) { + if (currNode === domNode) { inBrBlock = 1; } + const nodeIsBlock = isBlock(currNode); // BR and HR elements break the line - if (['BR', 'HR'].includes(nodeName)) { + if (nodeIsBlock || ['BR', 'HR'].includes(nodeName)) { if (inBrBlock === 0) { parentText = ''; widgetText = ''; @@ -92,7 +70,9 @@ function isInTextBlock(node, options) { } // Don't walk nodes with content not displayed on screen. - } else if ( + } + if ( + nodeIsBlock || currNode.style.display === 'none' || currNode.style.overflow === 'hidden' || !['', null, 'none'].includes(currNode.style.float) || @@ -109,7 +89,7 @@ function isInTextBlock(node, options) { }); parentText = sanitize(parentText); - if (options?.noLengthCompare) { + if (noLengthCompare) { return parentText.length !== 0; } @@ -118,3 +98,35 @@ function isInTextBlock(node, options) { } export default isInTextBlock; + +function isBlock(node) { + const { vNode } = nodeLookup(node); + const display = vNode.getComputedStylePropertyValue('display'); + return blockLike.includes(display) || display.substr(0, 6) === 'table-'; +} + +function isInlineBlockLike(vNode) { + const display = vNode.getComputedStylePropertyValue('display'); + return inlineBlockLike.includes(display); +} + +function walkDomNode(node, functor) { + // a11y-critical : do not descend into +
+

+ This is a in a + sentence. +

+ +
+

- x + + x