diff --git a/.changeset/chilly-students-glow.md b/.changeset/chilly-students-glow.md new file mode 100644 index 000000000000..b7f5ca4e896c --- /dev/null +++ b/.changeset/chilly-students-glow.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Adds "Missing ARIA roles check" and "Unsupported ARIA roles check" audit rules for the dev toolbar diff --git a/packages/astro/package.json b/packages/astro/package.json index 771289fe6df8..aa585b45ea3b 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -126,6 +126,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", @@ -182,6 +184,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/plugins/audit/a11y.ts b/packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts index 15432e910538..5a1fd4693893 100644 --- a/packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts +++ b/packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts @@ -23,6 +23,10 @@ * SOFTWARE. */ +import type { ARIARoleDefinitionKey } from 'aria-query'; +import { aria, roles } from 'aria-query'; +// @ts-expect-error package does not provide types +import { AXObjectRoles, elementAXObjects } from 'axobject-query'; import type { AuditRuleWithSelector } from './index.js'; const a11y_required_attributes = { @@ -125,6 +129,8 @@ const a11y_required_content = [ const a11y_distracting_elements = ['blink', 'marquee']; +// Unused for now +// eslint-disable-next-line @typescript-eslint/no-unused-vars const a11y_nested_implicit_semantics = new Map([ ['header', 'banner'], ['footer', 'contentinfo'], @@ -443,6 +449,61 @@ export const a11y: AuditRuleWithSelector[] = [ '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', @@ -476,6 +537,19 @@ export const a11y: AuditRuleWithSelector[] = [ }, ]; +// Unused for now +// eslint-disable-next-line @typescript-eslint/no-unused-vars +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 @@ -489,3 +563,81 @@ const a11y_non_interactive_element_to_interactive_role_exceptions = { td: ['gridcell'], fieldset: ['radiogroup', 'presentation'], }; + +const combobox_if_list = ['email', 'search', 'tel', 'text', 'url']; +function input_implicit_role(attributes: Record) { + 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} attribute_map */ +function menuitem_implicit_role(attributes: Record) { + 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 { + let obj: Record = {}; + 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} attribute_map + */ +function is_semantic_role_element( + role: string, + tag_name: string, + attributes: Record +) { + 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; +} diff --git a/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts b/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts index 31cdb8f71ac0..d8227a8af082 100644 --- a/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts +++ b/packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts @@ -7,6 +7,14 @@ const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID; export default function astroDevOverlay({ settings }: AstroPluginOptions): vite.Plugin { return { name: 'astro:dev-overlay', + config() { + return { + optimizeDeps: { + // Optimize CJS dependencies used by the dev toolbar + include: ['astro > aria-query', 'astro > axobject-query'], + }, + }; + }, resolveId(id) { if (id === VIRTUAL_MODULE_ID) { return resolvedVirtualModuleId; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea42a5abe7ce..7902ad244ac2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -524,6 +524,12 @@ importers: acorn: specifier: ^8.11.2 version: 8.11.3 + 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 @@ -682,6 +688,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.8 @@ -7341,6 +7350,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.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -8444,6 +8457,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==} dev: false