From b56dfe51a8a9e753a456ba152e9f71c8d4eb5bed Mon Sep 17 00:00:00 2001 From: Nguyen Tran <88808276+ngtr6788@users.noreply.github.com> Date: Mon, 27 Feb 2023 06:28:59 -0500 Subject: [PATCH] feat: add a11y `role-supports-aria-props` (#8195) #820 --------- Co-authored-by: Simon Holthausen --- .../content/docs/06-accessibility-warnings.md | 14 + src/compiler/compile/compiler_warnings.ts | 11 + src/compiler/compile/nodes/Element.ts | 84 +- .../input.svelte | 1 - .../input.svelte | 371 +++ .../warnings.json | 2150 +++++++++++++++++ 6 files changed, 2628 insertions(+), 3 deletions(-) create mode 100644 test/validator/samples/a11y-role-supports-aria-props/input.svelte create mode 100644 test/validator/samples/a11y-role-supports-aria-props/warnings.json diff --git a/site/content/docs/06-accessibility-warnings.md b/site/content/docs/06-accessibility-warnings.md index 7aa3873cce..ee52aab405 100644 --- a/site/content/docs/06-accessibility-warnings.md +++ b/site/content/docs/06-accessibility-warnings.md @@ -308,6 +308,20 @@ Elements with ARIA roles must have all required attributes for that role. --- +### `a11y-role-supports-aria-props` + +Elements with explicit or implicit roles defined contain only `aria-*` properties supported by that role. + +```sv + +
+ + +
  • +``` + +--- + ### `a11y-structure` Enforce that certain DOM elements have the correct structure. diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index a10fe6155c..817aabea02 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -123,6 +123,17 @@ export default { code: 'a11y-role-has-required-aria-props', message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}` }), + a11y_role_supports_aria_props: (attribute: string, role: string, is_implicit: boolean, name: string) => { + let message = `The attribute '${attribute}' is not supported by the role '${role}'.`; + if (is_implicit) { + message += ` This role is implicit on the element <${name}>.`; + } + + return { + code: 'a11y-role-supports-aria-props', + message: `A11y: ${message}` + }; + }, a11y_accesskey: { code: 'a11y-accesskey', message: 'A11y: Avoid using accesskey' diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 1ac75faffd..ed1b9d64bf 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -82,11 +82,15 @@ const a11y_nested_implicit_semantics = new Map([ 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'], @@ -98,10 +102,14 @@ const a11y_implicit_semantics = new Map([ ['h5', 'heading'], ['h6', 'heading'], ['hr', 'separator'], + ['img', 'img'], ['li', 'listitem'], + ['link', 'link'], ['menu', 'list'], + ['meter', 'progressbar'], ['nav', 'navigation'], ['ol', 'list'], + ['option', 'option'], ['optgroup', 'group'], ['output', 'status'], ['progress', 'progressbar'], @@ -115,6 +123,61 @@ const a11y_implicit_semantics = new Map([ ['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 combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']); + +function input_implicit_role(attribute_map: Map) { + const type_attribute = attribute_map.get('type'); + if (!type_attribute || !type_attribute.is_static) return; + const type = type_attribute.get_static_value() as string; + + const list_attribute_exists = attribute_map.has('list'); + + if (list_attribute_exists && combobox_if_list.has(type)) { + return 'combobox'; + } + + return input_type_to_implicit_role.get(type); +} + +function menuitem_implicit_role(attribute_map: Map) { + const type_attribute = attribute_map.get('type'); + if (!type_attribute || !type_attribute.is_static) return; + const type = type_attribute.get_static_value() as string; + return menuitem_type_to_implicit_role.get(type); +} + +function get_implicit_role(name: string, attribute_map: Map) : (string | undefined) { + if (name === 'menuitem') { + return menuitem_implicit_role(attribute_map); + } else if (name === 'input') { + return input_implicit_role(attribute_map); + } else { + return a11y_implicit_semantics.get(name); + } +} + const invisible_elements = new Set(['meta', 'html', 'script', 'style']); const valid_modifiers = new Set([ @@ -488,7 +551,7 @@ export default class Element extends Node { // aria-activedescendant-has-tabindex if (name === 'aria-activedescendant' && !is_interactive_element(this.name, attribute_map) && !attribute_map.has('tabindex')) { - component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex); + component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex); } } @@ -511,7 +574,7 @@ export default class Element extends Node { } // no-redundant-roles - const has_redundant_role = current_role === a11y_implicit_semantics.get(this.name); + const has_redundant_role = current_role === get_implicit_role(this.name, attribute_map); if (this.name === current_role || has_redundant_role) { component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role)); @@ -605,6 +668,23 @@ export default class Element extends Node { component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex); } } + + // role-supports-aria-props + const role = attribute_map.get('role'); + const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefintionKey; + if (typeof role_value === 'string' && roles.has(role_value)) { + const { props } = roles.get(role_value); + const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props))); + const is_implicit = role_value && role === undefined; + + attributes + .filter(prop => prop.type !== 'Spread') + .forEach(prop => { + if (invalid_aria_props.has(prop.name as ARIAProperty)) { + component.warn(prop, compiler_warnings.a11y_role_supports_aria_props(prop.name, role_value, is_implicit, this.name)); + } + }); + } } validate_special_cases() { diff --git a/test/validator/samples/a11y-role-has-required-aria-props/input.svelte b/test/validator/samples/a11y-role-has-required-aria-props/input.svelte index 113ed65316..8f72ff04bf 100644 --- a/test/validator/samples/a11y-role-has-required-aria-props/input.svelte +++ b/test/validator/samples/a11y-role-has-required-aria-props/input.svelte @@ -8,4 +8,3 @@
    - \ No newline at end of file diff --git a/test/validator/samples/a11y-role-supports-aria-props/input.svelte b/test/validator/samples/a11y-role-supports-aria-props/input.svelte new file mode 100644 index 0000000000..353069dc06 --- /dev/null +++ b/test/validator/samples/a11y-role-supports-aria-props/input.svelte @@ -0,0 +1,371 @@ + +Link + +
    +