Skip to content

Commit 308bad6

Browse files
committed
add a11y configuration for checking through control and input component
1 parent 012d639 commit 308bad6

File tree

10 files changed

+189
-57
lines changed

10 files changed

+189
-57
lines changed

site/content/docs/04-compile-time.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ The following options can be passed to the compiler. None are required:
8484
| `cssOutputFilename` | `null` | A `string` used for your CSS sourcemap.
8585
| `sveltePath` | `"svelte"` | The location of the `svelte` package. Any imports from `svelte` or `svelte/[module]` will be modified accordingly.
8686
| `namespace` | `"html"` | The namespace of the element; e.g., `"mathml"`, `"svg"`, `"foreign"`.
87+
| `a11y` | `null` | Rule configurations for accessibility checks. Check [individual rules](#accessibility-warnings) for more details.
8788

8889
---
8990

site/content/docs/05-accessibility-warnings.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,55 @@ There are two supported ways to associate a label with a control:
128128
<label>A</label>
129129
```
130130

131+
If your label and input are Svelte components, you can configure the rule to be aware of your custom components.
132+
133+
```svelte
134+
<CustomInputLabel label="Surname">
135+
<CustomInput type="text" bind:value />
136+
</CustomInputLabel>
137+
```
138+
139+
And the configuration:
140+
141+
```js
142+
// svelte.config.js
143+
export default {
144+
compilerOptions: {
145+
a11y: {
146+
rules: {
147+
'label-has-associated-control': {
148+
labelComponents: ['CustomInputLabel'],
149+
controlComponents: ['CustomInput'],
150+
}
151+
}
152+
}
153+
},
154+
}
155+
```
156+
157+
**Configuration**
158+
159+
```js
160+
// svelte.config.js
161+
export default {
162+
compilerOptions: {
163+
a11y: {
164+
rules: {
165+
'label-has-associated-control': {
166+
labelComponents: ['CustomInputLabel'],
167+
controlComponents: ['CustomInput'],
168+
depth: 3,
169+
}
170+
}
171+
}
172+
},
173+
}
174+
```
175+
176+
- `labelComponents` is a list of Svelte component names that should be checked for an associated control.
177+
- `controlComponents` is a list of Svelte component names that will output an input element.
178+
- `depth` (default 3, max 25) is an integer that determines how deep within the label element the rule should look for an element to determine if the label element has associated control.
179+
131180
---
132181

133182
### `a11y-media-has-caption`

src/compiler/compile/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ const valid_options = [
3232
'loopGuardTimeout',
3333
'preserveComments',
3434
'preserveWhitespace',
35-
'cssHash'
35+
'cssHash',
36+
'a11y'
3637
];
3738

3839
function validate_options(options: CompileOptions, warnings: Warning[]) {

src/compiler/compile/nodes/Element.ts

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { Literal } from 'estree';
2424
import compiler_warnings from '../compiler_warnings';
2525
import compiler_errors from '../compiler_errors';
2626
import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
27-
import { is_interactive_element, is_non_interactive_roles, is_presentation_role } from '../utils/a11y';
27+
import { is_interactive_element, is_non_interactive_roles, is_presentation_role, may_contain_input_child } from '../utils/a11y';
2828

2929
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|svg|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/;
3030

@@ -66,17 +66,6 @@ const a11y_required_content = new Set([
6666
'h6'
6767
]);
6868

69-
const a11y_labelable = new Set([
70-
'button',
71-
'input',
72-
'keygen',
73-
'meter',
74-
'output',
75-
'progress',
76-
'select',
77-
'textarea'
78-
]);
79-
8069
const a11y_nested_implicit_semantics = new Map([
8170
['header', 'banner'],
8271
['footer', 'contentinfo']
@@ -346,12 +335,12 @@ export default class Element extends Node {
346335
}
347336

348337
case 'Transition':
349-
{
350-
const transition = new Transition(component, this, scope, node);
351-
if (node.intro) this.intro = transition;
352-
if (node.outro) this.outro = transition;
353-
break;
354-
}
338+
{
339+
const transition = new Transition(component, this, scope, node);
340+
if (node.intro) this.intro = transition;
341+
if (node.outro) this.outro = transition;
342+
break;
343+
}
355344

356345
case 'Animation':
357346
this.animation = new Animation(component, this, scope, node);
@@ -486,7 +475,7 @@ export default class Element extends Node {
486475
}
487476

488477
const value = attribute.get_static_value() as ARIARoleDefintionKey;
489-
478+
490479
if (value && aria_role_abstract_set.has(value)) {
491480
component.warn(attribute, compiler_warnings.a11y_no_abstract_role(value));
492481
} else if (value && !aria_role_set.has(value)) {
@@ -626,24 +615,11 @@ export default class Element extends Node {
626615
}
627616

628617
if (this.name === 'label') {
629-
const has_input_child = (children: INode[]) => {
630-
if (children.some(child => (child instanceof Element && (a11y_labelable.has(child.name) || child.name === 'slot')))) {
631-
return true;
632-
}
633-
634-
for (const child of children) {
635-
if (!('children' in child) || child.children.length === 0) {
636-
continue;
637-
}
638-
if (has_input_child(child.children)) {
639-
return true;
640-
}
641-
}
642-
643-
return false;
644-
};
618+
const rule_options = component.compile_options.a11y?.rules?.['label-has-associated-control'];
619+
// magic number inspired from https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/label-has-associated-control.js
620+
const recursion_depth = Math.min(rule_options?.depth ?? 3, 25);
645621

646-
if (!attribute_map.has('for') && !has_input_child(this.children)) {
622+
if (!attribute_map.has('for') && !may_contain_input_child(this, recursion_depth, rule_options?.controlComponents)) {
647623
component.warn(this, compiler_warnings.a11y_label_has_associated_control);
648624
}
649625
}

src/compiler/compile/nodes/InlineComponent.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import TemplateScope from './shared/TemplateScope';
1010
import { INode } from './interfaces';
1111
import { TemplateNode } from '../../interfaces';
1212
import compiler_errors from '../compiler_errors';
13+
import compiler_warnings from '../compiler_warnings';
14+
import { may_contain_input_child } from '../utils/a11y';
1315

1416
export default class InlineComponent extends Node {
1517
type: 'InlineComponent';
@@ -155,11 +157,24 @@ export default class InlineComponent extends Node {
155157
}
156158

157159
this.children = map_children(component, this, this.scope, children);
160+
161+
this.validate();
158162
}
159163

160164
get slot_template_name() {
161165
return this.attributes.find(attribute => attribute.name === 'slot').get_static_value() as string;
162166
}
167+
168+
validate() {
169+
const label_has_associated_control_rule_options = this.component.compile_options.a11y?.rules?.['label-has-associated-control'];
170+
if (label_has_associated_control_rule_options?.labelComponents?.includes(this.name)) {
171+
// magic number inspired from https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/label-has-associated-control.js
172+
const recursion_depth = Math.min(label_has_associated_control_rule_options?.depth ?? 3, 25);
173+
if (!may_contain_input_child(this, recursion_depth, label_has_associated_control_rule_options?.controlComponents)) {
174+
this.component.warn(this, compiler_warnings.a11y_label_has_associated_control);
175+
}
176+
}
177+
}
163178
}
164179

165180
function not_whitespace_text(node) {

src/compiler/compile/utils/a11y.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from 'aria-query';
77
import { AXObjects, elementAXObjects } from 'axobject-query';
88
import Attribute from '../nodes/Attribute';
9+
import { INode } from '../nodes/interfaces';
910

1011
const roles = [...roles_map.keys()];
1112

@@ -99,8 +100,8 @@ function match_schema(
99100
schema_attribute.value &&
100101
schema_attribute.value !== attribute.get_static_value()
101102
) {
102-
return false;
103-
}
103+
return false;
104+
}
104105
return true;
105106
});
106107
}
@@ -135,3 +136,42 @@ export function is_interactive_element(
135136

136137
return false;
137138
}
139+
140+
const a11y_labelable = new Set([
141+
'button',
142+
'input',
143+
'keygen',
144+
'meter',
145+
'output',
146+
'progress',
147+
'select',
148+
'textarea'
149+
]);
150+
151+
export function may_contain_input_child(
152+
root: INode,
153+
max_depth: number = 1,
154+
additional_component_names: string[] | undefined
155+
): boolean {
156+
function traverse_children(
157+
node: INode,
158+
depth: number
159+
): boolean {
160+
// Bail when max_depth is exceeded.
161+
if (depth > max_depth) {
162+
return false;
163+
}
164+
if ('children' in node) {
165+
for (const child of node.children) {
166+
if ('name' in child && (a11y_labelable.has(child.name) || child.name === 'slot' || additional_component_names?.includes(child.name))) {
167+
return true;
168+
}
169+
if (traverse_children(child, depth + 1)) {
170+
return true;
171+
}
172+
}
173+
}
174+
return false;
175+
}
176+
return traverse_children(root, 1);
177+
}

src/compiler/interfaces.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ interface DebugTag extends BaseNode {
4141
}
4242

4343
export type DirectiveType = 'Action'
44-
| 'Animation'
45-
| 'Binding'
46-
| 'Class'
47-
| 'StyleDirective'
48-
| 'EventHandler'
49-
| 'Let'
50-
| 'Ref'
51-
| 'Transition';
44+
| 'Animation'
45+
| 'Binding'
46+
| 'Class'
47+
| 'StyleDirective'
48+
| 'EventHandler'
49+
| 'Let'
50+
| 'Ref'
51+
| 'Transition';
5252

5353
interface BaseDirective extends BaseNode {
5454
type: DirectiveType;
@@ -88,16 +88,16 @@ export interface Transition extends BaseExpressionDirective {
8888
export type Directive = BaseDirective | BaseExpressionDirective | Transition;
8989

9090
export type TemplateNode = Text
91-
| ConstTag
92-
| DebugTag
93-
| MustacheTag
94-
| BaseNode
95-
| Element
96-
| Attribute
97-
| SpreadAttribute
98-
| Directive
99-
| Transition
100-
| Comment;
91+
| ConstTag
92+
| DebugTag
93+
| MustacheTag
94+
| BaseNode
95+
| Element
96+
| Attribute
97+
| SpreadAttribute
98+
| Directive
99+
| Transition
100+
| Comment;
101101

102102
export interface Parser {
103103
readonly template: string;
@@ -186,6 +186,16 @@ export interface CompileOptions {
186186

187187
preserveComments?: boolean;
188188
preserveWhitespace?: boolean;
189+
190+
a11y?: {
191+
rules?: {
192+
'label-has-associated-control'?: {
193+
labelComponents?: string[];
194+
controlComponents?: string[];
195+
depth?: number;
196+
};
197+
};
198+
};
189199
}
190200

191201
export interface ParserOptions {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script>
2+
import MyLabel from "./MyLabel.svelte";
3+
import MyInput from "./MyInput.svelte";
4+
import CustomLabel from "./CustomLabel.svelte";
5+
</script>
6+
7+
<MyLabel>xxx</MyLabel>
8+
<CustomLabel>xxx</CustomLabel>
9+
<MyLabel><input /></MyLabel>
10+
<MyLabel><slot /></MyLabel>
11+
<MyLabel><MyInput /></MyLabel>
12+
<MyLabel><div><MyInput /></div></MyLabel>
13+
<label><div><MyInput /></div></label>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"a11y": {
3+
"rules": {
4+
"label-has-associated-control": {
5+
"labelComponents": ["MyLabel"],
6+
"controlComponents": ["MyInput"]
7+
}
8+
}
9+
}
10+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[
2+
{
3+
"code": "a11y-label-has-associated-control",
4+
"message": "A11y: A form label must be associated with a control.",
5+
"pos": 151,
6+
"start": {
7+
"character": 151,
8+
"column": 0,
9+
"line": 7
10+
},
11+
"end": {
12+
"character": 173,
13+
"column": 22,
14+
"line": 7
15+
}
16+
}
17+
]

0 commit comments

Comments
 (0)