Skip to content

Commit db072c3

Browse files
committed
feat: add a11y autocomplete-valid
Part of sveltejs#820
1 parent 6ba2f72 commit db072c3

File tree

5 files changed

+329
-1
lines changed

5 files changed

+329
-1
lines changed

src/compiler/compile/compiler_warnings.ts

+4
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ export default {
162162
code: 'a11y-missing-attribute',
163163
message: `A11y: <${name}> element should have ${article} ${sequence} attribute`
164164
}),
165+
a11y_autocomplete_valid: (type: null | true | string, value: null | true | string) => ({
166+
code: 'a11y-autocomplete-valid',
167+
message: `A11y: The value '${value}' is not supported by the attribute 'autocomplete' on element <input type="${type}">`
168+
}),
165169
a11y_img_redundant_alt: {
166170
code: 'a11y-img-redundant-alt',
167171
message: 'A11y: Screenreaders already announce <img> elements as an image.'

src/compiler/compile/nodes/Element.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { Literal } from 'estree';
2525
import compiler_warnings from '../compiler_warnings';
2626
import compiler_errors from '../compiler_errors';
2727
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
28-
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';
28+
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';
2929

3030
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(' ');
3131
const aria_attribute_set = new Set(aria_attributes);
@@ -848,6 +848,18 @@ export default class Element extends Node {
848848
should_have_attribute(this, required_attributes, 'input type="image"');
849849
}
850850
}
851+
852+
// autocomplete-valid
853+
const autocomplete = attribute_map.get('autocomplete');
854+
855+
if (type && autocomplete) {
856+
const type_value: null | true | string = type.get_static_value();
857+
const autocomplete_value: null | true | string = autocomplete.get_static_value();
858+
859+
if (!is_valid_autocomplete(type_value, autocomplete_value)) {
860+
component.warn(autocomplete, compiler_warnings.a11y_autocomplete_valid(type_value, autocomplete_value));
861+
}
862+
}
851863
}
852864

