add a11y configuration for checking through control and input component

pull/7774/head
tanhauhau 4 years ago
parent dc7fd76e53
commit 5b1c866270

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

@ -153,6 +153,55 @@ There are two supported ways to associate a label with a control:
<label>A</label> <label>A</label>
``` ```
If your label and input are Svelte components, you can configure the rule to be aware of your custom components.
```svelte
<CustomInputLabel label="Surname">
<CustomInput type="text" bind:value />
</CustomInputLabel>
```
And the configuration:
```js
// svelte.config.js
export default {
compilerOptions: {
a11y: {
rules: {
'label-has-associated-control': {
labelComponents: ['CustomInputLabel'],
controlComponents: ['CustomInput'],
}
}
}
},
}
```
**Configuration**
```js
// svelte.config.js
export default {
compilerOptions: {
a11y: {
rules: {
'label-has-associated-control': {
labelComponents: ['CustomInputLabel'],
controlComponents: ['CustomInput'],
depth: 3,
}
}
}
},
}
```
- `labelComponents` is a list of Svelte component names that should be checked for an associated control.
- `controlComponents` is a list of Svelte component names that will output an input element.
- `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.
--- ---
### `a11y-media-has-caption` ### `a11y-media-has-caption`

@ -32,7 +32,8 @@ const valid_options = [
'loopGuardTimeout', 'loopGuardTimeout',
'preserveComments', 'preserveComments',
'preserveWhitespace', 'preserveWhitespace',
'cssHash' 'cssHash',
'a11y'
]; ];
const valid_css_values = [ const valid_css_values = [

@ -24,7 +24,15 @@ import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings'; import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors'; import compiler_errors from '../compiler_errors';
import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
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'; import {
is_interactive_element,
is_non_interactive_roles,
is_presentation_role,
is_interactive_roles,
is_hidden_from_screen_reader,
is_semantic_role_element,
may_contain_input_child,
} 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_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); const aria_attribute_set = new Set(aria_attributes);
@ -64,17 +72,6 @@ const a11y_required_content = new Set([
'h6' 'h6'
]); ]);
const a11y_labelable = new Set([
'button',
'input',
'keygen',
'meter',
'output',
'progress',
'select',
'textarea'
]);
const a11y_nested_implicit_semantics = new Map([ const a11y_nested_implicit_semantics = new Map([
['header', 'banner'], ['header', 'banner'],
['footer', 'contentinfo'] ['footer', 'contentinfo']
@ -790,24 +787,8 @@ export default class Element extends Node {
} }
if (this.name === 'label') { if (this.name === 'label') {
const has_input_child = (children: INode[]) => { const rule_options = component.compile_options.a11y?.rules?.['label-has-associated-control'];
if (children.some(child => (child instanceof Element && (a11y_labelable.has(child.name) || child.name === 'slot')))) { if (!attribute_map.has('for') && !may_contain_input_child(this, rule_options)) {
return true;
}
for (const child of children) {
if (!('children' in child) || child.children.length === 0) {
continue;
}
if (has_input_child(child.children)) {
return true;
}
}
return false;
};
if (!attribute_map.has('for') && !has_input_child(this.children)) {
component.warn(this, compiler_warnings.a11y_label_has_associated_control); component.warn(this, compiler_warnings.a11y_label_has_associated_control);
} }
} }

@ -10,7 +10,9 @@ import TemplateScope from './shared/TemplateScope';
import { INode } from './interfaces'; import { INode } from './interfaces';
import { TemplateNode } from '../../interfaces'; import { TemplateNode } from '../../interfaces';
import compiler_errors from '../compiler_errors'; import compiler_errors from '../compiler_errors';
import compiler_warnings from '../compiler_warnings';
import { regex_only_whitespaces } from '../../utils/patterns'; import { regex_only_whitespaces } from '../../utils/patterns';
import { may_contain_input_child } from '../utils/a11y';
export default class InlineComponent extends Node { export default class InlineComponent extends Node {
type: 'InlineComponent'; type: 'InlineComponent';
@ -160,11 +162,22 @@ export default class InlineComponent extends Node {
} }
this.children = map_children(component, this, this.scope, children); this.children = map_children(component, this, this.scope, children);
this.validate();
} }
get slot_template_name() { get slot_template_name() {
return this.attributes.find(attribute => attribute.name === 'slot').get_static_value() as string; return this.attributes.find(attribute => attribute.name === 'slot').get_static_value() as string;
} }
validate() {
const label_has_associated_control_rule_options = this.component.compile_options.a11y?.rules?.['label-has-associated-control'];
if (label_has_associated_control_rule_options?.labelComponents?.includes(this.name)) {
if (!may_contain_input_child(this, label_has_associated_control_rule_options)) {
this.component.warn(this, compiler_warnings.a11y_label_has_associated_control);
}
}
}
} }
function not_whitespace_text(node) { function not_whitespace_text(node) {

@ -5,7 +5,9 @@ import {
ARIARoleRelationConcept ARIARoleRelationConcept
} from 'aria-query'; } from 'aria-query';
import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query'; import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query';
import { CompileOptions } from '../../interfaces';
import Attribute from '../nodes/Attribute'; import Attribute from '../nodes/Attribute';
import { INode } from '../nodes/interfaces';
const non_abstract_roles = [...roles_map.keys()].filter((name) => !roles_map.get(name).abstract); const non_abstract_roles = [...roles_map.keys()].filter((name) => !roles_map.get(name).abstract);
@ -160,3 +162,45 @@ export function is_semantic_role_element(role: ARIARoleDefintionKey, tag_name: s
} }
return false; return false;
} }
const a11y_labelable = new Set([
'button',
'input',
'keygen',
'meter',
'output',
'progress',
'select',
'textarea'
]);
export function may_contain_input_child(
root: INode,
rule_options: CompileOptions['a11y']['rules']['label-has-associated-control']
): boolean {
// magic number inspired from https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/rules/label-has-associated-control.js
const max_depth = Math.min(rule_options?.depth ?? 3, 25);
const additional_component_names = rule_options?.controlComponents;
function traverse_children(
node: INode,
depth: number
): boolean {
// Bail when max_depth is exceeded.
if (depth > max_depth) {
return false;
}
if ('children' in node) {
for (const child of node.children) {
if ('name' in child && (a11y_labelable.has(child.name) || child.name === 'slot' || additional_component_names?.includes(child.name))) {
return true;
}
if (traverse_children(child, depth + 1)) {
return true;
}
}
}
return false;
}
return traverse_children(root, 1);
}

@ -186,6 +186,16 @@ export interface CompileOptions {
preserveComments?: boolean; preserveComments?: boolean;
preserveWhitespace?: boolean; preserveWhitespace?: boolean;
a11y?: {
rules?: {
'label-has-associated-control'?: {
labelComponents?: string[];
controlComponents?: string[];
depth?: number;
};
};
};
} }
export interface ParserOptions { export interface ParserOptions {

@ -0,0 +1,13 @@
<script>
import MyLabel from "./MyLabel.svelte";
import MyInput from "./MyInput.svelte";
import CustomLabel from "./CustomLabel.svelte";
</script>
<MyLabel>xxx</MyLabel>
<CustomLabel>xxx</CustomLabel>
<MyLabel><input /></MyLabel>
<MyLabel><slot /></MyLabel>
<MyLabel><MyInput /></MyLabel>
<MyLabel><div><MyInput /></div></MyLabel>
<label><div><MyInput /></div></label>

@ -0,0 +1,10 @@
{
"a11y": {
"rules": {
"label-has-associated-control": {
"labelComponents": ["MyLabel"],
"controlComponents": ["MyInput"]
}
}
}
}

@ -0,0 +1,17 @@
[
{
"code": "a11y-label-has-associated-control",
"message": "A11y: A form label must be associated with a control.",
"pos": 151,
"start": {
"character": 151,
"column": 0,
"line": 7
},
"end": {
"character": 173,
"column": 22,
"line": 7
}
}
]
Loading…
Cancel
Save