Skip to content

Commit 5b1c866

Browse files
committed
add a11y configuration for checking through control and input component
1 parent dc7fd76 commit 5b1c866

File tree

10 files changed

+194
-55
lines changed

10 files changed

+194
-55
lines changed

site/content/docs/05-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/06-accessibility-warnings.md

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

156+
If your label and input are Svelte components, you can configure the rule to be aware of your custom components.
157+
158+
```svelte
159+
<CustomInputLabel label="Surname">
160+
<CustomInput type="text" bind:value />
161+
</CustomInputLabel>
162+
```
163+
164+
And the configuration:
165+
166+
```js
167+
// svelte.config.js
168+
export default {
169+
compilerOptions: {
170+
a11y: {
171+
rules: {
172+
'label-has-associated-control': {
173+
labelComponents: ['CustomInputLabel'],
174+
controlComponents: ['CustomInput'],
175+
}
176+
}
177+
}
178+
},
179+
}
180+
```
181+
182+
**Configuration**
183+
184+
```js
185+
// svelte.config.js
186+
export default {
187+
compilerOptions: {
188+
a11y: {
189+
rules: {
190+
'label-has-associated-control': {
191+
labelComponents: ['CustomInputLabel'],
192+
controlComponents: ['CustomInput'],
193+
depth: 3,
194+
}
195+
}
196+
}
197+
},
198+
}
199+
```
200+
201+
- `labelComponents` is a list of Svelte component names that should be checked for an associated control.
202+
- `controlComponents` is a list of Svelte component names that will output an input element.
203+
- `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.
204+
156205
---
157206

158207
### `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
const valid_css_values = [

src/compiler/compile/nodes/Element.ts

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@ 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, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element } from '../utils/a11y';
27+
import {
28+
is_interactive_element,
29+
is_non_interactive_roles,
30+
is_presentation_role,
31+
is_interactive_roles,
32+
is_hidden_from_screen_reader,
33+
is_semantic_role_element,
34+
may_contain_input_child,
35+
} from "../utils/a11y";
2836

2937
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(' ');
3038
const aria_attribute_set = new Set(aria_attributes);
@@ -64,17 +72,6 @@ const a11y_required_content = new Set([
6472
'h6'
6573
]);
6674

67-
const a11y_labelable = new Set([
68-
'button',
69-
'input',
70-
'keygen',
71-
'meter',
72-
'output',
73-
'progress',
74-
'select',
75-
'textarea'
76-
]);
77-
7875
const a11y_nested_implicit_semantics = new Map([
7976
['header', 'banner'],
8077
['footer', 'contentinfo']
@@ -413,12 +410,12 @@ export default class Element extends Node {
413410
}
414411

415412
case 'Transition':
416-
{
417-
const transition = new Transition(component, this, scope, node);
418-
if (node.intro) this.intro = transition;
419-
if (node.outro) this.outro = transition;
420-
break;
421-
}
413+
{
414+
const transition = new Transition(component, this, scope, node);
415+
if (node.intro) this.intro = transition;
416+
if (node.outro) this.outro = transition;
417+
break;
418+
}
422419

423420
case 'Animation':
424421
this.animation = new Animation(component, this, scope, node);
@@ -790,24 +787,8 @@ export default class Element extends Node {
790787
}
791788

792789
if (this.name === 'label') {
793-
const has_input_child = (children: INode[]) => {
794-
if (children.some(child => (child instanceof Element && (a11y_labelable.has(child.name) || child.name === 'slot')))) {
795-
return true;
796-
}
797-
798-
for (const child of children) {
799-
if (!('children' in child) || child.children.length === 0) {
800-
continue;
801-
}
802-
if (has_input_child(child.children)) {
803-
return true;
804-
}
805-
}
806-
807-
return false;
808-
};
809-
810-
if (!attribute_map.has('for') && !has_input_child(this.children)) {
790+
const rule_options = component.compile_options.a11y?.rules?.['label-has-associated-control'];
791+
if (!attribute_map.has('for') && !may_contain_input_child(this, rule_options)) {
811792
component.warn(this, compiler_warnings.a11y_label_has_associated_control);
812793
}
813794
}

src/compiler/compile/nodes/InlineComponent.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ 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';
1314
import { regex_only_whitespaces } from '../../utils/patterns';
15+
import { may_contain_input_child } from '../utils/a11y';
1416

1517
export default class InlineComponent extends Node {
1618
type: 'InlineComponent';
@@ -160,11 +162,22 @@ export default class InlineComponent extends Node {
160162
}
161163

162164
this.children = map_children(component, this, this.scope, children);
165+
166+
this.validate();
163167
}
164168

165169
get slot_template_name() {
166170
return this.attributes.find(attribute => attribute.name === 'slot').get_static_value() as string;
167171
}
172+
173+
validate() {
174+
const label_has_associated_control_rule_options = this.component.compile_options.a11y?.rules?.['label-has-associated-control'];
175+
if (label_has_associated_control_rule_options?.labelComponents?.includes(this.name)) {
176+
if (!may_contain_input_child(this, label_has_associated_control_rule_options)) {
177+
this.component.warn(this, compiler_warnings.a11y_label_has_associated_control);
178+
}
179+
}
180+
}
168181
}
169182

170183
function not_whitespace_text(node) {

src/compiler/compile/utils/a11y.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
ARIARoleRelationConcept
66
} from 'aria-query';
77
import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query';
8+
import { CompileOptions } from '../../interfaces';
89
import Attribute from '../nodes/Attribute';
10+
import { INode } from '../nodes/interfaces';
911

1012
const non_abstract_roles = [...roles_map.keys()].filter((name) => !roles_map.get(name).abstract);
1113

@@ -160,3 +162,45 @@ export function is_semantic_role_element(role: ARIARoleDefintionKey, tag_name: s
160162
}
161163
return false;
162164
}
165+
166+
const a11y_labelable = new Set([
167+
'button',
168+
'input',
169+
'keygen',
170+
'meter',
171+
'output',
172+
'progress',
173+
'select',
174+
'textarea'
175+
]);
176+
177+
export function may_contain_input_child(
178+
root: INode,
179+
rule_options: CompileOptions['a11y']['rules']['label-has-associated-control']
180+
): boolean {
181+
// magic number inspired from https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/label-has-associated-control.js
182+
const max_depth = Math.min(rule_options?.depth ?? 3, 25);
183+
const additional_component_names = rule_options?.controlComponents;
184+
185+
function traverse_children(
186+
node: INode,
187+
depth: number
188+
): boolean {
189+
// Bail when max_depth is exceeded.
190+
if (depth > max_depth) {
191+
return false;
192+
}
193+
if ('children' in node) {
194+
for (const child of node.children) {
195+
if ('name' in child && (a11y_labelable.has(child.name) || child.name === 'slot' || additional_component_names?.includes(child.name))) {
196+
return true;
197+
}
198+
if (traverse_children(child, depth + 1)) {
199+
return true;
200+
}
201+
}
202+
}
203+
return false;
204+
}
205+
return traverse_children(root, 1);
206+
}

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)