Skip to content

Commit 047714d

Browse files
Add first-class DOM support (#38)
* Add first-class DOM support * Update README.md Co-authored-by: Craig Morten <124147726+jlp-craigmorten@users.noreply.github.com> * Update JSDocs * Update README.md --------- Co-authored-by: Craig Morten <124147726+jlp-craigmorten@users.noreply.github.com>
1 parent 31a1268 commit 047714d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+3614
-947
lines changed

.changeset/brown-pumas-design.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"html-aria": minor
3+
---
4+
5+
⚠️ Breaking API changes:
6+
7+
- `getRole()` now returns full role data, rather than a string. To achieve the same result, access the `name` property:
8+
```diff
9+
- getRole({ tagName: 'button' })
10+
+ getRole({ tagName: 'button' })?.name
11+
```

.changeset/great-shrimps-turn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"html-aria": patch
3+
---
4+
5+
fix: Performance improvements for DOM API

.changeset/moody-humans-rhyme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"html-aria": patch
3+
---
4+
5+
feat: Add presentationalChildren property to RoleData

.changeset/tasty-emus-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"html-aria": patch
3+
---
4+
5+
Fix: all methods are now runnable in a DOM or DOM-like environment

.changeset/tiny-cameras-drop.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"html-aria": minor
3+
---
4+
5+
⚠️ Breaking change: Node API now requires all attributes.
6+
7+
**Attributes**
8+
9+
In the previous version, `<a>` and `<area>` would assume `href` was set, unless you passed in an explicit `attributes: {}` object. However, in expanding the DOM API this inconsistency in behavior led to problems. Now both versions behave the same way in regards to attributes: an attribute is assumed **NOT** to exist unless passed in.
10+
11+
**Ancestors**
12+
13+
This behavior is largely-unchanged, however, some small improvements have been made.
14+
15+
_Note: the DOM version will automatically traverse the DOM for you, and automatically reads all attributes. This change only affects the Node API where the DOM is unavailable._

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ jobs:
3838
version: latest
3939
run_install: true
4040
- run: pnpm run build
41+
- run: pnpm exec playwright install
4142
- run: pnpm test
4243
test-macos:
4344
runs-on: macos-latest
@@ -51,6 +52,7 @@ jobs:
5152
version: latest
5253
run_install: true
5354
- run: pnpm run build
55+
- run: pnpm exec playwright install
5456
- run: pnpm test
5557
test-windows:
5658
runs-on: windows-latest
@@ -64,4 +66,5 @@ jobs:
6466
version: latest
6567
run_install: true
6668
- run: pnpm run build
69+
- run: pnpm exec playwright install
6770
- run: pnpm test

README.md

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,29 @@ Utilities for creating accessible HTML based on the [latest ARIA 1.3 specs](http
44

55
This is designed to be a better replacement for aria-query when working with HTML. The reasons are:
66

7-
- aria-query neglects the critical [HTML to ARIA spec](https://www.w3.org/TR/html-aria). With just the ARIA spec alone, it’s insufficient for working with HTML.
7+
- html-aria is designed to reduce mistakes, while aria-query’s APIs are easy to “hold it wrong.” The information may not be _incorrect_, but often are locked behind several successful operations you must know to connect to get the right result.
8+
- html-aria and aria-query both follow the [ARIA 1.3 spec](https://w3c.github.io/aria/), but that’s only one part. There are also the [HTML Accessibility API Mappings](https://www.w3.org/TR/html-aam-1.0/) and [HTML to ARIA](https://www.w3.org/TR/html-aria) specs that are critical to working with HTML. While aria-query follows these other documents when it can, its design makes it difficult to apply the advice from all specs, often producing incomplete or incorrect results.
89
- html-aria supports ARIA 1.3 while aria-query is still on ARIA 1.2
910

10-
html-aria is also designed to be easier-to-use to prevent mistakes, smaller, is ESM tree-shakeable, and more performant (~100× faster than aria-query).
11+
html-aria is also ESM-compatible and [more performant](./test/node/html-aria.bench.ts).
1112

12-
## Setup
13+
## Usage
14+
15+
### Setup
1316

1417
```sh
1518
npm i html-aria
1619
```
1720

18-
## Examples
21+
### Environments
22+
23+
This library works both in Node.js and the browser. But works best **when the DOM is accessible**, either the actual DOM or a virtualized one like JSDOM. The reason is the spec requires DOM traversal—identifying an element’s context in parents and children, as well as attributes of the element. In a DOM environment, html-aria will do all the work for you; in Node.js you must provide complete information about attributes, and sometimes ancestors.
24+
25+
### Examples
1926

2027
Though this library is NOT a lint plugin, it can do most of the work for you. You only need to traverse the AST of the language you’re using (e.g. HTML vs React vs Svelte), and html-aria can validate the nodes.
2128

22-
### ESLint + React plugin
29+
#### Node.js (ESLint + React plugin)
2330

2431
```ts
2532
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
@@ -110,7 +117,11 @@ Determine which HTML maps to which default ARIA role.
110117
```ts
111118
import { getRole } from "html-aria";
112119

113-
getRole(document.createElement("article")); // "article"
120+
// DOM
121+
const el = document.querySelector('article')
122+
getRole(el); // "article"
123+
124+
// Node.js (no DOM)
114125
getRole({ tagName: "input", attributes: { type: "checkbox" } }); // "checkbox"
115126
getRole({ tagName: "div", attributes: { role: "button" } }); // "button"
116127
```
@@ -130,7 +141,11 @@ The spec dictates that **certain elements may NOT receive certain roles.** For e
130141
```ts
131142
import { getSupportedRoles } from "html-aria";
132143

133-
getSupportedRoles(document.createElement("img")); // ["none", "presentation", "img"]
144+
// DOM
145+
const el = document.querySelector('img')
146+
getSupportedRoles(el); // ["none", "presentation", "img"]
147+
148+
// Node.js (no DOM)
134149
getSupportedRoles({ tagName: "img", attributes: { alt: "Image caption" } }); // ["button", "checkbox", "link", (15 more)]
135150
```
136151

@@ -340,11 +355,17 @@ SVG is tricky. Though the [spec says](https://www.w3.org/TR/html-aria/#el-svg) `
340355

341356
Since we have 1 spec and 1 browser agreeing, this library defaults to `graphics-document`. Though the best answer is _SVGs should ALWAYS get an explicit `role`_.
342357

343-
#### Ancestor-based roles
358+
### Node.js vs DOM behavior
359+
360+
#### Node.js ignores necessary ancestor-based roles
361+
362+
There are 2 categories of context-dependent element usage: **necessary** and **conditional**.
363+
364+
“Necessary“ context elements require certain parents to use correctly, like table-based elements (`<tr>`, `<td>`, `<th>`, etc.) requiring table parents (`<table>`, `<table role="grid">`, etc.) and list-based elements `<li>` requiring list parents (`<ol>`, `<ul>`, `<menu>`, etc.). Without their parents, they have no purpose and their behavior is unpredictable, with some browsers even stripping elements out of the DOM. These elements will 99% of the time be used in their intended contexts.
344365

345-
In regards to [ARIA roles in HTML](#aria-roles-from-html), the spec gives non-semantic roles to `<td>`, `<th>`, and `<li>` UNLESS they are used inside specific containers (`table`, `grid`, or `gridcell` for `<td>`/`<th>`; `list` or `menu` for `<li>`). This library assumes they’re being used in their proper containers without requiring the `ancestors` array. This is done to avoid the [footgun](https://en.wiktionary.org/wiki/footgun) of requiring missable configuration to produce accurate results, which is bad software design.
366+
The DOM environment follows the ARIA spec. But in a Node.js context, it’s likely we are statically analyzing a component where the parents aren’t immediatly reachable—they may be in another file. If we assume the elements are used correctly even when we can’t see the ancestors, we can show more accurate errors and warnings, rather than requiring the consumer to do work that is technically and computationally difficult.
346367

347-
Instead, the non-semantic roles must be “opted in” by passing an explicitly-empty ancestors array:
368+
So for the reasons above, assuming the elements are used out of context is more likely to result in less predictable behavior that could lead to mistakes. To treat elements as if they _are_ used out of their context in Node.js, pass an empty `ancestors` array as an explicit way to declare it.
348369

349370
```ts
350371
import { getRole } from "html-aria";
@@ -354,6 +375,19 @@ getRole({ tagName: "th" }, { ancestors: [] }); // undefined
354375
getRole({ tagName: "li" }, { ancestors: [] }); // "generic"
355376
```
356377

378+
These are all the elements that have assumed context (i.e. different behavior in Node.js): `<col>`, `<colgroup>`, `<caption>`, `<li>`, `<rowgroup>`, `<tbody>`, `<td>`, `<tfoot>`, `<th>`, `<thead>`, `<tr>`.
379+
380+
“Conditional” context elements may either have certain parents or not, all of which are valid. `<aside>` used in the body is a landmark `complementary` role; inside a `<section>` it’s `generic` (unless it has an accessible name, then it’s `complementary` again). `<header>` is a `banner` landmark itself, or inside another landmark is `generic`. Since there’s no “wrong” usage here, In Node.js they behave as expected, so they don’t deviate from DOM behavior or the spec.
381+
382+
```ts
383+
import { getRole } from "html-aria";
384+
385+
getRole({ tagName: "header" }); // "banner"
386+
getRole({ tagName: "header" }, { ancestors: [{ tagName: "main" }]); // "generic"
387+
getRole({ tagName: "aside" }); // "complementary"
388+
getRole({ tagName: "aside" }, { ancestors: [{ tagName: "section" }] } }); // "generic"
389+
```
390+
357391
### FAQ
358392
359393
#### Why the `{ tagName: string }` object syntax?

biome.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,13 @@
2020
"linter": {
2121
"enabled": true,
2222
"rules": {
23-
"recommended": true
23+
"recommended": true,
24+
"style": {
25+
"noNonNullAssertion": "off"
26+
},
27+
"nursery": {
28+
"useGuardForIn": "error"
29+
}
2430
}
2531
},
2632
"javascript": {

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,12 @@
5252
"@changesets/changelog-github": "^0.5.0",
5353
"@changesets/cli": "^2.27.12",
5454
"@types/aria-query": "^5.0.4",
55+
"@vitest/browser": "^3.0.4",
5556
"aria-query": "^5.3.2",
5657
"dom-accessibility-api": "^0.7.0",
58+
"happy-dom": "^16.7.3",
5759
"jsdom": "^25.0.1",
60+
"playwright": "^1.50.0",
5861
"typescript": "^5.7.3",
5962
"unbuild": "^3.3.1",
6063
"vitest": "^3.0.4"

0 commit comments

Comments
 (0)