mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
Implement new a11y audits for the Dev Toolbar app (#9170)
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Princesseuh <3019731+Princesseuh@users.noreply.github.com> Co-authored-by: Fred K. Schott <fkschott@gmail.com>
This commit is contained in:
parent
4aa8091582
commit
8a228fce01
8 changed files with 726 additions and 23 deletions
9
.changeset/orange-candles-sip.md
Normal file
9
.changeset/orange-candles-sip.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
'astro': patch
|
||||
---
|
||||
|
||||
Adds new accessibility audits to the Dev Toolbar's built-in Audits app.
|
||||
|
||||
The audits Astro performs are non-exhaustive and only capable of detecting a handful of common accessibility issues. Please take care to perform a thorough, **manual** audit of your site to ensure compliance with the [Web Content Accessibility Guidelines (WCAG) international standard](https://www.w3.org/WAI/standards-guidelines/wcag/) _before_ publishing your site.
|
||||
|
||||
🧡 Huge thanks to the [Svelte](https://github.com/sveltejs/svelte) team for providing the basis of these accessibility audits!
|
2
LICENSE
2
LICENSE
|
@ -20,7 +20,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
"""
|
||||
This license applies to parts of the `packages/create-astro` and `packages/astro` subdirectories originating from the https://github.com/sveltejs/kit repository:
|
||||
|
||||
|
@ -33,7 +32,6 @@ The above copyright notice and this permission notice shall be included in all c
|
|||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
This license applies to parts of the `packages/create-astro` and `packages/astro` subdirectories originating from the https://github.com/vitejs/vite repository:
|
||||
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
"@babel/types": "^7.23.3",
|
||||
"@types/babel__core": "^7.20.4",
|
||||
"acorn": "^8.11.2",
|
||||
"aria-query": "^5.3.0",
|
||||
"axobject-query": "^4.0.0",
|
||||
"boxen": "^7.1.1",
|
||||
"chokidar": "^3.5.3",
|
||||
"ci-info": "^4.0.0",
|
||||
|
@ -180,6 +182,7 @@
|
|||
"devDependencies": {
|
||||
"@astrojs/check": "^0.3.1",
|
||||
"@playwright/test": "1.40.0",
|
||||
"@types/aria-query": "^5.0.4",
|
||||
"@types/babel__generator": "^7.6.7",
|
||||
"@types/babel__traverse": "^7.20.4",
|
||||
"@types/chai": "^4.3.10",
|
||||
|
|
|
@ -27,7 +27,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
] = await Promise.all([
|
||||
loadDevOverlayPlugins() as DevOverlayPluginDefinition[],
|
||||
import('./plugins/astro.js'),
|
||||
import('./plugins/audit.js'),
|
||||
import('./plugins/audit/index.js'),
|
||||
import('./plugins/xray.js'),
|
||||
import('./plugins/settings.js'),
|
||||
import('./overlay.js'),
|
||||
|
|
|
@ -0,0 +1,628 @@
|
|||
/**
|
||||
* https://github.com/sveltejs/svelte/blob/61e5e53eee82e895c1a5b4fd36efb87eafa1fc2d/LICENSE.md
|
||||
* @license MIT
|
||||
*
|
||||
* Copyright (c) 2016-23 [these people](https://github.com/sveltejs/svelte/graphs/contributors)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { ARIARoleDefinitionKey } from 'aria-query';
|
||||
import { aria, roles } from 'aria-query';
|
||||
import type { AuditRuleWithSelector } from './index.js';
|
||||
// @ts-expect-error package does not provide types
|
||||
import { AXObjectRoles, elementAXObjects } from 'axobject-query';
|
||||
|
||||
const a11y_required_attributes = {
|
||||
a: ['href'],
|
||||
area: ['alt', 'aria-label', 'aria-labelledby'],
|
||||
// html-has-lang
|
||||
html: ['lang'],
|
||||
// iframe-has-title
|
||||
iframe: ['title'],
|
||||
img: ['alt'],
|
||||
object: ['title', 'aria-label', 'aria-labelledby'],
|
||||
};
|
||||
|
||||
const interactiveElements = ['button', 'details', 'embed', 'iframe', 'label', 'select', 'textarea'];
|
||||
|
||||
const aria_non_interactive_roles = [
|
||||
'alert',
|
||||
'alertdialog',
|
||||
'application',
|
||||
'article',
|
||||
'banner',
|
||||
'button',
|
||||
'cell',
|
||||
'checkbox',
|
||||
'columnheader',
|
||||
'combobox',
|
||||
'complementary',
|
||||
'contentinfo',
|
||||
'definition',
|
||||
'dialog',
|
||||
'directory',
|
||||
'document',
|
||||
'feed',
|
||||
'figure',
|
||||
'form',
|
||||
'grid',
|
||||
'gridcell',
|
||||
'group',
|
||||
'heading',
|
||||
'img',
|
||||
'link',
|
||||
'list',
|
||||
'listbox',
|
||||
'listitem',
|
||||
'log',
|
||||
'main',
|
||||
'marquee',
|
||||
'math',
|
||||
'menu',
|
||||
'menubar',
|
||||
'menuitem',
|
||||
'menuitemcheckbox',
|
||||
'menuitemradio',
|
||||
'navigation',
|
||||
'none',
|
||||
'note',
|
||||
'option',
|
||||
'presentation',
|
||||
'progressbar',
|
||||
'radio',
|
||||
'radiogroup',
|
||||
'region',
|
||||
'row',
|
||||
'rowgroup',
|
||||
'rowheader',
|
||||
'scrollbar',
|
||||
'search',
|
||||
'searchbox',
|
||||
'separator',
|
||||
'slider',
|
||||
'spinbutton',
|
||||
'status',
|
||||
'switch',
|
||||
'tab',
|
||||
'tablist',
|
||||
'tabpanel',
|
||||
'term',
|
||||
'textbox',
|
||||
'timer',
|
||||
'toolbar',
|
||||
'tooltip',
|
||||
'tree',
|
||||
'treegrid',
|
||||
'treeitem',
|
||||
];
|
||||
|
||||
const a11y_required_content = [
|
||||
// anchor-has-content
|
||||
'a',
|
||||
// heading-has-content
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
];
|
||||
|
||||
const a11y_distracting_elements = ['blink', 'marquee'];
|
||||
|
||||
const a11y_nested_implicit_semantics = new Map([
|
||||
['header', 'banner'],
|
||||
['footer', 'contentinfo'],
|
||||
]);
|
||||
const a11y_implicit_semantics = new Map([
|
||||
['a', 'link'],
|
||||
['area', 'link'],
|
||||
['article', 'article'],
|
||||
['aside', 'complementary'],
|
||||
['body', 'document'],
|
||||
['button', 'button'],
|
||||
['datalist', 'listbox'],
|
||||
['dd', 'definition'],
|
||||
['dfn', 'term'],
|
||||
['dialog', 'dialog'],
|
||||
['details', 'group'],
|
||||
['dt', 'term'],
|
||||
['fieldset', 'group'],
|
||||
['figure', 'figure'],
|
||||
['form', 'form'],
|
||||
['h1', 'heading'],
|
||||
['h2', 'heading'],
|
||||
['h3', 'heading'],
|
||||
['h4', 'heading'],
|
||||
['h5', 'heading'],
|
||||
['h6', 'heading'],
|
||||
['hr', 'separator'],
|
||||
['img', 'img'],
|
||||
['li', 'listitem'],
|
||||
['link', 'link'],
|
||||
['main', 'main'],
|
||||
['menu', 'list'],
|
||||
['meter', 'progressbar'],
|
||||
['nav', 'navigation'],
|
||||
['ol', 'list'],
|
||||
['option', 'option'],
|
||||
['optgroup', 'group'],
|
||||
['output', 'status'],
|
||||
['progress', 'progressbar'],
|
||||
['section', 'region'],
|
||||
['summary', 'button'],
|
||||
['table', 'table'],
|
||||
['tbody', 'rowgroup'],
|
||||
['textarea', 'textbox'],
|
||||
['tfoot', 'rowgroup'],
|
||||
['thead', 'rowgroup'],
|
||||
['tr', 'row'],
|
||||
['ul', 'list'],
|
||||
]);
|
||||
const menuitem_type_to_implicit_role = new Map([
|
||||
['command', 'menuitem'],
|
||||
['checkbox', 'menuitemcheckbox'],
|
||||
['radio', 'menuitemradio'],
|
||||
]);
|
||||
const input_type_to_implicit_role = new Map([
|
||||
['button', 'button'],
|
||||
['image', 'button'],
|
||||
['reset', 'button'],
|
||||
['submit', 'button'],
|
||||
['checkbox', 'checkbox'],
|
||||
['radio', 'radio'],
|
||||
['range', 'slider'],
|
||||
['number', 'spinbutton'],
|
||||
['email', 'textbox'],
|
||||
['search', 'searchbox'],
|
||||
['tel', 'textbox'],
|
||||
['text', 'textbox'],
|
||||
['url', 'textbox'],
|
||||
]);
|
||||
|
||||
const ariaAttributes = new Set(
|
||||
'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(
|
||||
' '
|
||||
)
|
||||
);
|
||||
|
||||
const ariaRoles = new Set(
|
||||
'alert alertdialog application article banner button cell checkbox columnheader combobox complementary contentinfo definition dialog directory document feed figure form grid gridcell group heading img link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation none note option presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider spinbutton status tab tablist tabpanel textbox timer toolbar tooltip tree treegrid treeitem'.split(
|
||||
' '
|
||||
)
|
||||
);
|
||||
|
||||
export const a11y: AuditRuleWithSelector[] = [
|
||||
{
|
||||
code: 'a11y-accesskey',
|
||||
title: 'Avoid using `accesskey`',
|
||||
message:
|
||||
"The `accesskey` attribute can cause accessibility issues. The shortcuts can conflict with the browser's or operating system's shortcuts, and they are difficult for users to discover and use.",
|
||||
selector: '[accesskey]',
|
||||
},
|
||||
{
|
||||
code: 'a11y-aria-activedescendant-has-tabindex',
|
||||
title: 'Elements with attribute `aria-activedescendant` must be tabbable',
|
||||
message:
|
||||
'This element must either have an inherent `tabindex` or declare `tabindex` as an attribute.',
|
||||
selector: '[aria-activedescendant]',
|
||||
match(element) {
|
||||
if (!(element as HTMLElement).tabIndex && !element.hasAttribute('tabindex')) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-aria-attributes',
|
||||
title: 'Element does not support ARIA roles.',
|
||||
message: 'Elements like `meta`, `html`, `script`, `style` do not support having ARIA roles.',
|
||||
selector: ':is(meta, html, script, style)[role]',
|
||||
match(element) {
|
||||
for (const attribute of element.attributes) {
|
||||
if (attribute.name.startsWith('aria-')) return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-autofocus',
|
||||
title: 'Avoid using `autofocus`',
|
||||
message:
|
||||
'The `autofocus` attribute can cause accessibility issues, as it can cause the focus to move around unexpectedly for screen reader users.',
|
||||
selector: '[autofocus]',
|
||||
},
|
||||
{
|
||||
code: 'a11y-distracting-elements',
|
||||
title: 'Distracting elements should not be used',
|
||||
message:
|
||||
'Elements that can be visually distracting like `<marquee>` or `<blink>` can cause accessibility issues for visually impaired users and should be avoided.',
|
||||
selector: `:is(${a11y_distracting_elements.join(',')})`,
|
||||
},
|
||||
{
|
||||
code: 'a11y-hidden',
|
||||
title: 'Certain DOM elements are useful for screen reader navigation and should not be hidden',
|
||||
message: (element) => `${element.localName} element should not be hidden.`,
|
||||
selector: '[aria-hidden]:is(h1,h2,h3,h4,h5,h6)',
|
||||
},
|
||||
{
|
||||
code: 'a11y-img-redundant-alt',
|
||||
title: 'Redundant text in alt attribute',
|
||||
message:
|
||||
'Screen readers already announce `img` elements as an image. There is no need to use words such as "image", "photo", and/or "picture".',
|
||||
selector: 'img[alt]:not([aria-hidden])',
|
||||
match: (img: HTMLImageElement) => /\b(image|picture|photo)\b/i.test(img.alt),
|
||||
},
|
||||
{
|
||||
code: 'a11y-incorrect-aria-attribute-type',
|
||||
title: 'Incorrect value for ARIA attribute.',
|
||||
message: '`aria-hidden` should only receive a boolean.',
|
||||
selector: '[aria-hidden]',
|
||||
match(element) {
|
||||
const value = element.getAttribute('aria-hidden');
|
||||
if (!value) return true;
|
||||
if (!['true', 'false'].includes(value)) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-invalid-attribute',
|
||||
title: 'Attributes important for accessibility should have a valid value',
|
||||
message: "`href` should not be empty, `'#'`, or `javascript:`.",
|
||||
selector: 'a[href]:is([href=""], [href="#"], [href^="javascript:" i])',
|
||||
},
|
||||
{
|
||||
code: 'a11y-label-has-associated-control',
|
||||
title: '`label` tag should have an associated control and a text content.',
|
||||
message:
|
||||
'The `label` tag must be associated with a control using either `for` or having a nested input. Additionally, the `label` tag must have text content.',
|
||||
selector: 'label:not([for])',
|
||||
match(element) {
|
||||
const inputChild = element.querySelector('input');
|
||||
if (!inputChild?.textContent) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-media-has-caption',
|
||||
title: 'Unmuted video elements should have captions',
|
||||
message:
|
||||
'Videos without captions can be difficult for deaf and hard-of-hearing users to follow along with. If the video does not need captions, add the `muted` attribute.',
|
||||
selector: 'video:not([muted])',
|
||||
match(element) {
|
||||
const tracks = element.querySelectorAll('track');
|
||||
if (!tracks.length) return true;
|
||||
|
||||
const hasCaptionTrack = Array.from(tracks).some(
|
||||
(track) => track.getAttribute('kind') === 'captions'
|
||||
);
|
||||
|
||||
return !hasCaptionTrack;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-misplaced-scope',
|
||||
title: 'The `scope` attribute should only be used on `<th>` elements',
|
||||
message:
|
||||
'The `scope` attribute tells the browser and screen readers how to navigate tables. In HTML5, it should only be used on `<th>` elements.',
|
||||
selector: ':not(th)[scope]',
|
||||
},
|
||||
{
|
||||
code: 'a11y-missing-attribute',
|
||||
title: 'Required attributes missing.',
|
||||
message: (element) => {
|
||||
const requiredAttributes =
|
||||
a11y_required_attributes[element.localName as keyof typeof a11y_required_attributes];
|
||||
|
||||
const missingAttributes = requiredAttributes.filter(
|
||||
(attribute) => !element.hasAttribute(attribute)
|
||||
);
|
||||
|
||||
return `${
|
||||
element.localName
|
||||
} element is missing required attributes for accessibility: ${missingAttributes.join(', ')} `;
|
||||
},
|
||||
selector: Object.keys(a11y_required_attributes).join(','),
|
||||
match(element) {
|
||||
const requiredAttributes =
|
||||
a11y_required_attributes[element.localName as keyof typeof a11y_required_attributes];
|
||||
|
||||
if (!requiredAttributes) return true;
|
||||
for (const attribute of requiredAttributes) {
|
||||
if (!element.hasAttribute(attribute)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-missing-content',
|
||||
title: 'Missing content on element important for accessibility',
|
||||
message: 'Headings and anchors must have content to be accessible.',
|
||||
selector: a11y_required_content.join(','),
|
||||
match(element) {
|
||||
if (!element.textContent) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-no-redundant-roles',
|
||||
title: 'HTML element has redundant ARIA roles',
|
||||
message:
|
||||
'Giving these elements an ARIA role that is already set by the browser has no effect and is redundant.',
|
||||
selector: [...a11y_implicit_semantics.keys()].join(','),
|
||||
match(element) {
|
||||
const role = element.getAttribute('role');
|
||||
|
||||
if (element.localName === 'input') {
|
||||
const type = element.getAttribute('type');
|
||||
if (!type) return true;
|
||||
|
||||
const implicitRoleForType = input_type_to_implicit_role.get(type);
|
||||
if (!implicitRoleForType) return true;
|
||||
|
||||
if (role === implicitRoleForType) return false;
|
||||
}
|
||||
|
||||
// TODO: Handle menuitem and elements that inherit their role from their parent
|
||||
|
||||
const implicitRole = a11y_implicit_semantics.get(element.localName);
|
||||
if (!implicitRole) return true;
|
||||
|
||||
if (role === implicitRole) return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-no-interactive-element-to-noninteractive-role',
|
||||
title: 'Non-interactive ARIA role used on interactive HTML element.',
|
||||
message:
|
||||
'Interactive HTML elements like `<a>` and `<button>` cannot use non-interactive roles like `heading`, `list`, `menu`, and `toolbar`.',
|
||||
selector: `[role]:is(${interactiveElements.join(',')})`,
|
||||
match(element) {
|
||||
const role = element.getAttribute('role');
|
||||
if (!role) return false;
|
||||
if (!ariaRoles.has(role)) return false;
|
||||
|
||||
if (aria_non_interactive_roles.includes(role)) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-no-noninteractive-element-to-interactive-role',
|
||||
title: 'Interactive ARIA role used on non-interactive HTML element.',
|
||||
message:
|
||||
'Interactive roles should not be used to convert a non-interactive element to an interactive element',
|
||||
selector: `[role]:not(${interactiveElements.join(',')})`,
|
||||
match(element) {
|
||||
const role = element.getAttribute('role');
|
||||
if (!role) return false;
|
||||
if (!ariaRoles.has(role)) return false;
|
||||
const exceptions =
|
||||
a11y_non_interactive_element_to_interactive_role_exceptions[
|
||||
element.localName as keyof typeof a11y_non_interactive_element_to_interactive_role_exceptions
|
||||
];
|
||||
if (exceptions?.includes(role)) return false;
|
||||
|
||||
if (!aria_non_interactive_roles.includes(role)) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-no-noninteractive-tabindex',
|
||||
title: 'Invalid `tabindex` on non-interactive element',
|
||||
message: (element) => `${element.localName} elements should not have \`tabindex\` attribute`,
|
||||
selector: '[tabindex]',
|
||||
match(element) {
|
||||
// Scrollable elements are considered interactive
|
||||
// See: https://www.w3.org/WAI/standards-guidelines/act/rules/0ssw9k/proposed/
|
||||
const isScrollable =
|
||||
element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
||||
if (isScrollable) return false;
|
||||
|
||||
if (!interactiveElements.includes(element.localName)) return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-positive-tabindex',
|
||||
title: 'Avoid positive `tabindex` property values',
|
||||
message:
|
||||
'This will move elements out of the expected tab order, creating a confusing experience for keyboard users.',
|
||||
selector: '[tabindex]:not([tabindex="-1"]):not([tabindex="0"])',
|
||||
},
|
||||
{
|
||||
code: 'a11y-role-has-required-aria-props',
|
||||
title: 'Missing attributes required for ARIA role',
|
||||
message: (element) => {
|
||||
const { __astro_role: role, __astro_missing_attributes: required } = element as any;
|
||||
return `${
|
||||
element.localName
|
||||
} element is missing required attributes for its role (${role}): ${required.join(', ')}`;
|
||||
},
|
||||
selector: '*',
|
||||
match(element) {
|
||||
const role = getRole(element);
|
||||
if (!role) return false;
|
||||
if (is_semantic_role_element(role, element.localName, getAttributeObject(element))) {
|
||||
return;
|
||||
}
|
||||
const { requiredProps } = roles.get(role)!;
|
||||
const required_role_props = Object.keys(requiredProps);
|
||||
const missingProps = required_role_props.filter((prop) => !element.hasAttribute(prop));
|
||||
if (missingProps.length > 0) {
|
||||
(element as any).__astro_role = role;
|
||||
(element as any).__astro_missing_attributes = missingProps;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-role-supports-aria-props',
|
||||
title: 'Unsupported ARIA attribute',
|
||||
message: (element) => {
|
||||
const { __astro_role: role, __astro_unsupported_attributes: unsupported } = element as any;
|
||||
return `${
|
||||
element.localName
|
||||
} element has ARIA attributes that are not supported by its role (${role}): ${unsupported.join(
|
||||
', '
|
||||
)}`;
|
||||
},
|
||||
selector: '*',
|
||||
match(element) {
|
||||
const role = getRole(element);
|
||||
if (!role) return false;
|
||||
const { props } = roles.get(role)!;
|
||||
const attributes = getAttributeObject(element);
|
||||
const unsupportedAttributes = aria.keys().filter((attribute) => !(attribute in props));
|
||||
const invalidAttributes: string[] = Object.keys(attributes).filter(
|
||||
(key) => key.startsWith('aria-') && unsupportedAttributes.includes(key as any)
|
||||
);
|
||||
if (invalidAttributes.length > 0) {
|
||||
(element as any).__astro_role = role;
|
||||
(element as any).__astro_unsupported_attributes = invalidAttributes;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-structure',
|
||||
title: 'Invalid DOM structure',
|
||||
message:
|
||||
'The DOM structure must be valid for accessibility of the page, for example `figcaption` must be a direct child of `figure`.',
|
||||
selector: 'figcaption:not(figure > figcaption)',
|
||||
},
|
||||
{
|
||||
code: 'a11y-unknown-aria-attribute',
|
||||
title: 'Unknown ARIA attribute',
|
||||
message: 'ARIA attributes prefixed with `aria-` must be valid, non-abstract ARIA attributes.',
|
||||
selector: '*',
|
||||
match(element) {
|
||||
for (const attribute of element.attributes) {
|
||||
if (attribute.name.startsWith('aria-')) {
|
||||
if (!ariaAttributes.has(attribute.name.slice('aria-'.length))) return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'a11y-unknown-role',
|
||||
title: 'Unknown ARIA role',
|
||||
message: 'ARIA roles must be valid, non-abstract ARIA roles.',
|
||||
selector: '[role]',
|
||||
match(element) {
|
||||
const role = element.getAttribute('role');
|
||||
if (!role) return true;
|
||||
if (!ariaRoles.has(role)) return true;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const a11y_labelable = [
|
||||
'button',
|
||||
'input',
|
||||
'keygen',
|
||||
'meter',
|
||||
'output',
|
||||
'progress',
|
||||
'select',
|
||||
'textarea',
|
||||
];
|
||||
|
||||
/**
|
||||
* Exceptions to the rule which follows common A11y conventions
|
||||
* TODO make this configurable by the user
|
||||
* @type {Record<string, string[]>}
|
||||
*/
|
||||
const a11y_non_interactive_element_to_interactive_role_exceptions = {
|
||||
ul: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
|
||||
ol: ['listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid'],
|
||||
li: ['menuitem', 'option', 'row', 'tab', 'treeitem'],
|
||||
table: ['grid'],
|
||||
td: ['gridcell'],
|
||||
fieldset: ['radiogroup', 'presentation'],
|
||||
};
|
||||
|
||||
const combobox_if_list = ['email', 'search', 'tel', 'text', 'url'];
|
||||
function input_implicit_role(attributes: Record<string, string>) {
|
||||
if (!('type' in attributes)) return;
|
||||
const { type, list } = attributes;
|
||||
if (!type) return;
|
||||
if (list && combobox_if_list.includes(type)) {
|
||||
return 'combobox';
|
||||
}
|
||||
return input_type_to_implicit_role.get(type);
|
||||
}
|
||||
|
||||
/** @param {Map<string, import('#compiler').Attribute>} attribute_map */
|
||||
function menuitem_implicit_role(attributes: Record<string, string>) {
|
||||
if (!('type' in attributes)) return;
|
||||
const { type } = attributes;
|
||||
if (!type) return;
|
||||
return menuitem_type_to_implicit_role.get(type);
|
||||
}
|
||||
|
||||
function getRole(element: Element): ARIARoleDefinitionKey | undefined {
|
||||
if (element.hasAttribute('role')) {
|
||||
return element.getAttribute('role')! as ARIARoleDefinitionKey;
|
||||
}
|
||||
return getImplicitRole(element) as ARIARoleDefinitionKey;
|
||||
}
|
||||
|
||||
function getImplicitRole(element: Element) {
|
||||
const name = element.localName;
|
||||
const attrs = getAttributeObject(element);
|
||||
if (name === 'menuitem') {
|
||||
return menuitem_implicit_role(attrs);
|
||||
} else if (name === 'input') {
|
||||
return input_implicit_role(attrs);
|
||||
} else {
|
||||
return a11y_implicit_semantics.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
function getAttributeObject(element: Element): Record<string, string> {
|
||||
let obj: Record<string, string> = {};
|
||||
for (let i = 0; i < element.attributes.length; i++) {
|
||||
const attribute = element.attributes.item(i)!;
|
||||
obj[attribute.name] = attribute.value;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('aria-query').ARIARoleDefinitionKey} role
|
||||
* @param {string} tag_name
|
||||
* @param {Map<string, import('#compiler').Attribute>} attribute_map
|
||||
*/
|
||||
function is_semantic_role_element(
|
||||
role: string,
|
||||
tag_name: string,
|
||||
attributes: Record<string, string>
|
||||
) {
|
||||
for (const [schema, ax_object] of elementAXObjects.entries()) {
|
||||
if (
|
||||
schema.name === tag_name &&
|
||||
(!schema.attributes ||
|
||||
schema.attributes.every((attr: any) => attributes[attr.name] === attr.value))
|
||||
) {
|
||||
for (const name of ax_object) {
|
||||
const axRoles = AXObjectRoles.get(name);
|
||||
if (axRoles) {
|
||||
for (const { name: _name } of axRoles) {
|
||||
if (_name === role) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
|
@ -1,23 +1,47 @@
|
|||
import type { DevOverlayMetadata, DevOverlayPlugin } from '../../../../@types/astro.js';
|
||||
import type { DevOverlayHighlight } from '../ui-library/highlight.js';
|
||||
import { attachTooltipToHighlight, createHighlight, positionHighlight } from './utils/highlight.js';
|
||||
import { createWindowElement } from './utils/window.js';
|
||||
import type { DevOverlayMetadata, DevOverlayPlugin } from '../../../../../@types/astro.js';
|
||||
import type { DevOverlayHighlight } from '../../ui-library/highlight.js';
|
||||
import {
|
||||
attachTooltipToHighlight,
|
||||
createHighlight,
|
||||
positionHighlight,
|
||||
} from '../utils/highlight.js';
|
||||
import { createWindowElement } from '../utils/window.js';
|
||||
import { a11y } from './a11y.js';
|
||||
|
||||
const icon =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 1 20 16"><path fill="#fff" d="M.6 2A1.1 1.1 0 0 1 1.7.9h16.6a1.1 1.1 0 1 1 0 2.2H1.6A1.1 1.1 0 0 1 .8 2Zm1.1 7.1h6a1.1 1.1 0 0 0 0-2.2h-6a1.1 1.1 0 0 0 0 2.2ZM9.3 13H1.8a1.1 1.1 0 1 0 0 2.2h7.5a1.1 1.1 0 1 0 0-2.2Zm11.3 1.9a1.1 1.1 0 0 1-1.5 0l-1.7-1.7a4.1 4.1 0 1 1 1.6-1.6l1.6 1.7a1.1 1.1 0 0 1 0 1.6Zm-5.3-3.4a1.9 1.9 0 1 0 0-3.8 1.9 1.9 0 0 0 0 3.8Z"/></svg>';
|
||||
|
||||
interface AuditRule {
|
||||
type DynamicString = string | ((element: Element) => string);
|
||||
|
||||
export interface AuditRule {
|
||||
code: string;
|
||||
title: DynamicString;
|
||||
message: DynamicString;
|
||||
}
|
||||
|
||||
export interface ResolvedAuditRule {
|
||||
code: string;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const selectorBasedRules: (AuditRule & { selector: string })[] = [
|
||||
{
|
||||
title: 'Missing `alt` tag',
|
||||
message: 'The alt attribute is important for accessibility.',
|
||||
selector: 'img:not([alt])',
|
||||
},
|
||||
];
|
||||
export interface AuditRuleWithSelector extends AuditRule {
|
||||
selector: string;
|
||||
match?: (element: Element) => boolean | null | undefined | void;
|
||||
}
|
||||
|
||||
const rules = [...a11y];
|
||||
|
||||
const dynamicAuditRuleKeys: Array<keyof AuditRule> = ['title', 'message'];
|
||||
function resolveAuditRule(rule: AuditRule, element: Element): ResolvedAuditRule {
|
||||
let resolved: ResolvedAuditRule = { ...rule } as any;
|
||||
for (const key of dynamicAuditRuleKeys) {
|
||||
const value = rule[key];
|
||||
if (typeof value === 'string') continue;
|
||||
resolved[key] = value(element);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export default {
|
||||
id: 'astro:audit',
|
||||
|
@ -58,11 +82,22 @@ export default {
|
|||
});
|
||||
audits = [];
|
||||
canvas.getElementById('no-audit')?.remove();
|
||||
const selectorCache = new Map<string, NodeListOf<Element>>();
|
||||
|
||||
for (const rule of selectorBasedRules) {
|
||||
const elements = document.querySelectorAll(rule.selector);
|
||||
|
||||
for (const element of elements) {
|
||||
for (const rule of rules) {
|
||||
const elements =
|
||||
selectorCache.get(rule.selector) ?? document.querySelectorAll(rule.selector);
|
||||
let matches: Element[] = [];
|
||||
if (typeof rule.match === 'undefined') {
|
||||
matches = Array.from(elements);
|
||||
} else {
|
||||
for (const element of elements) {
|
||||
if (rule.match(element)) {
|
||||
matches.push(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const element of matches) {
|
||||
await createAuditProblem(rule, element);
|
||||
}
|
||||
}
|
||||
|
@ -89,7 +124,7 @@ export default {
|
|||
header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -151,6 +186,7 @@ export default {
|
|||
|
||||
// If the element is hidden, don't do anything
|
||||
if (targetedElement.offsetParent === null || computedStyle.display === 'none') {
|
||||
audits.push();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -170,14 +206,15 @@ export default {
|
|||
|
||||
function buildAuditTooltip(rule: AuditRule, element: Element) {
|
||||
const tooltip = document.createElement('astro-dev-toolbar-tooltip');
|
||||
const { title, message } = resolveAuditRule(rule, element);
|
||||
|
||||
tooltip.sections = [
|
||||
{
|
||||
icon: 'warning',
|
||||
title: rule.title,
|
||||
title: escapeHtml(title),
|
||||
},
|
||||
{
|
||||
content: rule.message,
|
||||
content: escapeHtml(message),
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -203,5 +240,14 @@ export default {
|
|||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe: string) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
},
|
||||
} satisfies DevOverlayPlugin;
|
|
@ -35,7 +35,7 @@ export class DevOverlayTooltip extends HTMLElement {
|
|||
z-index: 9999999;
|
||||
max-width: 45ch;
|
||||
width: fit-content;
|
||||
min-width: 27ch;
|
||||
min-width: 30ch;
|
||||
box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0.30), 0px 1px 2px 0px rgba(0, 0, 0, 0.29), 0px 4px 4px 0px rgba(0, 0, 0, 0.26), 0px 10px 6px 0px rgba(0, 0, 0, 0.15), 0px 17px 7px 0px rgba(0, 0, 0, 0.04), 0px 26px 7px 0px rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
|
|
|
@ -508,6 +508,12 @@ importers:
|
|||
acorn:
|
||||
specifier: ^8.11.2
|
||||
version: 8.11.2
|
||||
aria-query:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
axobject-query:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
boxen:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.1
|
||||
|
@ -666,6 +672,9 @@ importers:
|
|||
'@playwright/test':
|
||||
specifier: 1.40.0
|
||||
version: 1.40.0
|
||||
'@types/aria-query':
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
'@types/babel__generator':
|
||||
specifier: ^7.6.7
|
||||
version: 7.6.7
|
||||
|
@ -7241,6 +7250,10 @@ packages:
|
|||
resolution: {integrity: sha512-BSNTroRhmBkNiyd7ELK/5Boja92hnQMST6H4z1BqXKeMVzHjp9o1j5poqd5Tyhjd8oMFwxYC4I00eghfg2xrTA==}
|
||||
dev: false
|
||||
|
||||
/@types/aria-query@5.0.4:
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
dev: true
|
||||
|
||||
/@types/babel__core@7.20.4:
|
||||
resolution: {integrity: sha512-mLnSC22IC4vcWiuObSRjrLd9XcBTGf59vUSoq2jkQDJ/QQ8PMI9rSuzE+aEV8karUMbskw07bKYoUJCKTUaygg==}
|
||||
dependencies:
|
||||
|
@ -8358,6 +8371,12 @@ packages:
|
|||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
/axobject-query@4.0.0:
|
||||
resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==}
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
dev: false
|
||||
|
||||
/b4a@1.6.4:
|
||||
resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==}
|
||||
requiresBuild: true
|
||||
|
|
Loading…
Reference in a new issue