mirror of
https://github.com/withastro/astro.git
synced 2024-12-16 21:46:22 -05:00
Add reverted aria audit rules for dev toolbar (#9377)
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
This commit is contained in:
parent
f85cb1fab6
commit
fe719e27a8
5 changed files with 187 additions and 0 deletions
5
.changeset/chilly-students-glow.md
Normal file
5
.changeset/chilly-students-glow.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Adds "Missing ARIA roles check" and "Unsupported ARIA roles check" audit rules for the dev toolbar
|
|
@ -126,6 +126,8 @@
|
||||||
"@babel/types": "^7.23.3",
|
"@babel/types": "^7.23.3",
|
||||||
"@types/babel__core": "^7.20.4",
|
"@types/babel__core": "^7.20.4",
|
||||||
"acorn": "^8.11.2",
|
"acorn": "^8.11.2",
|
||||||
|
"aria-query": "^5.3.0",
|
||||||
|
"axobject-query": "^4.0.0",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"ci-info": "^4.0.0",
|
"ci-info": "^4.0.0",
|
||||||
|
@ -182,6 +184,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.3.1",
|
"@astrojs/check": "^0.3.1",
|
||||||
"@playwright/test": "1.40.0",
|
"@playwright/test": "1.40.0",
|
||||||
|
"@types/aria-query": "^5.0.4",
|
||||||
"@types/babel__generator": "^7.6.7",
|
"@types/babel__generator": "^7.6.7",
|
||||||
"@types/babel__traverse": "^7.20.4",
|
"@types/babel__traverse": "^7.20.4",
|
||||||
"@types/chai": "^4.3.10",
|
"@types/chai": "^4.3.10",
|
||||||
|
|
|
@ -23,6 +23,10 @@
|
||||||
* SOFTWARE.
|
* 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';
|
import type { AuditRuleWithSelector } from './index.js';
|
||||||
|
|
||||||
const a11y_required_attributes = {
|
const a11y_required_attributes = {
|
||||||
|
@ -125,6 +129,8 @@ const a11y_required_content = [
|
||||||
|
|
||||||
const a11y_distracting_elements = ['blink', 'marquee'];
|
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([
|
const a11y_nested_implicit_semantics = new Map([
|
||||||
['header', 'banner'],
|
['header', 'banner'],
|
||||||
['footer', 'contentinfo'],
|
['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.',
|
'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"])',
|
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',
|
code: 'a11y-structure',
|
||||||
title: 'Invalid DOM 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
|
* Exceptions to the rule which follows common A11y conventions
|
||||||
* TODO make this configurable by the user
|
* TODO make this configurable by the user
|
||||||
|
@ -489,3 +563,81 @@ const a11y_non_interactive_element_to_interactive_role_exceptions = {
|
||||||
td: ['gridcell'],
|
td: ['gridcell'],
|
||||||
fieldset: ['radiogroup', 'presentation'],
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,14 @@ const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
|
||||||
export default function astroDevOverlay({ settings }: AstroPluginOptions): vite.Plugin {
|
export default function astroDevOverlay({ settings }: AstroPluginOptions): vite.Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'astro:dev-overlay',
|
name: 'astro:dev-overlay',
|
||||||
|
config() {
|
||||||
|
return {
|
||||||
|
optimizeDeps: {
|
||||||
|
// Optimize CJS dependencies used by the dev toolbar
|
||||||
|
include: ['astro > aria-query', 'astro > axobject-query'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
resolveId(id) {
|
resolveId(id) {
|
||||||
if (id === VIRTUAL_MODULE_ID) {
|
if (id === VIRTUAL_MODULE_ID) {
|
||||||
return resolvedVirtualModuleId;
|
return resolvedVirtualModuleId;
|
||||||
|
|
|
@ -524,6 +524,12 @@ importers:
|
||||||
acorn:
|
acorn:
|
||||||
specifier: ^8.11.2
|
specifier: ^8.11.2
|
||||||
version: 8.11.3
|
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:
|
boxen:
|
||||||
specifier: ^7.1.1
|
specifier: ^7.1.1
|
||||||
version: 7.1.1
|
version: 7.1.1
|
||||||
|
@ -682,6 +688,9 @@ importers:
|
||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: 1.40.0
|
specifier: 1.40.0
|
||||||
version: 1.40.0
|
version: 1.40.0
|
||||||
|
'@types/aria-query':
|
||||||
|
specifier: ^5.0.4
|
||||||
|
version: 5.0.4
|
||||||
'@types/babel__generator':
|
'@types/babel__generator':
|
||||||
specifier: ^7.6.7
|
specifier: ^7.6.7
|
||||||
version: 7.6.8
|
version: 7.6.8
|
||||||
|
@ -7341,6 +7350,10 @@ packages:
|
||||||
resolution: {integrity: sha512-BSNTroRhmBkNiyd7ELK/5Boja92hnQMST6H4z1BqXKeMVzHjp9o1j5poqd5Tyhjd8oMFwxYC4I00eghfg2xrTA==}
|
resolution: {integrity: sha512-BSNTroRhmBkNiyd7ELK/5Boja92hnQMST6H4z1BqXKeMVzHjp9o1j5poqd5Tyhjd8oMFwxYC4I00eghfg2xrTA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/aria-query@5.0.4:
|
||||||
|
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/babel__core@7.20.5:
|
/@types/babel__core@7.20.5:
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -8444,6 +8457,12 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
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:
|
/b4a@1.6.4:
|
||||||
resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==}
|
resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
Loading…
Reference in a new issue