Skip to content

Commit ad59771

Browse files
authored
Merge pull request #815 from sveltejs/gh-374
a11y checks
2 parents 66c382a + 7c6ea13 commit ad59771

File tree

74 files changed

+686
-232
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

74 files changed

+686
-232
lines changed

src/generators/dom/preprocess.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Block from './Block';
22
import { trimStart, trimEnd } from '../../utils/trim';
33
import { assign } from '../../shared/index.js';
4-
import getStaticAttributeValue from '../shared/getStaticAttributeValue';
4+
import getStaticAttributeValue from '../../utils/getStaticAttributeValue';
55
import { DomGenerator } from './index';
66
import { Node } from '../../interfaces';
77
import { State } from './interfaces';

src/generators/dom/visitors/Element/Attribute.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import deindent from '../../../../utils/deindent';
33
import visitStyleAttribute, { optimizeStyle } from './StyleAttribute';
44
import { stringify } from '../../../../utils/stringify';
55
import getExpressionPrecedence from '../../../../utils/getExpressionPrecedence';
6-
import getStaticAttributeValue from '../../../shared/getStaticAttributeValue';
6+
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
77
import { DomGenerator } from '../../index';
88
import Block from '../../Block';
99
import { Node } from '../../../../interfaces';

src/generators/dom/visitors/Element/Binding.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import deindent from '../../../../utils/deindent';
22
import flattenReference from '../../../../utils/flattenReference';
3-
import getStaticAttributeValue from '../../../shared/getStaticAttributeValue';
3+
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
44
import { DomGenerator } from '../../index';
55
import Block from '../../Block';
66
import { Node } from '../../../../interfaces';

src/generators/dom/visitors/Element/Element.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import visitEventHandler from './EventHandler';
88
import visitBinding from './Binding';
99
import visitRef from './Ref';
1010
import * as namespaces from '../../../../utils/namespaces';
11-
import getStaticAttributeValue from '../../../shared/getStaticAttributeValue';
11+
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
1212
import addTransitions from './addTransitions';
1313
import { DomGenerator } from '../../index';
1414
import Block from '../../Block';
@@ -102,7 +102,7 @@ export default function visitElement(
102102

103103
if (node._cssRefAttribute) {
104104
block.builders.hydrate.addLine(
105-
`@setAttribute(${name}, "svelte-ref-${node._cssRefAttribute}", ");`
105+
`@setAttribute(${name}, "svelte-ref-${node._cssRefAttribute}", "");`
106106
)
107107
}
108108
}

src/generators/dom/visitors/Element/StyleAttribute.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import attributeLookup from './lookup';
22
import deindent from '../../../../utils/deindent';
33
import { stringify } from '../../../../utils/stringify';
44
import getExpressionPrecedence from '../../../../utils/getExpressionPrecedence';
5-
import getStaticAttributeValue from '../../../shared/getStaticAttributeValue';
5+
import getStaticAttributeValue from '../../../../utils/getStaticAttributeValue';
66
import { DomGenerator } from '../../index';
77
import Block from '../../Block';
88
import { Node } from '../../../../interfaces';

src/generators/dom/visitors/Slot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { DomGenerator } from '../index';
22
import deindent from '../../../utils/deindent';
33
import visit from '../visit';
44
import Block from '../Block';
5-
import getStaticAttributeValue from '../../shared/getStaticAttributeValue';
5+
import getStaticAttributeValue from '../../../utils/getStaticAttributeValue';
66
import { Node } from '../../../interfaces';
77
import { State } from '../interfaces';
88

src/generators/shared/getStaticAttributeValue.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/interfaces.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export interface CompileOptions {
5454
cascade?: boolean;
5555
hydratable?: boolean;
5656
legacy?: boolean;
57-
customElement: CustomElementOptions | true;
57+
customElement?: CustomElementOptions | true;
5858

5959
onerror?: (error: Error) => void;
6060
onwarn?: (warning: Warning) => void;

src/utils/getStaticAttributeValue.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Node } from '../interfaces';
2+
3+
export default function getStaticAttributeValue(node: Node, name: string) {
4+
const attribute = node.attributes.find(
5+
(attr: Node) => attr.name.toLowerCase() === name
6+
);
7+
8+
if (!attribute) return null;
9+
10+
if (attribute.value.length === 0) return '';
11+
12+
if (attribute.value.length === 1 && attribute.value[0].type === 'Text') {
13+
return attribute.value[0].data;
14+
}
15+
16+
return null;
17+
}

