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) { //
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