From db072c3adc2a3ea11d589a1ae33fa73e570e1c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20R=C3=BC=C3=9Fler?= Date: Wed, 19 Apr 2023 17:22:36 +0200 Subject: [PATCH] feat: add a11y `autocomplete-valid` Part of #820 --- src/compiler/compile/compiler_warnings.ts | 4 + src/compiler/compile/nodes/Element.ts | 14 +- src/compiler/compile/utils/a11y.ts | 192 ++++++++++++++++++ .../a11y-autocomplete-valid/input.svelte | 22 ++ .../a11y-autocomplete-valid/warnings.json | 98 +++++++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 test/validator/samples/a11y-autocomplete-valid/input.svelte create mode 100644 test/validator/samples/a11y-autocomplete-valid/warnings.json diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index a851bc24c2..2f8ade43e2 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -162,6 +162,10 @@ export default { code: 'a11y-missing-attribute', message: `A11y: <${name}> element should have ${article} ${sequence} attribute` }), + a11y_autocomplete_valid: (type: null | true | string, value: null | true | string) => ({ + code: 'a11y-autocomplete-valid', + message: `A11y: The value '${value}' is not supported by the attribute 'autocomplete' on element ` + }), a11y_img_redundant_alt: { code: 'a11y-img-redundant-alt', message: 'A11y: Screenreaders already announce elements as an image.' diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 2410904d63..3aee4b14c2 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -25,7 +25,7 @@ import { Literal } from 'estree'; import compiler_warnings from '../compiler_warnings'; import compiler_errors from '../compiler_errors'; import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; -import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute } from '../utils/a11y'; +import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute, is_valid_autocomplete } from '../utils/a11y'; const aria_attributes = '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 aria_attribute_set = new Set(aria_attributes); @@ -848,6 +848,18 @@ export default class Element extends Node { should_have_attribute(this, required_attributes, 'input type="image"'); } } + + // autocomplete-valid + const autocomplete = attribute_map.get('autocomplete'); + + if (type && autocomplete) { + const type_value: null | true | string = type.get_static_value(); + const autocomplete_value: null | true | string = autocomplete.get_static_value(); + + if (!is_valid_autocomplete(type_value, autocomplete_value)) { + component.warn(autocomplete, compiler_warnings.a11y_autocomplete_valid(type_value, autocomplete_value)); + } + } } if (this.name === 'img') { diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index 4409f80262..fbdf9e74df 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -6,6 +6,7 @@ import { } from 'aria-query'; import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query'; import Attribute from '../nodes/Attribute'; +import { regex_whitespaces } from '../../utils/patterns'; const aria_roles = roles_map.keys(); const abstract_roles = new Set(aria_roles.filter(role => roles_map.get(role).abstract)); @@ -223,3 +224,194 @@ export function is_semantic_role_element(role: ARIARoleDefinitionKey, tag_name: } return false; } + +// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute +const address_type_tokens = new Set(['shipping', 'billing']); +const autofill_field_name_tokens = new Set([ + 'name', + 'honorific-prefix', + 'given-name', + 'additional-name', + 'family-name', + 'honorific-suffix', + 'nickname', + 'username', + 'new-password', + 'current-password', + 'one-time-code', + 'organization-title', + 'organization', + 'street-address', + 'address-line1', + 'address-line2', + 'address-line3', + 'address-level4', + 'address-level3', + 'address-level2', + 'address-level1', + 'country', + 'country-name', + 'postal-code', + 'cc-name', + 'cc-given-name', + 'cc-additional-name', + 'cc-family-name', + 'cc-number', + 'cc-exp', + 'cc-exp-month', + 'cc-exp-year', + 'cc-csc', + 'cc-type', + 'transaction-currency', + 'transaction-amount', + 'language', + 'bday', + 'bday-day', + 'bday-month', + 'bday-year', + 'sex', + 'url', + 'photo' +]); +const contact_type_tokens = new Set(['home', 'work', 'mobile', 'fax', 'pager']); +const autofill_contact_field_name_tokens = new Set([ + 'tel', + 'tel-country-code', + 'tel-national', + 'tel-area-code', + 'tel-local', + 'tel-local-prefix', + 'tel-local-suffix', + 'tel-extension', + 'email', + 'impp' +]); + +const control_group_text_types = new Set(['hidden', 'text', 'search']); +const control_group_multiline_types = new Set(['hidden']); +const control_group_password_types = new Set(['hidden', 'text', 'search', 'password']); +const control_group_url_types = new Set(['hidden', 'text', 'search', 'url']); +const control_group_username_types = new Set(['hidden', 'text', 'search', 'email']); +const control_group_telephone_types = new Set(['hidden', 'text', 'search', 'tel']); +const control_group_numeric_types = new Set(['hidden', 'text', 'search', 'number']); +const control_group_month_types = new Set(['hidden', 'text', 'search', 'month']); +const control_group_date_types = new Set(['hidden', 'text', 'search', 'date']); + +const appropriate_types_for_field_names = new Map([ + ['name', control_group_text_types], + ['honorific-prefix', control_group_text_types], + ['given-name', control_group_text_types], + ['additional-name', control_group_text_types], + ['family-name', control_group_text_types], + ['honorific-suffix', control_group_text_types], + ['nickname', control_group_text_types], + ['organization-title', control_group_text_types], + ['username', control_group_username_types], + ['new-password', control_group_password_types], + ['current-password', control_group_password_types], + ['one-time-code', control_group_password_types], + ['organization', control_group_text_types], + ['street-address', control_group_multiline_types], + ['address-line1', control_group_text_types], + ['address-line2', control_group_text_types], + ['address-line3', control_group_text_types], + ['address-level4', control_group_text_types], + ['address-level3', control_group_text_types], + ['address-level2', control_group_text_types], + ['address-level1', control_group_text_types], + ['country', control_group_text_types], + ['country-name', control_group_text_types], + ['postal-code', control_group_text_types], + ['cc-name', control_group_text_types], + ['cc-given-name', control_group_text_types], + ['cc-additional-name', control_group_text_types], + ['cc-family-name', control_group_text_types], + ['cc-number', control_group_text_types], + ['cc-exp', control_group_month_types], + ['cc-exp-month', control_group_numeric_types], + ['cc-exp-year', control_group_numeric_types], + ['cc-csc', control_group_text_types], + ['cc-type', control_group_text_types], + ['transaction-currency', control_group_text_types], + ['transaction-amount', control_group_numeric_types], + ['language', control_group_text_types], + ['bday', control_group_date_types], + ['bday-day', control_group_numeric_types], + ['bday-month', control_group_numeric_types], + ['bday-year', control_group_numeric_types], + ['sex', control_group_text_types], + ['url', control_group_url_types], + ['photo', control_group_url_types], + ['tel', control_group_telephone_types], + ['tel-country-code', control_group_text_types], + ['tel-national', control_group_text_types], + ['tel-area-code', control_group_text_types], + ['tel-local', control_group_text_types], + ['tel-local-prefix', control_group_text_types], + ['tel-local-suffix', control_group_text_types], + ['tel-extension', control_group_text_types], + ['email', control_group_username_types], + ['impp', control_group_url_types] +]); + +function is_appropriate_type_for_field_name(type: string, field_name: string) { + if (autofill_field_name_tokens.has(field_name)) { + return appropriate_types_for_field_names.get(field_name)?.has(type); + } + + return false; +} + +function is_appropriate_type_for_contact_field_name(type: string, field_name: string) { + if (autofill_contact_field_name_tokens.has(field_name)) { + return appropriate_types_for_field_names.get(field_name)?.has(type); + } + + return false; +} + +export function is_valid_autocomplete(type: null | true | string, autocomplete: null | true | string) { + if (typeof autocomplete !== 'string' || typeof type !== 'string') { + return false; + } + + const tokens = autocomplete.trim().toLowerCase().split(regex_whitespaces); + const normalized_type = type.toLowerCase(); + + const input_wears_autofill_anchor_mantle = normalized_type === 'hidden'; + const input_wears_autofill_expectation_mantle = !input_wears_autofill_anchor_mantle; + + if (input_wears_autofill_expectation_mantle) { + if (tokens[0] === 'on' || tokens[0] === 'off') { + return tokens.length === 1; + } + } + + if (typeof tokens[0] === 'string' && tokens[0].startsWith('section-')) { + tokens.shift(); + } + + if (address_type_tokens.has(tokens[0])) { + tokens.shift(); + } + + if (is_appropriate_type_for_field_name(normalized_type, tokens[0])) { + tokens.shift(); + } else { + if (contact_type_tokens.has(tokens[0])) { + tokens.shift(); + } + + if (is_appropriate_type_for_contact_field_name(normalized_type, tokens[0])) { + tokens.shift(); + } else { + return false; + } + } + + if (tokens[0] === 'webauthn') { + tokens.shift(); + } + + return tokens.length === 0; +} diff --git a/test/validator/samples/a11y-autocomplete-valid/input.svelte b/test/validator/samples/a11y-autocomplete-valid/input.svelte new file mode 100644 index 0000000000..f6b01b6640 --- /dev/null +++ b/test/validator/samples/a11y-autocomplete-valid/input.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/validator/samples/a11y-autocomplete-valid/warnings.json b/test/validator/samples/a11y-autocomplete-valid/warnings.json new file mode 100644 index 0000000000..8256c57bc7 --- /dev/null +++ b/test/validator/samples/a11y-autocomplete-valid/warnings.json @@ -0,0 +1,98 @@ +[ + { + "code": "a11y-autocomplete-valid", + "end": { + "column": 31, + "line": 14 + }, + "message": "A11y: The value 'true' is not supported by the attribute 'autocomplete' on element ", + "start": { + "column": 19, + "line": 14 + } + }, + { + "code": "a11y-autocomplete-valid", + "end": { + "column": 39, + "line": 15 + }, + "message": "A11y: The value 'off' is not supported by the attribute 'autocomplete' on element ", + "start": { + "column": 21, + "line": 15 + } + }, + { + "code": "a11y-autocomplete-valid", + "message": "A11y: The value 'on' is not supported by the attribute 'autocomplete' on element ", + "end": { + "column": 38, + "line": 16 + }, + "start": { + "column": 21, + "line": 16 + } + }, + { + "code": "a11y-autocomplete-valid", + "message": "A11y: The value '' is not supported by the attribute 'autocomplete' on element ", + "end": { + "column": 34, + "line": 17 + }, + "start": { + "column": 19, + "line": 17 + } + }, + { + "code": "a11y-autocomplete-valid", + "end": { + "column": 43, + "line": 18 + }, + "message": "A11y: The value 'incorrect' is not supported by the attribute 'autocomplete' on element ", + "start": { + "column": 19, + "line": 18 + } + }, + { + "code": "a11y-autocomplete-valid", + "end": { + "column": 38, + "line": 19 + }, + "message": "A11y: The value 'url' is not supported by the attribute 'autocomplete' on element ", + "start": { + "column": 20, + "line": 19 + } + }, + { + "code": "a11y-autocomplete-valid", + "end": { + "column": 70, + "line": 21 + }, + "message": "A11y: The value 'section-blue shipping street-address' is not supported by the attribute 'autocomplete' on element ", + "start": { + "column": 19, + "line": 21 + } + }, + { + "code": "a11y-autocomplete-valid", + "end": { + "column": 42, + "line": 22 + }, + "message": "A11y: The value 'webauthn' is not supported by the attribute 'autocomplete' on element ", + "start": { + "column": 19, + "line": 22 + } + } +]