Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/red-mails-fry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"html-aria": minor
---

Adopt WAI-ARIA 1.3 default of image role in preference to img role.
Fix role calculation for img element with no alt.
Fix ACCNAME calculation to support title attribute.
Fix ACCNAME calculation for empty or whitespace only labels.
11 changes: 9 additions & 2 deletions src/get-role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,15 @@ export function getRole(element: Element | VirtualElement, options?: GetRoleOpti
return getHeaderRole(element, options);
}
case 'img': {
const name = calculateAccessibleName(element, roles.img);
return name ? roles.img : roles.none;
const name = calculateAccessibleName(element, roles.image);

if (name) {
return roles.image;
}

const alt = attr(element, 'alt');

return alt === '' ? roles.none : roles.image;
}
case 'li': {
return getLIRole(element, options);
Expand Down
2 changes: 1 addition & 1 deletion src/get-supported-attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export function getSupportedAttributes(element: Element | VirtualElement, option
return roles.application.supported;
}
case 'img': {
const name = calculateAccessibleName(element, roles.img);
const name = calculateAccessibleName(element, roles.image);
// if no accessible name, only aria-hidden allowed
return name && roleData?.supported?.length ? roleData.supported : ['aria-hidden'];
}
Expand Down
2 changes: 1 addition & 1 deletion src/get-supported-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function getSupportedRoles(element: Element | VirtualElement, options?: S
return options?.ancestors?.[0]?.tagName === 'dl' ? DL_PARENT_ROLES : tagData.supportedRoles;
}
case 'img': {
const name = calculateAccessibleName(element, roles.img);
const name = calculateAccessibleName(element, roles.image);
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
Expand Down
26 changes: 18 additions & 8 deletions src/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,32 @@ export function calculateAccessibleName(element: Element | VirtualElement, role:
// for author + authorAndContents, handle special cases first
const tagName = getTagName(element);
switch (tagName) {
/**
* @see https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation
*/
case 'img': {
const label =
(attr(element, 'aria-label') as string | undefined)?.trim() ||
(attr(element, 'aria-labelledby') as string | undefined)?.trim();

if (label) {
return label;
}

const alt = attr(element, 'alt');
/**
* According to spec, aria-label is technically allowed for <img> (even if alt is preferred)
* @see https://www.w3.org/TR/html-aam-1.0/#img-element-accessible-name-computation
*/
if (alt) {

if (typeof alt !== 'undefined') {
return alt as string;
}
break;

return (attr(element, 'title') as string | undefined) || undefined;
}
}

return (
(attr(element, 'aria-label') as string | undefined) ||
(attr(element, 'aria-labelledby') as string | undefined) ||
(attr(element, 'aria-label') as string | undefined)?.trim() ||
(attr(element, 'aria-labelledby') as string | undefined)?.trim() ||
(attr(element, 'title') as string | undefined) ||
(role.nameFrom === 'authorAndContents' &&
'innerText' in (element as HTMLElement) &&
(element as HTMLElement).innerText) ||
Expand Down
2 changes: 1 addition & 1 deletion src/tags/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const SVG_ACCESSIBLE_ROLE_MAPPING = {
foreignObject: roles.group,
g: roles.group,
line: roles['graphics-symbol'],
image: roles.img,
image: roles.image,
path: roles['graphics-symbol'],
polygon: roles['graphics-symbol'],
polyline: roles['graphics-symbol'],
Expand Down
81 changes: 73 additions & 8 deletions test/dom/get-role.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,55 @@ describe('getRole', () => {
'aside (in section[aria-label])',
{ given: ['<section aria-label="My section"><aside></aside></section>', 'aside'], want: 'generic' },
],
[
'aside[aria-label] (in section[aria-label])',
{
given: ['<section aria-label="My section"><aside aria-label="Aside"></aside></section>', 'aside'],
want: 'complementary',
},
],
[
'aside[aria-labelledby] (in section[aria-label])',
{
given: ['<section aria-label="My section"><aside aria-labelledby="Aside"></aside></section>', 'aside'],
want: 'complementary',
},
],
[
'aside (empty label in section[aria-label])',
{
given: ['<section aria-label="My section"><aside aria-label=""></aside></section>', 'aside'],
want: 'generic',
},
],
[
'aside (whitespace label in section[aria-label])',
{
given: ['<section aria-label="My section"><aside aria-label=" "></aside></section>', 'aside'],
want: 'generic',
},
],
[
'aside (empty labelledby in section[aria-label])',
{
given: ['<section aria-label="My section"><aside aria-labelledby=""></aside></section>', 'aside'],
want: 'generic',
},
],
[
'aside (whitespace labelledby in section[aria-label])',
{
given: ['<section aria-label="My section"><aside aria-labelledby=" "></aside></section>', 'aside'],
want: 'generic',
},
],
[
'aside[title] (in section[aria-label])',
{
given: ['<section aria-label="My section"><aside title="Aside"></aside></section>', 'aside'],
want: 'complementary',
},
],
['audio', { given: ['<audio></audio>', 'audio'], want: NO_CORRESPONDING_ROLE }],
['b', { given: ['<b></b>', 'b'], want: 'generic' }],
['base', { given: ['<base></base>', 'base'], want: NO_CORRESPONDING_ROLE }],
Expand Down Expand Up @@ -110,10 +159,25 @@ describe('getRole', () => {
['html', { given: ['<html></html>', 'html'], want: 'document' }],
['i', { given: ['<i></i>', 'i'], want: 'generic' }],
['iframe', { given: ['<iframe></iframe>', 'iframe'], want: NO_CORRESPONDING_ROLE }],
['img (named by alt)', { given: ['<img alt="My image" />', 'img'], want: 'img' }],
['img (named by label)', { given: ['<img aria-label="My image"/>', 'img'], want: 'img' }],
['img (named by labelledby)', { given: ['<img aria-labelledby="My image" />', 'img'], want: 'img' }],
['img (no name)', { given: ['<img />', 'img'], want: 'none' }],
['img (named by alt)', { given: ['<img alt="My image" />', 'img'], want: 'image' }],
['img (named by label)', { given: ['<img aria-label="My image"/>', 'img'], want: 'image' }],
['img (named by labelledby)', { given: ['<img aria-labelledby="My image" />', 'img'], want: 'image' }],
['img (named by title)', { given: ['<img title="My image" />', 'img'], want: 'image' }],
['img (empty alt named by label)', { given: ['<img alt="" aria-label="My image"/>', 'img'], want: 'image' }],
[
'img (empty alt named by labelledby)',
{ given: ['<img alt="" aria-labelledby="My image"/>', 'img'], want: 'image' },
],
['img (no name)', { given: ['<img />', 'img'], want: 'image' }],
['img (empty string alt)', { given: ['<img alt="" />', 'img'], want: 'none' }],
['img (empty alt)', { given: ['<img alt />', 'img'], want: 'none' }],
['img (empty alt, empty label)', { given: ['<img alt aria-label="" />', 'img'], want: 'none' }],
['img (empty alt, whitespace label)', { given: ['<img alt aria-label=" " />', 'img'], want: 'none' }],
['img (empty alt, empty labelledby)', { given: ['<img alt aria-labelledby="" />', 'img'], want: 'none' }],
['img (empty alt, whitespace labelledby)', { given: ['<img alt aria-labelledby=" " />', 'img'], want: 'none' }],
['img (empty alt, title)', { given: ['<img alt title="My image" />', 'img'], want: 'none' }],
['img (empty alt, empty title)', { given: ['<img alt title="" />', 'img'], want: 'none' }],
['img (empty alt, whitespace title)', { given: ['<img alt title=" " />', 'img'], want: 'none' }],
['input', { given: ['<input></input>', 'input'], want: 'textbox' }],
['input[type=button]', { given: ['<input type="button" />', 'input'], want: 'button' }],
['input[type=color]', { given: ['<input type="color" />', 'input'], want: NO_CORRESPONDING_ROLE }],
Expand Down Expand Up @@ -269,6 +333,7 @@ describe('getRole', () => {
['section', { given: ['<section></section>', 'section'], want: 'generic' }],
['section[aria-label]', { given: ['<section aria-label="My section"></section>', 'section'], want: 'region' }],
['section[aria-labelledby]', { given: ['<section aria-labelledby="my-section">', 'section'], want: 'region' }],
['section[title]', { given: ['<section title="my-section">', 'section'], want: 'region' }],
['select', { given: ['<select></select>', 'select'], want: 'combobox' }],
['select[size=0]', { given: ['<select size="0"></select>', 'select'], want: 'combobox' }],
['select[size=1]', { given: ['<select size="1"></select>', 'select'], want: 'combobox' }],
Expand Down Expand Up @@ -334,10 +399,10 @@ describe('getRole', () => {

// SVG
['svg', { given: ['<svg></svg>', 'svg'], want: 'graphics-document' }],
['svg[role=img]', { given: ['<svg role="img"></svg>', 'svg'], want: 'img' }],
['svg[role=image]', { given: ['<svg role="image"></svg>', 'svg'], want: 'image' }],
[
'svg[role=graphics-symbol img]',
{ given: ['<svg role="graphics-symbol img"></svg>', 'svg'], want: 'graphics-symbol' },
'svg[role=graphics-symbol image]',
{ given: ['<svg role="graphics-symbol image"></svg>', 'svg'], want: 'graphics-symbol' },
],
['a (in svg)', { given: ['<svg><a href="#"></a></svg>', 'a'], want: 'link' }],
['a[xlink:href] (in svg)', { given: ['<svg><a xlink:href="#"></a></svg>', 'a'], want: 'link' }],
Expand Down Expand Up @@ -464,7 +529,7 @@ describe('getRole', () => {
['g (title)', { given: ['<svg><g><title>Group</title></g></svg>', 'g'], want: 'group' }],
['g[aria-label]', { given: ['<svg><g aria-label="Group"></g></svg>', 'g'], want: 'group' }],
['image', { given: ['<svg><image /></svg>', 'image'], want: 'none' }],
['image[src]', { given: ['<svg><image src="foo.png" /></svg>', 'image'], want: 'img' }],
['image[src]', { given: ['<svg><image src="foo.png" /></svg>', 'image'], want: 'image' }],
['line', { given: ['<svg><line></line></svg>', 'line'], want: 'none' }],
['linearGradient', { given: ['<svg><linearGradient></linearGradient></svg>', 'linearGradient'], want: 'none' }],
['marker', { given: ['<svg><marker></marker></svg>', 'marker'], want: 'none' }],
Expand Down
132 changes: 124 additions & 8 deletions test/node/get-role.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,86 @@ describe('getRole', () => {
want: 'complementary',
},
],
[
'aside (in section[aria-label])',
{
given: [
{ tagName: 'aside' },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'generic',
},
],
[
'aside[aria-label] (in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { 'aria-label': 'Aside' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'complementary',
},
],
[
'aside[aria-labelledby] (in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { 'aria-labelledby': 'Aside' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'complementary',
},
],
[
'aside (empty label in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { 'aria-label': '' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'generic',
},
],
[
'aside (whitespace label in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { 'aria-label': ' ' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'generic',
},
],
[
'aside (empty labelledby in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { 'aria-labelledby': '' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'generic',
},
],
[
'aside (whitespace labelledby in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { 'aria-labelledby': ' ' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'generic',
},
],
[
'aside[title] (in section[aria-label])',
{
given: [
{ tagName: 'aside', attributes: { title: 'Aside' } },
{ ancestors: [{ tagName: 'section', attributes: { 'aria-label': 'My section' } }] },
],
want: 'complementary',
},
],
['audio', { given: [{ tagName: 'audio' }], want: NO_CORRESPONDING_ROLE }],
['b', { given: [{ tagName: 'b' }], want: 'generic' }],
['base', { given: [{ tagName: 'base' }], want: NO_CORRESPONDING_ROLE }],
Expand Down Expand Up @@ -123,13 +203,48 @@ describe('getRole', () => {
['html', { given: [{ tagName: 'html' }], want: 'document' }],
['i', { given: [{ tagName: 'i' }], want: 'generic' }],
['iframe', { given: [{ tagName: 'iframe' }], want: NO_CORRESPONDING_ROLE }],
['img (named by alt)', { given: [{ tagName: 'img', attributes: { alt: 'My image' } }], want: 'img' }],
['img (named by label)', { given: [{ tagName: 'img', attributes: { 'aria-label': 'My image' } }], want: 'img' }],
['img (named by alt)', { given: [{ tagName: 'img', attributes: { alt: 'My image' } }], want: 'image' }],
['img (named by label)', { given: [{ tagName: 'img', attributes: { 'aria-label': 'My image' } }], want: 'image' }],
[
'img (named by labelledby)',
{ given: [{ tagName: 'img', attributes: { 'aria-labelledby': 'My image' } }], want: 'img' },
{ given: [{ tagName: 'img', attributes: { 'aria-labelledby': 'My image' } }], want: 'image' },
],
['img (named by title)', { given: [{ tagName: 'img', attributes: { title: 'My image' } }], want: 'image' }],
[
'img (empty alt named by label)',
{ given: [{ tagName: 'img', attributes: { alt: '', 'aria-label': 'My image' } }], want: 'image' },
],
[
'img (empty alt named by labelledby)',
{ given: [{ tagName: 'img', attributes: { alt: '', 'aria-labelledby': 'My image' } }], want: 'image' },
],
['img (no name)', { given: [{ tagName: 'img' }], want: 'image' }],
['img (empty alt)', { given: [{ tagName: 'img', attributes: { alt: '' } }], want: 'none' }],
[
'img (empty alt, empty label)',
{ given: [{ tagName: 'img', attributes: { alt: '', 'aria-label': '' } }], want: 'none' },
],
[
'img (empty alt, whitespace label)',
{ given: [{ tagName: 'img', attributes: { alt: '', 'aria-label': ' ' } }], want: 'none' },
],
[
'img (empty alt, empty labelledby)',
{ given: [{ tagName: 'img', attributes: { alt: '', 'aria-labelledby': '' } }], want: 'none' },
],
[
'img (empty alt, whitespace labelledby)',
{ given: [{ tagName: 'img', attributes: { alt: '', 'aria-labelledby': ' ' } }], want: 'none' },
],
[
'img (empty alt, title)',
{ given: [{ tagName: 'img', attributes: { alt: '', title: 'My image' } }], want: 'none' },
],
['img (empty alt, empty title)', { given: [{ tagName: 'img', attributes: { alt: '', title: '' } }], want: 'none' }],
[
'img (empty alt, whitespace title)',
{ given: [{ tagName: 'img', attributes: { alt: '', title: ' ' } }], want: 'none' },
],
['img (no name)', { given: [{ tagName: 'img' }], want: 'none' }],
['input', { given: [{ tagName: 'input' }], want: 'textbox' }],
['input[type=button]', { given: [{ tagName: 'input', attributes: { type: 'button' } }], want: 'button' }],
[
Expand Down Expand Up @@ -322,6 +437,7 @@ describe('getRole', () => {
'section[aria-labelledby]',
{ given: [{ tagName: 'section', attributes: { 'aria-labelledby': 'My section' } }], want: 'region' },
],
['section[title]', { given: [{ tagName: 'section', attributes: { title: 'My section' } }], want: 'region' }],
['select', { given: [{ tagName: 'select' }], want: 'combobox' }],
['select[size=0]', { given: [{ tagName: 'select', attributes: { size: 0 } }], want: 'combobox' }],
['select[size=1]', { given: [{ tagName: 'select', attributes: { size: 1 } }], want: 'combobox' }],
Expand Down Expand Up @@ -408,10 +524,10 @@ describe('getRole', () => {

// SVG
['svg', { given: [{ tagName: 'svg' }], want: 'graphics-document' }],
['svg[role=img]', { given: [{ tagName: 'svg', attributes: { role: 'img' } }], want: 'img' }],
['svg[role=image]', { given: [{ tagName: 'svg', attributes: { role: 'image' } }], want: 'image' }],
[
'svg[role=graphics-symbol img]',
{ given: [{ tagName: 'svg', attributes: { role: 'graphics-symbol img' } }], want: 'graphics-symbol' },
'svg[role=graphics-symbol image]',
{ given: [{ tagName: 'svg', attributes: { role: 'graphics-symbol image' } }], want: 'graphics-symbol' },
],
['animate', { given: [{ tagName: 'animate' }], want: 'none' }],
['animateMotion', { given: [{ tagName: 'animateMotion' }], want: 'none' }],
Expand Down Expand Up @@ -455,7 +571,7 @@ describe('getRole', () => {
['g', { given: [{ tagName: 'g' }], want: 'none' }],
['g[aria-label]', { given: [{ tagName: 'g', attributes: { 'aria-label': 'Group' } }], want: 'group' }],
['image', { given: [{ tagName: 'image' }], want: 'none' }],
['image', { given: [{ tagName: 'image', attributes: { src: 'foo.png' } }], want: 'img' }],
['image', { given: [{ tagName: 'image', attributes: { src: 'foo.png' } }], want: 'image' }],
['line', { given: [{ tagName: 'line' }], want: 'none' }],
['linearGradient', { given: [{ tagName: 'linearGradient' }], want: 'none' }],
['marker', { given: [{ tagName: 'marker' }], want: 'none' }],
Expand Down
2 changes: 1 addition & 1 deletion test/node/get-supported-attributes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ const tests: [
['html', { given: [{ tagName: 'html' }], want: NO_ATTRIBUTES }],
['i', { given: [{ tagName: 'i' }], want: GENERIC_NO_NAMING }],
['iframe', { given: [{ tagName: 'iframe' }], want: GLOBAL_ATTRIBUTES }],
['img (name)', { given: [{ tagName: 'img', attributes: { alt: 'Alt text' } }], want: roles.img.supported }],
['img (name)', { given: [{ tagName: 'img', attributes: { alt: 'Alt text' } }], want: roles.image.supported }],
['img (no name)', { given: [{ tagName: 'img' }], want: ['aria-hidden'] }],
['input[type=button]', { given: [{ tagName: 'input', attributes: { type: 'button' } }], want: BUTTON_ATTRIBUTES }],
[
Expand Down