Skip to content

Commit

Permalink
Add SVG AAM spec (#42)
Browse files Browse the repository at this point in the history
* Add SVG AAM

* Add SVG AAM
  • Loading branch information
drwpow authored Feb 10, 2025
1 parent eedfa8f commit c000339
Show file tree
Hide file tree
Showing 24 changed files with 1,238 additions and 234 deletions.
5 changes: 5 additions & 0 deletions .changeset/chatty-vans-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"html-aria": patch
---

Incorporate the [SVG AAM spec](https://www.w3.org/TR/svg-aam-1.0) and add tests.
5 changes: 5 additions & 0 deletions .changeset/twelve-avocados-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"html-aria": minor
---

⚠️ Breaking change: aria- attribute data now matches ARIA spec, e.g. `enum` (unique type) replaced with `token` (described in ARIA 1.3).
33 changes: 16 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,18 @@ isInteractive({
}); // true (see https://www.w3.org/TR/wai-aria-1.3/#separator)
```

The methodology for this is somewhat complex to follow the complete ARIA specification:
> ![WARNING]
>
> This doesn’t check for `display: none`, which could be applied with CSS that would make any element non-interactive.
The methodology for this follows the complete ARIA specification:

1. If the role is a [widget](https://www.w3.org/TR/wai-aria-1.3/#widget_roles) or [window](https://www.w3.org/TR/wai-aria-1.3/#window_roles) subclass, then it is interactive
- Note: if the element manually specifies `role`, and if it natively is NOT a widget or window role, `tabindex` must also be supplied
- If the element manually specifies `role`, and if it natively is NOT a widget or window role, `tabindex` must also be supplied
1. If the element is `disabled` or `aria-disabled`, then it is NOT interactive
1. Handle some explicit edge cases like [separator](https://www.w3.org/TR/wai-aria-1.3/#separator)

Note that `aria-hidden` elements may be interactive (even if it’s not best practice) as a part of [2.4.5 Multiple Ways](https://www.w3.org/WAI/WCAG21/Understanding/multiple-ways.html) if an alternative is made for screenreaders, etc.
Note that `aria-hidden` elements MAY be interactive (even if it’s not best practice) as a part of [2.4.5 Multiple Ways](https://www.w3.org/WAI/WCAG21/Understanding/multiple-ways.html) if an alternative is made for screenreaders, etc.

### isNameRequired()

Expand Down Expand Up @@ -345,21 +349,16 @@ _Note: `—` = [no corresponding role](#whats-the-difference-between-no-correspo

Though the [HTML in ARIA](https://www.w3.org/TR/html-aria) spec was the foundation for this library, at points it conflicts with [AAM](https://www.w3.org/TR/html-aam-1.0). We also have browsers sometimes showing inconsistent roles, too. For these discrepancies, we compare what the specs recommend, along with the library’s current decision in an attempt to follow the most helpful path.

| Element | [HTML in ARIA](https://www.w3.org/TR/html-aria) | [AAM](https://www.w3.org/TR/html-aam-1.0) | Browsers\* | html-aria |
| :------------- | :---------------------------------------------- | :---------------------------------------- | :------------------------------- | --------------------- |
| `<dd>` | No corresponding role | definition | definition | definition |
| `<dl>` | No corresponding role | list | (inconsistent) | No corresponding role |
| `<dt>` | No corresponding role | term | term | term |
| `<figcaption>` | No corresponding role | caption | caption (`Figcaption` in Chrome) | caption |
| `<mark>` | No corresponding role | mark | mark | mark |

_\* Chrome 132, Safari 18, Firefox 135. _

#### SVG

SVG is tricky. Though the [spec says](https://www.w3.org/TR/html-aria/#el-svg) `<svg>` should get the `graphics-document` role by default, browsers chose chaos. Firefox 134 displays `graphics-document`, Chrome 131 defaults to `image` (previously it returned nothing, or other roles), and Safari defaults to `generic` (which is one of the worst roles you could probably give it).
| Element | [HTML in ARIA](https://www.w3.org/TR/html-aria) | [AAM](https://www.w3.org/TR/html-aam-1.0) | Browsers\* | html-aria |
| :------------- | :---------------------------------------------- | :---------------------------------------- | :---------------------------------------------------------------- | --------------------- |
| `<dd>` | No corresponding role | `definition` | `definition` | `definition` |
| `<dl>` | No corresponding role | `list` | (inconsistent) | No corresponding role |
| `<dt>` | No corresponding role | `term` | `term` | `term` |
| `<figcaption>` | No corresponding role | `caption` | `caption` (`Figcaption` in Chrome) | `caption` |
| `<mark>` | No corresponding role | `mark` | `mark` | `mark` |
| `<svg>` | `graphics-document` | `graphics-document` | `graphics-document` (Firefox), `img` (Chrome), `generic` (Safari) | `graphics-document` |

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`_.
_\* Chrome 132, Safari 18, Firefox 135._

### Node.js vs DOM behavior

Expand Down
8 changes: 4 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 26 additions & 7 deletions src/get-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getHeaderRole } from './tags/header.js';
import { getInputRole } from './tags/input.js';
import { getLIRole } from './tags/li.js';
import { getSelectRole } from './tags/select.js';
import { getSvgElementRole } from './tags/svg.js';
import { getTDRole } from './tags/td.js';
import { getTHRole } from './tags/th.js';
import type { VirtualAncestorList, VirtualElement } from './types.js';
Expand Down Expand Up @@ -52,6 +53,7 @@ export interface GetRoleOptions {
*/
export function getRole(element: Element | VirtualElement, options?: GetRoleOptions): RoleData | undefined {
const tagName = getTagName(element);
const tagData = tags[tagName];
const role = attr(element, 'role') as string | undefined;

// explicit role: use if valid
Expand All @@ -64,22 +66,22 @@ export function getRole(element: Element | VirtualElement, options?: GetRoleOpti
return roles[firstRole!];
}

const tag = tags[tagName];

// If custom element (unknown HTML element), assume generic
if (!tag) {
if (!tagData) {
return roles.generic;
}

const defaultRole = roles[tagData.defaultRole!];

switch (tagName) {
case 'a':
case 'area': {
const href = attr(element, 'href');
return typeof href === 'string' ? roles[tag.defaultRole!] : roles.generic;
return typeof href === 'string' ? defaultRole : roles.generic;
}
case 'aside': {
const name = calculateAccessibleName(element, roles.complementary);
return name ? roles[tag.defaultRole!] : getAsideRole(element, options);
return name ? defaultRole : getAsideRole(element, options);
}
case 'header': {
return getHeaderRole(element, options);
Expand All @@ -99,7 +101,7 @@ export function getRole(element: Element | VirtualElement, options?: GetRoleOpti
}
case 'section': {
const name = calculateAccessibleName(element, roles.region);
return name ? roles[tag.defaultRole!] : roles.generic;
return name ? defaultRole : roles.generic;
}
case 'select': {
return getSelectRole(element);
Expand All @@ -113,7 +115,24 @@ export function getRole(element: Element | VirtualElement, options?: GetRoleOpti
case 'tr': {
return roles.row;
}

// @see https://www.w3.org/TR/svg-aam-1.0/#include_elements
case 'circle':
case 'ellipse':
case 'foreignObject':
case 'g':
case 'image':
case 'line':
case 'path':
case 'polygon':
case 'polyline':
case 'rect':
case 'textPath':
case 'tspan':
case 'use': {
return getSvgElementRole(element);
}
}

return roles[tag.defaultRole!];
return defaultRole;
}
65 changes: 47 additions & 18 deletions src/get-supported-attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { type GetRoleOptions, getRole } from './get-role.js';
import { attributes, globalAttributes } from './lib/aria-attributes.js';
import { roles } from './lib/aria-roles.js';
import { tags } from './lib/html.js';
import { attr, calculateAccessibleName, concatDedupeAndSort, getTagName, removeProhibited } from './lib/util.js';
import {
attr,
calculateAccessibleName,
concatDedupeAndSort,
getTagName,
parseTokenList,
removeProhibited,
} from './lib/util.js';
import type { ARIAAttribute, VirtualElement } from './types.js';

const GLOBAL_ATTRIBUTES = Object.keys(globalAttributes) as ARIAAttribute[];
Expand All @@ -11,21 +18,20 @@ const GLOBAL_ATTRIBUTES = Object.keys(globalAttributes) as ARIAAttribute[];
* Given an ARIA role returns a list of supported/inherited aria-* attributes.
*/
export function getSupportedAttributes(element: Element | VirtualElement, options?: GetRoleOptions): ARIAAttribute[] {
const role = getRole(element, options);
const roleData = roles[role?.name!];
const tagName = getTagName(element);
const tag = tags[tagName];
if (!tag) {
return [];
const tagData = tags[tagName];
if (!tagData) {
return roleData?.supported ?? GLOBAL_ATTRIBUTES;
}

// Note: DON’T check for length! Often an empty array is used
// to mean “no aria-* attributes supported
if (tag.supportedAttributesOverride) {
return tag.supportedAttributesOverride;
if (tagData.supportedAttributesOverride) {
return tagData.supportedAttributesOverride;
}

const role = getRole(element, options);
const roleData = role && roles[role?.name];

// special cases
switch (tagName) {
// <audio> and <video> allow application aria-* attributes despite not
Expand Down Expand Up @@ -87,7 +93,7 @@ export function getSupportedAttributes(element: Element | VirtualElement, option
}

return removeProhibited(attrList, {
nameProhibited: roleData?.nameFrom === 'prohibited' || tag.namingProhibited,
nameProhibited: roleData?.nameFrom === 'prohibited' || tagData.namingProhibited,
prohibited: roleData?.prohibited?.length ? roleData.prohibited : undefined,
});
}
Expand Down Expand Up @@ -120,14 +126,37 @@ export function isValidAttributeValue(attribute: ARIAAttribute, value: unknown):
throw new Error(`${attribute} isn’t a valid ARIA attribute`);
}

const valueStr = String(value);
if (attributeData.type === 'boolean') {
return (
valueStr === 'true' || valueStr === 'false' || valueStr === '' // note: ="" is equivalent to "true"
);
}
if (attributeData.type === 'enum') {
return attributeData.values.includes(valueStr);
switch (attributeData.type) {
case 'true/false': {
// Note: "" = true
return ['true', 'false', ''].includes(String(value));
}
case 'true/false/undefined': {
// Note: "" = true
return ['true', 'false', 'undefined', ''].includes(String(value));
}
case 'tristate': {
return ['true', 'false', 'mixed'].includes(String(value));
}
case 'idRef':
case 'idRefList': {
return typeof value === 'string' && value.length > 0;
}
case 'integer': {
const numVal = Number.parseFloat(String(value));
return Number.isInteger(numVal);
}
case 'number': {
const numVal = Number.parseFloat(String(value));
return Number.isFinite(numVal);
}
case 'token': {
return attributeData.values.includes(String(value));
}
case 'tokenList': {
const values = parseTokenList(String(value));
return values.length > 0 && values.every((v) => attributeData.values.includes(v));
}
}

return true; // if we can’t prove that it’s invalid, assume valid
Expand Down
31 changes: 14 additions & 17 deletions src/get-supported-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,40 +26,40 @@ export interface SupportedRoleOptions {
*/
export function getSupportedRoles(element: Element | VirtualElement, options?: SupportedRoleOptions): ARIARole[] {
const tagName = getTagName(element);
const tag = tags[tagName];
if (!tag) {
return [];
const tagData = tags[tagName];
if (!tagData) {
return ALL_ROLES;
}

// special cases: some HTML elements require unique logic to determine supported roles based on attributes, etc.
switch (tagName) {
case 'a': {
const href = attr(element, 'href');
return typeof href === 'string' ? tag.supportedRoles : ALL_ROLES;
return typeof href === 'string' ? tagData.supportedRoles : ALL_ROLES;
}
case 'area': {
const href = attr(element, 'href');
return typeof href === 'string' ? tag.supportedRoles : ['button', 'generic', 'link'];
return typeof href === 'string' ? tagData.supportedRoles : ['button', 'generic', 'link'];
}
case 'footer':
case 'header': {
const role = getFooterRole(element, options);
return role?.name === 'generic' ? ['generic', 'group', 'none', 'presentation'] : tag.supportedRoles;
return role?.name === 'generic' ? ['generic', 'group', 'none', 'presentation'] : tagData.supportedRoles;
}
case 'div': {
const DL_PARENT_ROLES: ARIARole[] = ['none', 'presentation'];
if (typeof Element !== 'undefined' && element instanceof Element) {
return element.parentElement?.closest('dl:not([role])') ? DL_PARENT_ROLES : tag.supportedRoles;
return element.parentElement?.closest('dl:not([role])') ? DL_PARENT_ROLES : tagData.supportedRoles;
}
return options?.ancestors?.[0]?.tagName === 'dl' ? DL_PARENT_ROLES : tag.supportedRoles;
return options?.ancestors?.[0]?.tagName === 'dl' ? DL_PARENT_ROLES : tagData.supportedRoles;
}
case 'img': {
const name = calculateAccessibleName(element, roles.img);
if (name) {
/** @see https://www.w3.org/TR/html-aria/#el-img */
return ['button', 'checkbox', 'image', 'img', 'link', 'math', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option', 'progressbar', 'radio', 'scrollbar', 'separator', 'slider', 'switch', 'tab', 'treeitem']; // biome-ignore format: long list
}
return tag.supportedRoles;
return tagData.supportedRoles;
}
case 'li': {
return hasListParent(element, options?.ancestors) ? ['listitem'] : ALL_ROLES;
Expand All @@ -72,9 +72,9 @@ export function getSupportedRoles(element: Element | VirtualElement, options?: S
}
case 'summary': {
if (typeof Element !== 'undefined' && element instanceof Element) {
return element.parentElement?.closest('details:not([role])') ? [] : tag.supportedRoles;
return element.parentElement?.closest('details:not([role])') ? [] : tagData.supportedRoles;
}
return options?.ancestors?.some((a) => a.tagName === 'details') ? [] : tag.supportedRoles;
return options?.ancestors?.some((a) => a.tagName === 'details') ? [] : tagData.supportedRoles;
}
case 'td': {
const role = getTDRole(element, options);
Expand All @@ -91,17 +91,14 @@ export function getSupportedRoles(element: Element | VirtualElement, options?: S
}
}
case 'th': {
return hasTableParent(element, options?.ancestors) ? tag.supportedRoles : ALL_ROLES;
return hasTableParent(element, options?.ancestors) ? tagData.supportedRoles : ALL_ROLES;
}
case 'tr': {
return hasTableParent(element, options?.ancestors) ? tag.supportedRoles : ALL_ROLES;
return hasTableParent(element, options?.ancestors) ? tagData.supportedRoles : ALL_ROLES;
}
}

// Known cases that aren’t possible to detect without scanning full DOM:
// - <div> directly <dl> MUST be either role="presentation" or role="none"

return tag.supportedRoles;
return tagData.supportedRoles;
}

/** Helper function for getSupportedRoles that returns a boolean instead */
Expand Down
8 changes: 8 additions & 0 deletions src/is-interactive.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { type GetRoleOptions, getRole } from './get-role.js';
import { tags } from './lib/html.js';
import { attr, getTagName, isDisabled } from './lib/util.js';
import { getTDRole } from './tags/td.js';
import type { VirtualElement } from './types.js';

/** Given HTML, can this element be interacted with? */
export function isInteractive(element: Element | VirtualElement, options?: GetRoleOptions): boolean {
const tagName = getTagName(element);

// if tag doesn’t support any roles, this can’t be interactive
if (tags[tagName]?.supportedRoles.length === 0) {
return false;
}

const role = getRole(element, options);

// separator is a special case, and does NOT care about the HTML element
Expand Down
Loading

0 comments on commit c000339

Please sign in to comment.