diff --git a/.changeset/orange-candles-sip.md b/.changeset/orange-candles-sip.md new file mode 100644 index 0000000000..442e386b74 --- /dev/null +++ b/.changeset/orange-candles-sip.md @@ -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! diff --git a/LICENSE b/LICENSE index 1f0bcaa7dc..b3cd0c0f0e 100644 --- a/LICENSE +++ b/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: diff --git a/packages/astro/package.json b/packages/astro/package.json index 23b2d68b08..7641ce2b5c 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts b/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts index f7c09dea6d..ffd9ea32b5 100644 --- a/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts +++ b/packages/astro/src/runtime/client/dev-overlay/entrypoint.ts @@ -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'), diff --git a/packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts new file mode 100644 index 0000000000..fd3763564b --- /dev/null +++ b/packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts @@ -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 `` or `` 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 `` elements', + message: + 'The `scope` attribute tells the browser and screen readers how to navigate tables. In HTML5, it should only be used on `` 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 `` and `