853865
if (this.name === 'img') {

src/compiler/compile/utils/a11y.ts

+192
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from 'aria-query';
77
import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query';
88
import Attribute from '../nodes/Attribute';
9+
import { regex_whitespaces } from '../../utils/patterns';
910

1011
const aria_roles = roles_map.keys();
1112
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:
223224
}
224225
return false;
225226
}
227+
228+
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
229+
const address_type_tokens = new Set(['shipping', 'billing']);
230+
const autofill_field_name_tokens = new Set([
231+
'name',
232+
'honorific-prefix',
233+
'given-name',
234+
'additional-name',
235+
'family-name',
236+
'honorific-suffix',
237+
'nickname',
238+
'username',
239+
'new-password',
240+
'current-password',
241+
'one-time-code',
242+
'organization-title',
243+
'organization',
244+
'street-address',
245+
'address-line1',
246+
'address-line2',
247+
'address-line3',
248+
'address-level4',
249+
'address-level3',
250+
'address-level2',
251+
'address-level1',
252+
'country',
253+
'country-name',
254+
'postal-code',
255+
'cc-name',
256+
'cc-given-name',
257+
'cc-additional-name',
258+
'cc-family-name',
259+
'cc-number',
260+
'cc-exp',
261+
'cc-exp-month',
262+
'cc-exp-year',
263+
'cc-csc',
264+
'cc-type',
265+
'transaction-currency',
266+
'transaction-amount',
267+
'language',
268+
'bday',
269+
'bday-day',
270+
'bday-month',
271+
'bday-year',
272+
'sex',
273+
'url',
274+
'photo'
275+
]);
276+
const contact_type_tokens = new Set(['home', 'work', 'mobile', 'fax', 'pager']);
277+
const autofill_contact_field_name_tokens = new Set([
278+
'tel',
279+
'tel-country-code',
280+
'tel-national',
281+
'tel-area-code',
282+
'tel-local',
283+
'tel-local-prefix',
284+
'tel-local-suffix',
285+
'tel-extension',
286+
'email',
287+
'impp'
288+
]);
289+
290+
const control_group_text_types = new Set(['hidden', 'text', 'search']);
291+
const control_group_multiline_types = new Set(['hidden']);
292+
const control_group_password_types = new Set(['hidden', 'text', 'search', 'password']);
293+
const control_group_url_types = new Set(['hidden', 'text', 'search', 'url']);
294+
const control_group_username_types = new Set(['hidden', 'text', 'search', 'email']);
295+
const control_group_telephone_types = new Set(['hidden', 'text', 'search', 'tel']);
296+
const control_group_numeric_types = new Set(['hidden', 'text', 'search', 'number']);
297+
const control_group_month_types = new Set(['hidden', 'text', 'search', 'month']);
298+
const control_group_date_types = new Set(['hidden', 'text', 'search', 'date']);
299+
300+
const appropriate_types_for_field_names = new Map([
301+
['name', control_group_text_types],
302+
['honorific-prefix', control_group_text_types],
303+
['given-name', control_group_text_types],
304+
['additional-name', control_group_text_types],
305+
['family-name', control_group_text_types],
306+
['honorific-suffix', control_group_text_types],
307+
['nickname', control_group_text_types],
308+
['organization-title', control_group_text_types],
309+
['username', control_group_username_types],
310+
['new-password', control_group_password_types],
311+
['current-password', control_group_password_types],
312+
['one-time-code', control_group_password_types],
313+
['organization', control_group_text_types],
314+
['street-address', control_group_multiline_types],
315+
['address-line1', control_group_text_types],
316+
['address-line2', control_group_text_types],
317+
['address-line3', control_group_text_types],
318+
['address-level4', control_group_text_types],
319+
['address-level3', control_group_text_types],
320+
['address-level2', control_group_text_types],
321+
['address-level1', control_group_text_types],
322+
['country', control_group_text_types],
323+
['country-name', control_group_text_types],
324+
['postal-code', control_group_text_types],
325+
['cc-name', control_group_text_types],
326+
['cc-given-name', control_group_text_types],
327+
['cc-additional-name', control_group_text_types],
328+
['cc-family-name', control_group_text_types],
329+
['cc-number', control_group_text_types],
330+
['cc-exp', control_group_month_types],
331+
['cc-exp-month', control_group_numeric_types],
332+
['cc-exp-year', control_group_numeric_types],
333+
['cc-csc', control_group_text_types],
334+
['cc-type', control_group_text_types],
335+
['transaction-currency', control_group_text_types],
336+
['transaction-amount', control_group_numeric_types],
337+
['language', control_group_text_types],
338+
['bday', control_group_date_types],
339+
['bday-day', control_group_numeric_types],
340+
['bday-month', control_group_numeric_types],
341+
['bday-year', control_group_numeric_types],
342+
['sex', control_group_text_types],
343+
['url', control_group_url_types],
344+
['photo', control_group_url_types],
345+
['tel', control_group_telephone_types],
346+
['tel-country-code', control_group_text_types],
347+
['tel-national', control_group_text_types],
348+
['tel-area-code', control_group_text_types],
349+
['tel-local', control_group_text_types],
350+
['tel-local-prefix', control_group_text_types],
351+
['tel-local-suffix', control_group_text_types],
352+
['tel-extension', control_group_text_types],
353+
['email', control_group_username_types],
354+
['impp', control_group_url_types]
355+
]);
356+
357+
function is_appropriate_type_for_field_name(type: string, field_name: string) {
358+
if (autofill_field_name_tokens.has(field_name)) {
359+
return appropriate_types_for_field_names.get(field_name)?.has(type);
360+
}
361+
362+
return false;
363+
}
364+
365+
function is_appropriate_type_for_contact_field_name(type: string, field_name: string) {
366+
if (autofill_contact_field_name_tokens.has(field_name)) {
367+
return appropriate_types_for_field_names.get(field_name)?.has(type);
368+
}
369+
370+
return false;
371+
}
372+
373+
export function is_valid_autocomplete(type: null | true | string, autocomplete: null | true | string) {
374+
if (typeof autocomplete !== 'string' || typeof type !== 'string') {
375+
return false;
376+
}
377+
378+
const tokens = autocomplete.trim().toLowerCase().split(regex_whitespaces);
379+
const normalized_type = type.toLowerCase();
380+
381+
const input_wears_autofill_anchor_mantle = normalized_type === 'hidden';
382+
const input_wears_autofill_expectation_mantle = !input_wears_autofill_anchor_mantle;
383+
384+
if (input_wears_autofill_expectation_mantle) {
385+
if (tokens[0] === 'on' || tokens[0] === 'off') {
386+
return tokens.length === 1;
387+
}
388+
}
389+
390+
if (typeof tokens[0] === 'string' && tokens[0].startsWith('section-')) {
391+
tokens.shift();
392+
}
393+
394+
if (address_type_tokens.has(tokens[0])) {
395+
tokens.shift();
396+
}
397+
398+
if (is_appropriate_type_for_field_name(normalized_type, tokens[0])) {
399+
tokens.shift();
400+
} else {
401+
if (contact_type_tokens.has(tokens[0])) {
402+
tokens.shift();
403+
}
404+
405+
if (is_appropriate_type_for_contact_field_name(normalized_type, tokens[0])) {
406+
tokens.shift();
407+
} else {
408+
return false;
409+
}
410+
}
411+
412+
if (tokens[0] === 'webauthn') {
413+
tokens.shift();
414+
}
415+
416+
return tokens.length === 0;
417+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!-- VALID -->
2+
<input type="text" />
3+
<input type="text" autocomplete="name" />
4+
<input type="text" autocomplete="off" />
5+
<input type="text" autocomplete="on" />
6+
<input type="text" autocomplete="billing family-name" />
7+
<input type="hidden" autocomplete="section-blue shipping street-address" />
8+
<input type="text" autocomplete="section-somewhere shipping work email" />
9+
<input type="text" autocomplete="section-somewhere shipping work email webauthn" />
10+
<input type="text" autocomplete="SECTION-SOMEWHERE SHIPPING WORK EMAIL WEBAUTHN" />
11+
<input type="TEXT" autocomplete="ON" />
12+
13+
<!-- INVALID -->
14+
<input type="text" autocomplete />
15+
<input type="hidden" autocomplete="off" />
16+
<input type="hidden" autocomplete="on" />
17+
<input type="text" autocomplete="" />
18+
<input type="text" autocomplete="incorrect" />
19+
<input type="email" autocomplete="url" />
20+
<!-- `street-address` is only valid for Control Group "Multiline" -->
21+
<input type="text" autocomplete="section-blue shipping street-address" />
22+
<input type="text" autocomplete="webauthn" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
[
2+
{
3+
"code": "a11y-autocomplete-valid",
4+
"end": {
5+
"column": 31,
6+
"line": 14
7+
},
8+
"message": "A11y: The value 'true' is not supported by the attribute 'autocomplete' on element <input type=\"text\">",
9+
"start": {
10+
"column": 19,
11+
"line": 14
12+
}
13+
},
14+
{
15+
"code": "a11y-autocomplete-valid",
16+
"end": {
17+
"column": 39,
18+
"line": 15
19+
},
20+
"message": "A11y: The value 'off' is not supported by the attribute 'autocomplete' on element <input type=\"hidden\">",
21+
"start": {
22+
"column": 21,
23+
"line": 15
24+
}
25+
},
26+
{
27+
"code": "a11y-autocomplete-valid",
28+
"message": "A11y: The value 'on' is not supported by the attribute 'autocomplete' on element <input type=\"hidden\">",
29+
"end": {
30+
"column": 38,
31+
"line": 16
32+
},
33+
"start": {
34+
"column": 21,
35+
"line": 16
36+
}
37+
},
38+
{
39+
"code": "a11y-autocomplete-valid",
40+
"message": "A11y: The value '' is not supported by the attribute 'autocomplete' on element <input type=\"text\">",
41+
"end": {
42+
"column": 34,
43+
"line": 17
44+
},
45+
"start": {
46+
"column": 19,
47+
"line": 17
48+
}
49+
},
50+
{
51+
"code": "a11y-autocomplete-valid",
52+
"end": {
53+
"column": 43,
54+
"line": 18
55+
},
56+
"message": "A11y: The value 'incorrect' is not supported by the attribute 'autocomplete' on element <input type=\"text\">",
57+
"start": {
58+
"column": 19,
59+
"line": 18
60+
}
61+
},
62+
{
63+
"code": "a11y-autocomplete-valid",
64+
"end": {
65+
"column": 38,
66+
"line": 19
67+
},
68+
"message": "A11y: The value 'url' is not supported by the attribute 'autocomplete' on element <input type=\"email\">",
69+
"start": {
70+
"column": 20,
71+
"line": 19
72+
}
73+
},
74+
{
75+
"code": "a11y-autocomplete-valid",
76+
"end": {
77+
"column": 70,
78+
"line": 21
79+
},
80+
"message": "A11y: The value 'section-blue shipping street-address' is not supported by the attribute 'autocomplete' on element <input type=\"text\">",
81+
"start": {
82+
"column": 19,
83+
"line": 21
84+
}
85+
},
86+
{
87+
"code": "a11y-autocomplete-valid",
88+
"end": {
89+
"column": 42,
90+
"line": 22
91+
},
92+
"message": "A11y: The value 'webauthn' is not supported by the attribute 'autocomplete' on element <input type=\"text\">",
93+
"start": {
94+
"column": 19,
95+
"line": 22
96+
}
97+
}
98+
]

0 commit comments

Comments
 (0)