src/validate/html/a11y.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import * as namespaces from '../../utils/namespaces';
2+
import getStaticAttributeValue from '../../utils/getStaticAttributeValue';
3+
import fuzzymatch from '../utils/fuzzymatch';
4+
import validateEventHandler from './validateEventHandler';
5+
import { Validator } from '../index';
6+
import { Node } from '../../interfaces';
7+
8+
const ariaAttributes = 'activedescendant atomic autocomplete busy checked controls describedby disabled dropeffect expanded flowto grabbed haspopup hidden invalid label labelledby level live multiline multiselectable orientation owns posinset pressed readonly relevant required selected setsize sort valuemax valuemin valuenow valuetext'.split(' ');
9+
const ariaAttributeSet = new Set(ariaAttributes);
10+
11+
const ariaRoles = 'alert alertdialog application article banner button checkbox columnheader combobox command complementary composite contentinfo definition dialog directory document form grid gridcell group heading img input landmark link list listbox listitem log main marquee math menu menubar menuitem menuitemcheckbox menuitemradio navigation note option presentation progressbar radio radiogroup range region roletype row rowgroup rowheader scrollbar search section sectionhead select separator slider spinbutton status structure tab tablist tabpanel textbox timer toolbar tooltip tree treegrid treeitem widget window'.split(' ');
12+
const ariaRoleSet = new Set(ariaRoles);
13+
14+
const invisibleElements = new Set(['meta', 'html', 'script', 'style']);
15+
16+
export default function a11y(
17+
validator: Validator,
18+
node: Node,
19+
elementStack: Node[]
20+
) {
21+
if (node.type === 'Text') {
22+
// accessible-emoji
23+
return;
24+
}
25+
26+
if (node.type !== 'Element') return;
27+
28+
const attributeMap = new Map();
29+
node.attributes.forEach((attribute: Node) => {
30+
const name = attribute.name.toLowerCase();
31+
32+
// aria-props
33+
if (name.startsWith('aria-')) {
34+
if (invisibleElements.has(node.name)) {
35+
// aria-unsupported-elements
36+
validator.warn(`A11y: <${node.name}> should not have aria-* attributes`, attribute.start);
37+
}
38+
39+
const type = name.slice(5);
40+
if (!ariaAttributeSet.has(type)) {
41+
const match = fuzzymatch(type, ariaAttributes);
42+
let message = `A11y: Unknown aria attribute 'aria-${type}'`;
43+
if (match) message += ` (did you mean '${match}'?)`;
44+
45+
validator.warn(message, attribute.start);
46+
}
47+
}
48+
49+
// aria-role
50+
if (name === 'role') {
51+
if (invisibleElements.has(node.name)) {
52+
// aria-unsupported-elements
53+
validator.warn(`A11y: <${node.name}> should not have role attribute`, attribute.start);
54+
}
55+
56+
const value = getStaticAttributeValue(node, 'role');
57+
if (value && !ariaRoleSet.has(value)) {
58+
const match = fuzzymatch(value, ariaRoles);
59+
let message = `A11y: Unknown role '${value}'`;
60+
if (match) message += ` (did you mean '${match}'?)`;
61+
62+
validator.warn(message, attribute.start);
63+
}
64+
}
65+
66+
// no-access-key
67+
if (name === 'accesskey') {
68+
validator.warn(`A11y: Avoid using accesskey`, attribute.start);
69+
}
70+
71+
// no-autofocus
72+
if (name === 'autofocus') {
73+
validator.warn(`A11y: Avoid using autofocus`, attribute.start);
74+
}
75+
76+
// scope
77+
if (name === 'scope' && node.name !== 'th') {
78+
validator.warn(`A11y: The scope attribute should only be used with <th> elements`, attribute.start);
79+
}
80+
81+
// tabindex-no-positive
82+
if (name === 'tabindex') {
83+
const value = getStaticAttributeValue(node, 'tabindex');
84+
if (!isNaN(value) && +value > 0) {
85+
validator.warn(`A11y: avoid tabindex values above zero`, attribute.start);
86+
}
87+
}
88+
89+
attributeMap.set(attribute.name, attribute);
90+
});
91+
92+
function shouldHaveAttribute(attributes: string[], name = node.name) {
93+
if (attributes.length === 0 || !attributes.some((name: string) => attributeMap.has(name))) {
94+
const article = /^[aeiou]/.test(attributes[0]) ? 'an' : 'a';
95+
const sequence = attributes.length > 1 ?
96+
attributes.slice(0, -1).join(', ') + ` or ${attributes[attributes.length - 1]}` :
97+
attributes[0];
98+
99+
validator.warn(`A11y: <${name}> element should have ${article} ${sequence} attribute`, node.start);
100+
}
101+
}
102+
103+
function shouldHaveContent() {
104+
if (node.children.length === 0) {
105+
validator.warn(`A11y: <${node.name}> element should have child content`, node.start);
106+
}
107+
}
108+
109+
if (node.name === 'a') {
110+
// anchor-is-valid
111+
const href = attributeMap.get('href');
112+
if (attributeMap.has('href')) {
113+
const value = getStaticAttributeValue(node, 'href');
114+
if (value === '' || value === '#') {
115+
validator.warn(`A11y: '${value}' is not a valid href attribute`, href.start);
116+
}
117+
} else {
118+
validator.warn(`A11y: <a> element should have an href attribute`, node.start);
119+
}
120+
121+
// anchor-has-content
122+
shouldHaveContent();
123+
}
124+
125+
if (node.name === 'img') shouldHaveAttribute(['alt']);
126+
if (node.name === 'area') shouldHaveAttribute(['alt', 'aria-label', 'aria-labelledby']);
127+
if (node.name === 'object') shouldHaveAttribute(['title', 'aria-label', 'aria-labelledby']);
128+
if (node.name === 'input' && getStaticAttributeValue(node, 'type') === 'image') {
129+
shouldHaveAttribute(['alt', 'aria-label', 'aria-labelledby'], 'input type="image"');
130+
}
131+
132+
// heading-has-content
133+
if (/^h[1-6]$/.test(node.name)) {
134+
shouldHaveContent();
135+
136+
if (attributeMap.has('aria-hidden')) {
137+
validator.warn(`A11y: <${node.name}> element should not be hidden`, attributeMap.get('aria-hidden').start);
138+
}
139+
}
140+
141+
// iframe-has-title
142+
if (node.name === 'iframe') {
143+
shouldHaveAttribute(['title']);
144+
}
145+
146+
// no-distracting-elements
147+
if (node.name === 'marquee' || node.name === 'blink') {
148+
validator.warn(`A11y: Avoid <${node.name}> elements`, node.start);
149+
}
150+
151+
if (node.name === 'figcaption') {
152+
const parent = elementStack[elementStack.length - 1];
153+
if (parent) {
154+
if (parent.name !== 'figure') {
155+
validator.warn(`A11y: <figcaption> must be an immediate child of <figure>`, node.start);
156+
} else {
157+
const index = parent.children.indexOf(node);
158+
if (index !== 0 && index !== parent.children.length - 1) {
159+
validator.warn(`A11y: <figcaption> must be first or last child of <figure>`, node.start);
160+
}
161+
}
162+
}
163+
}
164+
}
165+
166+
function getValue(attribute: Node) {
167+
if (attribute.value.length === 0) return '';
168+
if (attribute.value.length === 1 && attribute.value[0].type === 'Text') return attribute.value[0].data;
169+
170+
return null;
171+
}

