diff --git a/site/content/docs/05-compile-time.md b/site/content/docs/05-compile-time.md index 45dd454ea01d..1437f41016b9 100644 --- a/site/content/docs/05-compile-time.md +++ b/site/content/docs/05-compile-time.md @@ -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. --- diff --git a/site/content/docs/06-accessibility-warnings.md b/site/content/docs/06-accessibility-warnings.md index ee52aab405ea..ab9225047d0b 100644 --- a/site/content/docs/06-accessibility-warnings.md +++ b/site/content/docs/06-accessibility-warnings.md @@ -153,6 +153,55 @@ There are two supported ways to associate a label with a control: ``` +If your label and input are Svelte components, you can configure the rule to be aware of your Svelte components. + +```svelte + + + +``` + +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` diff --git a/src/compiler/compile/index.ts b/src/compiler/compile/index.ts index 7a18231cef9d..102111070fa0 100644 --- a/src/compiler/compile/index.ts +++ b/src/compiler/compile/index.ts @@ -32,7 +32,8 @@ const valid_options = [ 'loopGuardTimeout', 'preserveComments', 'preserveWhitespace', - 'cssHash' + 'cssHash', + 'a11y' ]; const valid_css_values = [ diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index 4e43fe1824e8..85f92ca64f16 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -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); @@ -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') && !contains_input_child(this, rule_options)) { component.warn(this, compiler_warnings.a11y_label_has_associated_control); } } diff --git a/src/compiler/compile/nodes/InlineComponent.ts b/src/compiler/compile/nodes/InlineComponent.ts index e9ac86a55cf5..4978ff3b9806 100644 --- a/src/compiler/compile/nodes/InlineComponent.ts +++ b/src/compiler/compile/nodes/InlineComponent.ts @@ -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'; @@ -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) { diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index d51125fa926e..14a0a6c78f37 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -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 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); +} diff --git a/src/compiler/interfaces.ts b/src/compiler/interfaces.ts index 402f9ff5e143..e27e37cff12e 100644 --- a/src/compiler/interfaces.ts +++ b/src/compiler/interfaces.ts @@ -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 { diff --git a/test/validator/samples/a11y-label-has-associated-control-3/input.svelte b/test/validator/samples/a11y-label-has-associated-control-3/input.svelte new file mode 100644 index 000000000000..53a85fe9e845 --- /dev/null +++ b/test/validator/samples/a11y-label-has-associated-control-3/input.svelte @@ -0,0 +1,13 @@ + + +xxx +xxx + + + +
+ diff --git a/test/validator/samples/a11y-label-has-associated-control-3/options.json b/test/validator/samples/a11y-label-has-associated-control-3/options.json new file mode 100644 index 000000000000..f096f13593a9 --- /dev/null +++ b/test/validator/samples/a11y-label-has-associated-control-3/options.json @@ -0,0 +1,10 @@ +{ + "a11y": { + "rules": { + "label-has-associated-control": { + "labelComponents": ["MyLabel"], + "controlComponents": ["MyInput"] + } + } + } +} \ No newline at end of file diff --git a/test/validator/samples/a11y-label-has-associated-control-3/warnings.json b/test/validator/samples/a11y-label-has-associated-control-3/warnings.json new file mode 100644 index 000000000000..69032ac3633d --- /dev/null +++ b/test/validator/samples/a11y-label-has-associated-control-3/warnings.json @@ -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 + } + } +]