diff --git a/.changeset/twelve-avocados-provide.md b/.changeset/twelve-avocados-provide.md
new file mode 100644
index 0000000..9aaae04
--- /dev/null
+++ b/.changeset/twelve-avocados-provide.md
@@ -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).
diff --git a/README.md b/README.md
index aaf0bc7..3bc58c6 100644
--- a/README.md
+++ b/README.md
@@ -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()
@@ -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 |
-| :------------- | :---------------------------------------------- | :---------------------------------------- | :------------------------------- | --------------------- |
-| `
` | No corresponding role | definition | definition | definition |
-| `` | No corresponding role | list | (inconsistent) | No corresponding role |
-| `` | No corresponding role | term | term | term |
-| `` | No corresponding role | caption | caption (`Figcaption` in Chrome) | caption |
-| `` | 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) `` 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 |
+| :------------- | :---------------------------------------------- | :---------------------------------------- | :---------------------------------------------------------------- | --------------------- |
+| `` | No corresponding role | `definition` | `definition` | `definition` |
+| `` | No corresponding role | `list` | (inconsistent) | No corresponding role |
+| `` | No corresponding role | `term` | `term` | `term` |
+| `` | No corresponding role | `caption` | `caption` (`Figcaption` in Chrome) | `caption` |
+| `` | No corresponding role | `mark` | `mark` | `mark` |
+| `` | `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
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b935347..86ad20f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1913,8 +1913,8 @@ packages:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
- type-fest@4.33.0:
- resolution: {integrity: sha512-s6zVrxuyKbbAsSAD5ZPTB77q4YIdRctkTbJ2/Dqlinwz+8ooH2gd+YA7VA6Pa93KML9GockVvoxjZ2vHP+mu8g==}
+ type-fest@4.34.1:
+ resolution: {integrity: sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==}
engines: {node: '>=16'}
typescript@5.7.3:
@@ -3441,7 +3441,7 @@ snapshots:
path-to-regexp: 6.3.0
picocolors: 1.1.1
strict-event-emitter: 0.5.1
- type-fest: 4.33.0
+ type-fest: 4.34.1
yargs: 17.7.2
optionalDependencies:
typescript: 5.7.3
@@ -3925,7 +3925,7 @@ snapshots:
type-fest@0.21.3: {}
- type-fest@4.33.0: {}
+ type-fest@4.34.1: {}
typescript@5.7.3: {}
diff --git a/src/get-role.ts b/src/get-role.ts
index 59e7380..72dba5e 100644
--- a/src/get-role.ts
+++ b/src/get-role.ts
@@ -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';
@@ -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
@@ -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);
@@ -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);
@@ -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;
}
diff --git a/src/get-supported-attributes.ts b/src/get-supported-attributes.ts
index fd8c9f5..c268ccd 100644
--- a/src/get-supported-attributes.ts
+++ b/src/get-supported-attributes.ts
@@ -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[];
@@ -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) {
// and allow application aria-* attributes despite not
@@ -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,
});
}
@@ -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
diff --git a/src/get-supported-roles.ts b/src/get-supported-roles.ts
index f7e83e4..6ec218b 100644
--- a/src/get-supported-roles.ts
+++ b/src/get-supported-roles.ts
@@ -26,32 +26,32 @@ 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);
@@ -59,7 +59,7 @@ export function getSupportedRoles(element: Element | VirtualElement, options?: S
/** @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;
@@ -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);
@@ -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:
- // - directly
MUST be either role="presentation" or role="none"
-
- return tag.supportedRoles;
+ return tagData.supportedRoles;
}
/** Helper function for getSupportedRoles that returns a boolean instead */
diff --git a/src/is-interactive.ts b/src/is-interactive.ts
index 394be7a..4bf0912 100644
--- a/src/is-interactive.ts
+++ b/src/is-interactive.ts
@@ -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
diff --git a/src/lib/aria-attributes.ts b/src/lib/aria-attributes.ts
index 3f4963d..98a615f 100644
--- a/src/lib/aria-attributes.ts
+++ b/src/lib/aria-attributes.ts
@@ -10,87 +10,83 @@ import type {
// note: all fields required to be monomorphic
export const globalAttributes: Record = {
- 'aria-atomic': { category: ['global', 'liveregion'], type: 'boolean', default: false },
- 'aria-braillelabel': { category: ['global'], type: 'string' },
- 'aria-brailleroledescription': { category: ['global'], type: 'string' },
- 'aria-busy': { category: ['global', 'liveregion'], type: 'boolean', default: false },
- 'aria-controls': { category: ['global', 'relationship'], type: 'string' },
- 'aria-current': { category: ['global'], type: 'string' },
- 'aria-describedby': { category: ['global', 'relationship'], type: 'string' },
- 'aria-description': { category: ['global'], type: 'string' },
- 'aria-details': { category: ['global', 'relationship'], type: 'string' },
- 'aria-dropeffect': { category: ['global', 'draganddrop'], type: 'string' },
- 'aria-flowto': { category: ['global', 'relationship'], type: 'string' },
- 'aria-grabbed': { category: ['global', 'draganddrop'], type: 'boolean', default: undefined },
- 'aria-hidden': { category: ['global', 'widget'], type: 'boolean', default: undefined },
- 'aria-keyshortcuts': { category: ['global'], type: 'string' },
- 'aria-label': { category: ['global', 'widget'], type: 'string' },
- 'aria-labelledby': { category: ['global', 'relationship'], type: 'string' },
- 'aria-live': { category: ['global', 'liveregion'], type: 'string' },
- 'aria-owns': { category: ['global', 'relationship'], type: 'string' },
+ 'aria-atomic': { category: ['global', 'liveregion'], type: 'true/false', default: false },
+ 'aria-braillelabel': { category: ['global'], type: 'string', default: undefined },
+ 'aria-brailleroledescription': { category: ['global'], type: 'string', default: undefined },
+ 'aria-busy': { category: ['global', 'liveregion'], type: 'true/false', default: false },
+ 'aria-controls': { category: ['global', 'relationship'], type: 'string', default: undefined },
+ 'aria-current': { category: ['global'], type: 'string', default: undefined },
+ 'aria-describedby': { category: ['global', 'relationship'], type: 'string', default: undefined },
+ 'aria-description': { category: ['global'], type: 'string', default: undefined },
+ 'aria-details': { category: ['global', 'relationship'], type: 'string', default: undefined },
+ 'aria-dropeffect': { category: ['global', 'draganddrop'], type: 'string', default: undefined },
+ 'aria-flowto': { category: ['global', 'relationship'], type: 'string', default: undefined },
+ 'aria-grabbed': { category: ['global', 'draganddrop'], type: 'true/false/undefined', default: undefined },
+ 'aria-hidden': { category: ['global', 'widget'], type: 'true/false/undefined', default: undefined },
+ 'aria-keyshortcuts': { category: ['global'], type: 'string', default: undefined },
+ 'aria-label': { category: ['global', 'widget'], type: 'string', default: undefined },
+ 'aria-labelledby': { category: ['global', 'relationship'], type: 'string', default: undefined },
+ 'aria-live': {
+ category: ['global', 'liveregion'],
+ type: 'token',
+ default: 'off',
+ values: ['assertive', 'off', 'polite'],
+ },
+ 'aria-owns': { category: ['global', 'relationship'], type: 'idRefList' },
'aria-relevant': {
category: ['global', 'liveregion'],
- type: 'enum',
+ type: 'tokenList',
default: 'additions text',
- values: [
- 'additions',
- 'additions removals',
- 'additions removals text',
- 'additions text',
- 'all',
- 'removals',
- 'removals text',
- 'text',
- ],
+ values: ['additions', 'removals', 'text', 'all'],
},
- 'aria-roledescription': { category: ['global'], type: 'string' },
+ 'aria-roledescription': { category: ['global'], type: 'string', default: undefined },
};
export const widgetAttributes: Record = {
'aria-autocomplete': {
category: ['widget'],
- type: 'enum',
+ type: 'token',
default: 'none',
values: ['inline', 'list', 'both', 'none'],
},
- 'aria-checked': { category: ['widget'], type: 'enum', values: ['true', 'false', 'mixed'], default: undefined },
- 'aria-disabled': { category: ['widget'], type: 'boolean', default: false },
- 'aria-errormessage': { category: ['widget', 'relationship'], type: 'string' },
- 'aria-expanded': { category: ['widget'], type: 'boolean', default: undefined },
+ 'aria-checked': { category: ['widget'], type: 'tristate', default: undefined },
+ 'aria-disabled': { category: ['widget'], type: 'true/false', default: false },
+ 'aria-errormessage': { category: ['widget', 'relationship'], type: 'string', default: undefined },
+ 'aria-expanded': { category: ['widget'], type: 'true/false/undefined', default: undefined },
'aria-haspopup': {
category: ['widget'],
- type: 'enum',
+ type: 'token',
default: 'false',
values: ['false', 'true', 'menu', 'listbox', 'tree', 'grid', 'dialog'],
},
'aria-hidden': globalAttributes['aria-hidden'],
'aria-invalid': {
category: ['widget'],
- type: 'enum',
+ type: 'token',
default: 'false',
values: ['grammar', 'false', 'spelling', 'true'],
},
'aria-label': globalAttributes['aria-label'],
- 'aria-level': { category: ['widget'], type: 'string' },
- 'aria-modal': { category: ['widget'], type: 'boolean', default: false },
- 'aria-multiline': { category: ['widget'], type: 'boolean', default: false },
- 'aria-multiselectable': { category: ['widget'], type: 'boolean', default: false },
- 'aria-orientation': { category: ['widget'], type: 'enum', default: undefined, values: ['horizontal', 'vertical'] },
+ 'aria-level': { category: ['widget'], type: 'integer' },
+ 'aria-modal': { category: ['widget'], type: 'true/false', default: false },
+ 'aria-multiline': { category: ['widget'], type: 'true/false', default: false },
+ 'aria-multiselectable': { category: ['widget'], type: 'true/false', default: false },
+ 'aria-orientation': { category: ['widget'], type: 'token', default: undefined, values: ['horizontal', 'vertical'] },
'aria-placeholder': { category: ['widget'], type: 'string' },
- 'aria-pressed': { category: ['widget'], type: 'enum', values: ['true', 'false', 'mixed'], default: undefined },
- 'aria-readonly': { category: ['widget'], type: 'boolean', default: false },
- 'aria-required': { category: ['widget'], type: 'boolean', default: false },
- 'aria-selected': { category: ['widget'], type: 'boolean', default: undefined },
+ 'aria-pressed': { category: ['widget'], type: 'tristate', default: undefined },
+ 'aria-readonly': { category: ['widget'], type: 'true/false', default: false },
+ 'aria-required': { category: ['widget'], type: 'true/false', default: false },
+ 'aria-selected': { category: ['widget'], type: 'true/false/undefined', default: undefined },
'aria-sort': {
category: ['widget'],
- type: 'enum',
+ type: 'token',
default: 'none',
values: ['ascending', 'descending', 'none', 'other'],
},
- 'aria-valuemax': { category: ['widget'], type: 'number' },
+ 'aria-valuemax': { category: ['widget'], type: 'number', default: undefined },
'aria-valuemin': { category: ['widget'], type: 'number' },
'aria-valuenow': { category: ['widget'], type: 'number' },
- 'aria-valuetext': { category: ['widget'], type: 'string' },
+ 'aria-valuetext': { category: ['widget'], type: 'string', default: undefined },
};
export const liveregionAttributes: Record = {
@@ -106,11 +102,11 @@ export const draganddropAttributes: Record
};
export const relationshipAttributes: Record = {
- 'aria-activedescendant': { category: ['relationship'], type: 'string' },
- 'aria-colcount': { category: ['relationship'], type: 'string' },
- 'aria-colindex': { category: ['relationship'], type: 'number' },
+ 'aria-activedescendant': { category: ['relationship'], type: 'idRef' },
+ 'aria-colcount': { category: ['relationship'], type: 'integer' },
+ 'aria-colindex': { category: ['relationship'], type: 'integer' },
'aria-colindextext': { category: ['relationship'], type: 'string' },
- 'aria-colspan': { category: ['relationship'], type: 'number' },
+ 'aria-colspan': { category: ['relationship'], type: 'integer' },
'aria-controls': globalAttributes['aria-controls'],
'aria-describedby': globalAttributes['aria-describedby'],
'aria-details': globalAttributes['aria-details'],
@@ -118,12 +114,12 @@ export const relationshipAttributes: Record = {
/** A container for a collection of elements that form an image. See synonym image. */
img: {
allowedChildRoles: [],
- childrenPresentational: false,
+ childrenPresentational: true,
defaultAttributeValues: {},
elements: [{ tagName: 'img' }],
name: 'img',
@@ -1129,7 +1129,7 @@ export const documentRoles: Record = {
defaultAttributeValues: {},
elements: [],
name: 'presentation',
- nameFrom: 'author',
+ nameFrom: 'prohibited',
nameRequired: false,
prohibited: ['aria-braillelabel', 'aria-label', 'aria-labelledby'],
required: [],
diff --git a/src/lib/html.ts b/src/lib/html.ts
index 2a66660..9a994e6 100644
--- a/src/lib/html.ts
+++ b/src/lib/html.ts
@@ -27,6 +27,27 @@ export interface TagInfo {
namingProhibited: boolean;
}
+/**
+ * SVG elements that are always hidden from screenreaders.
+ * @see https://www.w3.org/TR/svg-aam-1.0/#include_elements
+ */
+const SVG_ALWAYS_INACCESSIBLE_ELEMENT: TagInfo = {
+ defaultRole: 'none',
+ namingProhibited: true,
+ supportedRoles: [],
+ supportedAttributesOverride: [],
+};
+/**
+ * SVG elements that by default aren’t added to the a11y tree, but MAY be included if certain criteria are met.
+ * @see https://www.w3.org/TR/svg-aam-1.0/#include_elements
+ */
+const SVG_MAYBE_ACCESSIBLE_ELEMENT: TagInfo = {
+ defaultRole: 'none', // ⚠️ these elements will get different default roles if they have an accessible name, but they all default to 'none'
+ namingProhibited: true,
+ supportedRoles: ALL_ROLES,
+ supportedAttributesOverride: undefined,
+};
+
export const tags: Record = {
// Main root
html: {
@@ -756,106 +777,76 @@ export const tags: Record = {
// SVG
// @see https://www.w3.org/TR/svg-aam-1.0/#mapping_role_table
- animate: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- animateMotion: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- animateTransform: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- circle: {
- defaultRole: 'graphics-symbol',
- namingProhibited: false,
- supportedRoles: ALL_ROLES,
- supportedAttributesOverride: undefined,
- },
- clipPath: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- cursor: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- defs: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- desc: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- discard: {
- defaultRole: 'none',
- namingProhibited: true,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- ellipse: {
- defaultRole: 'graphics-symbol',
- namingProhibited: false,
- supportedRoles: ALL_ROLES,
- supportedAttributesOverride: undefined,
- },
- g: {
- defaultRole: NO_CORRESPONDING_ROLE,
- namingProhibited: false,
- supportedRoles: ['group', 'graphics-object'],
- supportedAttributesOverride: undefined,
- },
- switch: {
- defaultRole: 'none',
- namingProhibited: false,
- supportedRoles: [],
- supportedAttributesOverride: [],
- },
- symbol: {
- defaultRole: 'graphics-object',
- namingProhibited: false,
- supportedRoles: ALL_ROLES,
- supportedAttributesOverride: undefined,
- },
+ // a: (use HTML )
+ animate: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ animateMotion: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ animateTransform: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ // audio: (use HTML )
+ // canvas: (use HTML )
+ circle: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ clipPath: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ defs: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ desc: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ ellipse: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ feBlend: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feColorMatrix: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feComponentTransfer: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feComposite: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feConvolveMatrix: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feDiffuseLighting: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feDisplacementMap: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feDistantLight: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feDropShadow: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feFlood: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feFuncA: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feFuncB: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feFuncG: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feFuncR: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feGaussianBlur: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feImage: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feMerge: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feMergeNode: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feMorphology: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feOffset: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ fePointLight: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feSpecularLighting: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feSpotLight: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feTile: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ feTurbulence: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ filter: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ foreignObject: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ g: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ image: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ line: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ linearGradient: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ marker: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ mask: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ metadata: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ mpath: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ path: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ pattern: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ polygon: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ polyline: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ radialGradient: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ rect: SVG_MAYBE_ACCESSIBLE_ELEMENT,
+ set: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ stop: SVG_ALWAYS_INACCESSIBLE_ELEMENT,
+ // style: (use HTML