Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/content/docs/05-compile-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
49 changes: 49 additions & 0 deletions site/content/docs/06-accessibility-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 Svelte 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: 5,
}
}
}
},
}
```

- `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 5, 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`
Expand Down
3 changes: 2 additions & 1 deletion src/compiler/compile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const valid_options = [
'loopGuardTimeout',
'preserveComments',
'preserveWhitespace',
'cssHash'
'cssHash',
'a11y'
];

const valid_css_values = [
Expand Down
53 changes: 17 additions & 36 deletions src/compiler/compile/nodes/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
contains_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);
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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') && !contains_input_child(this, rule_options)) {
component.warn(this, compiler_warnings.a11y_label_has_associated_control);
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/compiler/compile/nodes/InlineComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { contains_input_child } from '../utils/a11y';

export default class InlineComponent extends Node {
type: 'InlineComponent';
Expand Down Expand Up @@ -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 (!contains_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) {
Expand Down
44 changes: 44 additions & 0 deletions src/compiler/compile/utils/a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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 contains_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 ?? 5, 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);
}
46 changes: 28 additions & 18 deletions src/compiler/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"a11y": {
"rules": {
"label-has-associated-control": {
"labelComponents": ["MyLabel"],
"controlComponents": ["MyInput"]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"code": "a11y-label-has-associated-control",
"message": "A11y: A form label must be associated with a control.",
"start": {
"column": 0,
"line": 7
},
"end": {
"column": 22,
"line": 7
}
}
]