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.
| `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"`.
| `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>
```
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`

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

@ -24,7 +24,15 @@ import { Literal } from 'estree';
import compiler_warnings from '../compiler_warnings';
import compiler_errors from '../compiler_errors';
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_attribute_set = new Set(aria_attributes);
@ -64,17 +72,6 @@ const a11y_required_content = new Set([
'h6'
]);
const a11y_labelable = new Set([
'button',
'input',
'keygen',
'meter',
'output',
'progress',
'select',
'textarea'
]);
const a11y_nested_implicit_semantics = new Map([
['header', 'banner'],
['footer', 'contentinfo']
@ -413,12 +410,12 @@ export default class Element extends Node {
}
case 'Transition':
{
const transition = new Transition(component, this, scope, node);
if (node.intro) this.intro = transition;
if (node.outro) this.outro = transition;
break;
}
{
const transition = new Transition(component, this, scope, node);
if (node.intro) this.intro = transition;
if (node.outro) this.outro = transition;
break;
}
case 'Animation':
this.animation = new Animation(component, this, scope, node);
@ -790,24 +787,8 @@ export default class Element extends Node {
}
if (this.name === 'label') {
const has_input_child = (children: INode[]) => {
if (children.some(child => (child instanceof Element && (a11y_labelable.has(child.name) || child.name === 'slot')))) {
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)) {
const rule_options = component.compile_options.a11y?.rules?.['label-has-associated-control'];
if (!attribute_map.has('for') && !may_contain_input_child(this, rule_options)) {
component.warn(this, compiler_warnings.a11y_label_has_associated_control);
}
}

@ -10,7 +10,9 @@ import TemplateScope from './shared/TemplateScope';
import { INode } from './interfaces';
import { TemplateNode } from '../../interfaces';
import compiler_errors from '../compiler_errors';
import compiler_warnings from '../compiler_warnings';
import { regex_only_whitespaces } from '../../utils/patterns';
import { may_contain_input_child } from '../utils/a11y';
export default class InlineComponent extends Node {
type: 'InlineComponent';
@ -160,11 +162,22 @@ export default class InlineComponent extends Node {
}
this.children = map_children(component, this, this.scope, children);
this.validate();
}
get slot_template_name() {
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) {

@ -5,7 +5,9 @@ import {
ARIARoleRelationConcept
} from 'aria-query';
import { AXObjects, AXObjectRoles, elementAXObjects } from 'axobject-query';
import { CompileOptions } from '../../interfaces';
import Attribute from '../nodes/Attribute';
import { INode } from '../nodes/interfaces';
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;
}
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);
}

@ -41,14 +41,14 @@ interface DebugTag extends BaseNode {
}
export type DirectiveType = 'Action'
| 'Animation'
| 'Binding'
| 'Class'
| 'StyleDirective'
| 'EventHandler'
| 'Let'
| 'Ref'
| 'Transition';
| 'Animation'
| 'Binding'
| 'Class'
| 'StyleDirective'
| 'EventHandler'
| 'Let'
| 'Ref'
| 'Transition';
interface BaseDirective extends BaseNode {
type: DirectiveType;
@ -88,16 +88,16 @@ export interface Transition extends BaseExpressionDirective {
export type Directive = BaseDirective | BaseExpressionDirective | Transition;
export type TemplateNode = Text
| ConstTag
| DebugTag
| MustacheTag
| BaseNode
| Element
| Attribute
| SpreadAttribute
| Directive
| Transition
| Comment;
| ConstTag
| DebugTag
| MustacheTag
| BaseNode
| Element
| Attribute
| SpreadAttribute
| Directive
| Transition
| Comment;
export interface Parser {
readonly template: string;
@ -186,6 +186,16 @@ export interface CompileOptions {
preserveComments?: boolean;
preserveWhitespace?: boolean;
a11y?: {
rules?: {
'label-has-associated-control'?: {
labelComponents?: string[];
controlComponents?: string[];
depth?: number;
};
};
};
}
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