src/validate/html/index.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,27 @@
1-
import * as namespaces from '../../utils/namespaces';
21
import validateElement from './validateElement';
32
import validateWindow from './validateWindow';
3+
import a11y from './a11y';
44
import fuzzymatch from '../utils/fuzzymatch'
55
import flattenReference from '../../utils/flattenReference';
66
import { Validator } from '../index';
77
import { Node } from '../../interfaces';
88

9-
const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|switch|symbol|text|textPath|title|tref|tspan|unknown|use|view|vkern)$/;
10-
119
const meta = new Map([[':Window', validateWindow]]);
1210

1311
export default function validateHtml(validator: Validator, html: Node) {
14-
let elementDepth = 0;
15-
1612
const refs = new Map();
1713
const refCallees: Node[] = [];
14+
const elementStack: Node[] = [];
1815

1916
function visit(node: Node) {
20-
if (node.type === 'Element') {
21-
if (
22-
elementDepth === 0 &&
23-
validator.namespace !== namespaces.svg &&
24-
svg.test(node.name)
25-
) {
26-
validator.warn(
27-
`<${node.name}> is an SVG element – did you forget to add { namespace: 'svg' } ?`,
28-
node.start
29-
);
30-
}
17+
a11y(validator, node, elementStack);
3118

19+
if (node.type === 'Element') {
3220
if (meta.has(node.name)) {
3321
return meta.get(node.name)(validator, node, refs, refCallees);
3422
}
3523

36-
elementDepth += 1;
37-
38-
validateElement(validator, node, refs, refCallees);
24+
validateElement(validator, node, refs, refCallees, elementStack);
3925
} else if (node.type === 'EachBlock') {
4026
if (validator.helpers.has(node.context)) {
4127
let c = node.expression.end;
@@ -53,16 +39,14 @@ export default function validateHtml(validator: Validator, html: Node) {
5339
}
5440

5541
if (node.children) {
42+
if (node.type === 'Element') elementStack.push(node);
5643
node.children.forEach(visit);
44+
if (node.type === 'Element') elementStack.pop();
5745
}
5846

5947
if (node.else) {
6048
visit(node.else);
6149
}
62-
63-
if (node.type === 'Element') {
64-
elementDepth -= 1;
65-
}
6650
}
6751

6852
html.children.forEach(visit);

src/validate/html/validateElement.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1+
import * as namespaces from '../../utils/namespaces';
12
import validateEventHandler from './validateEventHandler';
23
import { Validator } from '../index';
34
import { Node } from '../../interfaces';
45

5-
export default function validateElement(validator: Validator, node: Node, refs: Map<string, Node[]>, refCallees: Node[]) {
6+
const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|switch|symbol|text|textPath|title|tref|tspan|unknown|use|view|vkern)$/;
7+
8+
export default function validateElement(
9+
validator: Validator,
10+
node: Node,
11+
refs: Map<string, Node[]>,
12+
refCallees: Node[],
13+
elementStack: Node[]
14+
) {
615
const isComponent =
716
node.name === ':Self' || validator.components.has(node.name);
817

@@ -11,6 +20,13 @@ export default function validateElement(validator: Validator, node: Node, refs:
1120
validator.warn(`${node.name} component is not defined`, node.start);
1221
}
1322

23+
if (elementStack.length === 0 && validator.namespace !== namespaces.svg && svg.test(node.name)) {
24+
validator.warn(
25+
`<${node.name}> is an SVG element – did you forget to add { namespace: 'svg' } ?`,
26+
node.start
27+
);
28+
}
29+
1430
if (node.name === 'slot') {
1531
const nameAttribute = node.attributes.find((attribute: Node) => attribute.name === 'name');
1632
if (nameAttribute) {

src/validate/js/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,12 @@ export default function validateJs(validator: Validator, js: Node) {
5757
const match = fuzzymatch(prop.key.name, validPropList);
5858
if (match) {
5959
validator.error(
60-
`Unexpected property '${prop.key
61-
.name}' (did you mean '${match}'?)`,
60+
`Unexpected property '${prop.key.name}' (did you mean '${match}'?)`,
6261
prop.start
6362
);
6463
} else if (/FunctionExpression/.test(prop.value.type)) {
6564
validator.error(
66-
`Unexpected property '${prop.key
67-
.name}' (did you mean to include it in 'methods'?)`,
65+
`Unexpected property '${prop.key.name}' (did you mean to include it in 'methods'?)`,
6866
prop.start
6967
);
7068
} else {

0 commit comments

Comments
 (0)