Skip to content

Commit fe719e2

Browse files
bluwyematipico
andauthoredJan 3, 2024
Add reverted aria audit rules for dev toolbar (#9377)
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
1 parent f85cb1f commit fe719e2

File tree

5 files changed

+187
-0
lines changed

5 files changed

+187
-0
lines changed
 

‎.changeset/chilly-students-glow.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Adds "Missing ARIA roles check" and "Unsupported ARIA roles check" audit rules for the dev toolbar

‎packages/astro/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@
126126
"@babel/types": "^7.23.3",
127127
"@types/babel__core": "^7.20.4",
128128
"acorn": "^8.11.2",
129+
"aria-query": "^5.3.0",
130+
"axobject-query": "^4.0.0",
129131
"boxen": "^7.1.1",
130132
"chokidar": "^3.5.3",
131133
"ci-info": "^4.0.0",
@@ -182,6 +184,7 @@
182184
"devDependencies": {
183185
"@astrojs/check": "^0.3.1",
184186
"@playwright/test": "1.40.0",
187+
"@types/aria-query": "^5.0.4",
185188
"@types/babel__generator": "^7.6.7",
186189
"@types/babel__traverse": "^7.20.4",
187190
"@types/chai": "^4.3.10",

‎packages/astro/src/runtime/client/dev-overlay/plugins/audit/a11y.ts

+152
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
* SOFTWARE.
2424
*/
2525

26+
import type { ARIARoleDefinitionKey } from 'aria-query';
27+
import { aria, roles } from 'aria-query';
28+
// @ts-expect-error package does not provide types
29+
import { AXObjectRoles, elementAXObjects } from 'axobject-query';
2630
import type { AuditRuleWithSelector } from './index.js';
2731

2832
const a11y_required_attributes = {
@@ -125,6 +129,8 @@ const a11y_required_content = [
125129

126130
const a11y_distracting_elements = ['blink', 'marquee'];
127131

132+
// Unused for now
133+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
128134
const a11y_nested_implicit_semantics = new Map([
129135
['header', 'banner'],
130136
['footer', 'contentinfo'],
@@ -443,6 +449,61 @@ export const a11y: AuditRuleWithSelector[] = [
443449
'This will move elements out of the expected tab order, creating a confusing experience for keyboard users.',
444450
selector: '[tabindex]:not([tabindex="-1"]):not([tabindex="0"])',
445451
},
452+
{
453+
code: 'a11y-role-has-required-aria-props',
454+
title: 'Missing attributes required for ARIA role',
455+
message: (element) => {
456+
const { __astro_role: role, __astro_missing_attributes: required } = element as any;
457+
return `${
458+
element.localName
459+
} element is missing required attributes for its role (${role}): ${required.join(', ')}`;
460+
},
461+
selector: '*',
462+
match(element) {
463+
const role = getRole(element);
464+
if (!role) return false;
465+
if (is_semantic_role_element(role, element.localName, getAttributeObject(element))) {
466+
return;
467+
}
468+
const { requiredProps } = roles.get(role)!;
469+
const required_role_props = Object.keys(requiredProps);
470+
const missingProps = required_role_props.filter((prop) => !element.hasAttribute(prop));
471+
if (missingProps.length > 0) {
472+
(element as any).__astro_role = role;
473+
(element as any).__astro_missing_attributes = missingProps;
474+
return true;
475+
}
476+
},
477+
},
478+
479+
{
480+
code: 'a11y-role-supports-aria-props',
481+
title: 'Unsupported ARIA attribute',
482+
message: (element) => {
483+
const { __astro_role: role, __astro_unsupported_attributes: unsupported } = element as any;
484+
return `${
485+
element.localName
486+
} element has ARIA attributes that are not supported by its role (${role}): ${unsupported.join(
487+
', '
488+
)}`;
489+
},
490+
selector: '*',
491+
match(element) {
492+
const role = getRole(element);
493+
if (!role) return false;
494+
const { props } = roles.get(role)!;
495+
const attributes = getAttributeObject(element);
496+
const unsupportedAttributes = aria.keys().filter((attribute) => !(attribute in props));
497+
const invalidAttributes: string[] = Object.keys(attributes).filter(
498+
(key) => key.startsWith('aria-') && unsupportedAttributes.includes(key as any)
499+
);
500+
if (invalidAttributes.length > 0) {
501+
(element as any).__astro_role = role;
502+
(element as any).__astro_unsupported_attributes = invalidAttributes;
503+
return true;
504+
}
505+
},
506+
},
446507
{
447508
code: 'a11y-structure',
448509
title: 'Invalid DOM structure',
@@ -476,6 +537,19 @@ export const a11y: AuditRuleWithSelector[] = [
476537
},
477538
];
478539

540+
// Unused for now
541+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
542+
const a11y_labelable = [
543+
'button',
544+
'input',
545+
'keygen',
546+
'meter',
547+
'output',
548+
'progress',
549+
'select',
550+
'textarea',
551+
];
552+
479553
/**
480554
* Exceptions to the rule which follows common A11y conventions
481555
* TODO make this configurable by the user
@@ -489,3 +563,81 @@ const a11y_non_interactive_element_to_interactive_role_exceptions = {
489563
td: ['gridcell'],
490564
fieldset: ['radiogroup', 'presentation'],
491565
};
566+
567+
const combobox_if_list = ['email', 'search', 'tel', 'text', 'url'];
568+
function input_implicit_role(attributes: Record<string, string>) {
569+
if (!('type' in attributes)) return;
570+
const { type, list } = attributes;
571+
if (!type) return;
572+
if (list && combobox_if_list.includes(type)) {
573+
return 'combobox';
574+
}
575+
return input_type_to_implicit_role.get(type);
576+
}
577+
578+
/** @param {Map<string, import('#compiler').Attribute>} attribute_map */
579+
function menuitem_implicit_role(attributes: Record<string, string>) {
580+
if (!('type' in attributes)) return;
581+
const { type } = attributes;
582+
if (!type) return;
583+
return menuitem_type_to_implicit_role.get(type);
584+
}
585+
586+
function getRole(element: Element): ARIARoleDefinitionKey | undefined {
587+
if (element.hasAttribute('role')) {
588+
return element.getAttribute('role')! as ARIARoleDefinitionKey;
589+
}
590+
return getImplicitRole(element) as ARIARoleDefinitionKey;
591+
}
592+
593+
function getImplicitRole(element: Element) {
594+
const name = element.localName;
595+
const attrs = getAttributeObject(element);
596+
if (name === 'menuitem') {
597+
return menuitem_implicit_role(attrs);
598+
} else if (name === 'input') {
599+
return input_implicit_role(attrs);
600+
} else {
601+
return a11y_implicit_semantics.get(name);
602+
}
603+
}
604+
605+
function getAttributeObject(element: Element): Record<string, string> {
606+
let obj: Record<string, string> = {};
607+
for (let i = 0; i < element.attributes.length; i++) {
608+
const attribute = element.attributes.item(i)!;
609+
obj[attribute.name] = attribute.value;
610+
}
611+
return obj;
612+
}
613+
614+
/**
615+
* @param {import('aria-query').ARIARoleDefinitionKey} role
616+
* @param {string} tag_name
617+
* @param {Map<string, import('#compiler').Attribute>} attribute_map
618+
*/
619+
function is_semantic_role_element(
620+
role: string,
621+
tag_name: string,
622+
attributes: Record<string, string>
623+
) {
624+
for (const [schema, ax_object] of elementAXObjects.entries()) {
625+
if (
626+
schema.name === tag_name &&
627+
(!schema.attributes ||
628+
schema.attributes.every((attr: any) => attributes[attr.name] === attr.value))
629+
) {
630+
for (const name of ax_object) {
631+
const axRoles = AXObjectRoles.get(name);
632+
if (axRoles) {
633+
for (const { name: _name } of axRoles) {
634+
if (_name === role) {
635+
return true;
636+
}
637+
}
638+
}
639+
}
640+
}
641+
}
642+
return false;
643+
}

‎packages/astro/src/vite-plugin-dev-overlay/vite-plugin-dev-overlay.ts

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ const resolvedVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
77
export default function astroDevOverlay({ settings }: AstroPluginOptions): vite.Plugin {
88
return {
99
name: 'astro:dev-overlay',
10+
config() {
11+
return {
12+
optimizeDeps: {
13+
// Optimize CJS dependencies used by the dev toolbar
14+
include: ['astro > aria-query', 'astro > axobject-query'],
15+
},
16+
};
17+
},
1018
resolveId(id) {
1119
if (id === VIRTUAL_MODULE_ID) {
1220
return resolvedVirtualModuleId;

‎pnpm-lock.yaml

+19
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.