diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index 5fc5faa8f12c..b1981e0494f1 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -179,6 +179,10 @@ export default { code: 'a11y-missing-content', message: `A11y: <${name}> element should have child content` }), + a11y_no_nointeractive_tabindex: { + code: 'a11y-no-nointeractive-tabindex', + message: 'A11y: not interactive element cannot have positive tabIndex value' + }, redundant_event_modifier_for_touch: { code: 'redundant-event-modifier', message: 'Touch event handlers that don\'t use the \'event\' object are passive by default' diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index e351a02499c9..b41c5654ea0f 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -24,7 +24,7 @@ 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 } from '../utils/a11y'; +import { is_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles } from '../utils/a11y'; const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|svg|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/; @@ -551,8 +551,15 @@ export default class Element extends Node { } } }); - } + // no-nointeractive-tabindex + if (!is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefintionKey)) { + const tab_index = attribute_map.get('tabindex'); + if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) { + component.warn(this, compiler_warnings.a11y_no_nointeractive_tabindex); + } + } + } validate_special_cases() { const { component, attributes, handlers } = this; diff --git a/src/compiler/compile/utils/a11y.ts b/src/compiler/compile/utils/a11y.ts index 1e06608b54e3..5b300eac1393 100644 --- a/src/compiler/compile/utils/a11y.ts +++ b/src/compiler/compile/utils/a11y.ts @@ -51,6 +51,10 @@ export function is_non_interactive_roles(role: ARIARoleDefintionKey) { return non_interactive_roles.has(role); } +export function is_interactive_roles(role: ARIARoleDefintionKey) { + return interactive_roles.has(role); +} + const presentation_roles = new Set(['presentation', 'none']); export function is_presentation_role(role: ARIARoleDefintionKey) { diff --git a/test/validator/samples/a11y-no-nointeractive-tabindex/input.svelte b/test/validator/samples/a11y-no-nointeractive-tabindex/input.svelte new file mode 100644 index 000000000000..e9ac0d3c9f7a --- /dev/null +++ b/test/validator/samples/a11y-no-nointeractive-tabindex/input.svelte @@ -0,0 +1,14 @@ + +