Skip to content

Commit 95c346c

Browse files
committed
Merge remote-tracking branch 'origin/master' into sites
2 parents b45fe80 + aa4d0fc commit 95c346c

File tree

26 files changed

+618
-236
lines changed

26 files changed

+618
-236
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
## Unreleased
44

55
* Add `bind:innerText` for `contenteditable` elements ([#3311](https://github.com/sveltejs/svelte/issues/3311))
6+
* Relax `a11y-no-noninteractive-element-to-interactive-role` warning ([#8402](https://github.com/sveltejs/svelte/pull/8402))
7+
* Add `a11y-interactive-supports-focus` warning ([#8392](https://github.com/sveltejs/svelte/pull/8392))
8+
* Fix equality check when updating dynamic text ([#5931](https://github.com/sveltejs/svelte/issues/5931))
69

710
## 3.57.0
811

elements/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1586,6 +1586,7 @@ export interface SvelteHTMLElements {
15861586

15871587
// Svelte specific
15881588
'svelte:window': SvelteWindowAttributes;
1589+
'svelte:document': HTMLAttributes<Document>;
15891590
'svelte:body': HTMLAttributes<HTMLElement>;
15901591
'svelte:fragment': { slot?: string };
15911592
'svelte:options': { [name: string]: any };

site/content/docs/06-accessibility-warnings.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ Enforce that attributes important for accessibility have a valid value. For exam
137137

138138
---
139139

140+
### `a11y-interactive-supports-focus`
141+
142+
Enforce that elements with an interactive role and interactive handlers (mouse or key press) must be focusable or tabbable.
143+
144+
```sv
145+
<!-- A11y: Elements with the 'button' interactive role must have a tabindex value. -->
146+
<div role="button" on:keypress={() => {}} />
147+
```
148+
149+
---
150+
140151
### `a11y-label-has-associated-control`
141152

142153
Enforce that a label tag has a text label and an associated control.

site/content/tutorial/16-special-elements/06-svelte-document/app-a/App.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
const handleSelectionChange = (e) => selection = document.getSelection();
55
</script>
66

7-
<svelte:body />
7+
<svelte:document />
88

99
<p>Select this text to fire events</p>
1010
<p>Selection: {selection}</p>

src/compiler/compile/compiler_warnings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ export default {
166166
code: 'a11y-img-redundant-alt',
167167
message: 'A11y: Screenreaders already announce <img> elements as an image.'
168168
},
169+
a11y_interactive_supports_focus: (role: string) => ({
170+
code: 'a11y-interactive-supports-focus',
171+
message: `A11y: Elements with the '${role}' interactive role must have a tabindex value.`
172+
}),
169173
a11y_label_has_associated_control: {
170174
code: 'a11y-label-has-associated-control',
171175
message: 'A11y: A form label must be associated with a control.'

src/compiler/compile/nodes/Element.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { Literal } from 'estree';
2525
import compiler_warnings from '../compiler_warnings';
2626
import compiler_errors from '../compiler_errors';
2727
import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query';
28-
import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role } from '../utils/a11y';
28+
import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute } from '../utils/a11y';
2929

3030
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(' ');
3131
const aria_attribute_set = new Set(aria_attributes);
@@ -75,6 +75,33 @@ const a11y_labelable = new Set([
7575
'textarea'
7676
]);
7777

78+
const a11y_interactive_handlers = new Set([
79+
// Keyboard events
80+
'keypress',
81+
'keydown',
82+
'keyup',
83+
84+
// Click events
85+
'click',
86+
'contextmenu',
87+
'dblclick',
88+
'drag',
89+
'dragend',
90+
'dragenter',
91+
'dragexit',
92+
'dragleave',
93+
'dragover',
94+
'dragstart',
95+
'drop',
96+
'mousedown',
97+
'mouseenter',
98+
'mouseleave',
99+
'mousemove',
100+
'mouseout',
101+
'mouseover',
102+
'mouseup'
103+
]);
104+
78105
const a11y_nested_implicit_semantics = new Map([
79106
['header', 'banner'],
80107
['footer', 'contentinfo']
@@ -145,6 +172,35 @@ const input_type_to_implicit_role = new Map([
145172
['url', 'textbox']
146173
]);
147174

175+
/**
176+
* Exceptions to the rule which follows common A11y conventions
177+
* TODO make this configurable by the user
178+
*/
179+
const a11y_non_interactive_element_to_interactive_role_exceptions = {
180+
ul: [
181+
'listbox',
182+
'menu',
183+
'menubar',
184+
'radiogroup',
185+
'tablist',
186+
'tree',
187+
'treegrid'
188+
],
189+
ol: [
190+
'listbox',
191+
'menu',
192+
'menubar',
193+
'radiogroup',
194+
'tablist',
195+
'tree',
196+
'treegrid'
197+
],
198+
li: ['menuitem', 'option', 'row', 'tab', 'treeitem'],
199+
table: ['grid'],
200+
td: ['gridcell'],
201+
fieldset: ['radiogroup', 'presentation']
202+
};
203+
148204
const combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']);
149205

150206
function input_implicit_role(attribute_map: Map<string, Attribute>) {
@@ -603,13 +659,28 @@ export default class Element extends Node {
603659
}
604660
}
605661

662+
// interactive-supports-focus
663+
if (
664+
!has_disabled_attribute(attribute_map) &&
665+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
666+
!is_presentation_role(current_role) &&
667+
is_interactive_roles(current_role) &&
668+
is_static_element(this.name, attribute_map) &&
669+
!attribute_map.get('tabindex')
670+
) {
671+
const has_interactive_handlers = handlers.some((handler) => a11y_interactive_handlers.has(handler.name));
672+
if (has_interactive_handlers) {
673+
component.warn(this, compiler_warnings.a11y_interactive_supports_focus(current_role));
674+
}
675+
}
676+
606677
// no-interactive-element-to-noninteractive-role
607678
if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) {
608679
component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name));
609680
}
610681

611682
// no-noninteractive-element-to-interactive-role
612-
if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role)) {
683+
if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role) && !a11y_non_interactive_element_to_interactive_role_exceptions[this.name]?.includes(current_role)) {
613684
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_to_interactive_role(current_role, this.name));
614685
}
615686
});

src/compiler/compile/render_dom/wrappers/Element/index.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ export default class ElementWrapper extends Wrapper {
174174
child_dynamic_element_block?: Block = null;
175175
child_dynamic_element?: ElementWrapper = null;
176176

177+
element_data_name = null;
178+
177179
constructor(
178180
renderer: Renderer,
179181
block: Block,
@@ -287,6 +289,8 @@ export default class ElementWrapper extends Wrapper {
287289
}
288290

289291
this.fragment = new FragmentWrapper(renderer, block, node.children, this, strip_whitespace, next_sibling);
292+
293+
this.element_data_name = block.get_unique_name(`${this.var.name}_data`);
290294
}
291295

292296
render(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
@@ -516,7 +520,8 @@ export default class ElementWrapper extends Wrapper {
516520
child.render(
517521
block,
518522
is_template ? x`${node}.content` : node,
519-
nodes
523+
nodes,
524+
{ element_data_name: this.element_data_name }
520525
);
521526
});
522527
}
@@ -824,7 +829,6 @@ export default class ElementWrapper extends Wrapper {
824829

825830
add_spread_attributes(block: Block) {
826831
const levels = block.get_unique_name(`${this.var.name}_levels`);
827-
const data = block.get_unique_name(`${this.var.name}_data`);
828832

829833
const initial_props = [];
830834
const updates = [];
@@ -855,9 +859,9 @@ export default class ElementWrapper extends Wrapper {
855859
block.chunks.init.push(b`
856860
let ${levels} = [${initial_props}];
857861
858-
let ${data} = {};
862+
let ${this.element_data_name} = {};
859863
for (let #i = 0; #i < ${levels}.length; #i += 1) {
860-
${data} = @assign(${data}, ${levels}[#i]);
864+
${this.element_data_name} = @assign(${this.element_data_name}, ${levels}[#i]);
861865
}
862866
`);
863867

@@ -869,12 +873,12 @@ export default class ElementWrapper extends Wrapper {
869873
: x`@set_attributes`;
870874

871875
block.chunks.hydrate.push(
872-
b`${fn}(${this.var}, ${data});`
876+
b`${fn}(${this.var}, ${this.element_data_name});`
873877
);
874878

875879
if (this.has_dynamic_attribute) {
876880
block.chunks.update.push(b`
877-
${fn}(${this.var}, ${data} = @get_spread_update(${levels}, [
881+
${fn}(${this.var}, ${this.element_data_name} = @get_spread_update(${levels}, [
878882
${updates}
879883
]));
880884
`);
@@ -890,23 +894,23 @@ export default class ElementWrapper extends Wrapper {
890894
}
891895

892896
block.chunks.mount.push(b`
893-
'value' in ${data} && (${data}.multiple ? @select_options : @select_option)(${this.var}, ${data}.value);
897+
'value' in ${this.element_data_name} && (${this.element_data_name}.multiple ? @select_options : @select_option)(${this.var}, ${this.element_data_name}.value);
894898
`);
895899

896900
block.chunks.update.push(b`
897-
if (${block.renderer.dirty(Array.from(dependencies))} && 'value' in ${data}) (${data}.multiple ? @select_options : @select_option)(${this.var}, ${data}.value);
901+
if (${block.renderer.dirty(Array.from(dependencies))} && 'value' in ${this.element_data_name}) (${this.element_data_name}.multiple ? @select_options : @select_option)(${this.var}, ${this.element_data_name}.value);
898902
`);
899903
} else if (this.node.name === 'input' && this.attributes.find(attr => attr.node.name === 'value')) {
900904
const type = this.node.get_static_attribute_value('type');
901905
if (type === null || type === '' || type === 'text' || type === 'email' || type === 'password') {
902906
block.chunks.mount.push(b`
903-
if ('value' in ${data}) {
904-
${this.var}.value = ${data}.value;
907+
if ('value' in ${this.element_data_name}) {
908+
${this.var}.value = ${this.element_data_name}.value;
905909
}
906910
`);
907911
block.chunks.update.push(b`
908-
if ('value' in ${data}) {
909-
${this.var}.value = ${data}.value;
912+
if ('value' in ${this.element_data_name}) {
913+
${this.var}.value = ${this.element_data_name}.value;
910914
}
911915
`);
912916
}
@@ -1220,8 +1224,8 @@ export default class ElementWrapper extends Wrapper {
12201224
if (this.dynamic_style_dependencies.size > 0) {
12211225
maybe_create_style_changed_var();
12221226
// If all dependencies are same as the style attribute dependencies, then we can skip the dirty check
1223-
condition =
1224-
all_deps.size === this.dynamic_style_dependencies.size
1227+
condition =
1228+
all_deps.size === this.dynamic_style_dependencies.size
12251229
? style_changed_var
12261230
: x`${style_changed_var} || ${condition}`;
12271231
}
@@ -1232,7 +1236,6 @@ export default class ElementWrapper extends Wrapper {
12321236
}
12331237
`);
12341238
}
1235-
12361239
});
12371240
}
12381241

src/compiler/compile/render_dom/wrappers/Fragment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import Body from './Body';
44
import DebugTag from './DebugTag';
55
import Document from './Document';
66
import EachBlock from './EachBlock';
7-
import Element from './Element/index';
7+
import Element from './Element';
88
import Head from './Head';
99
import IfBlock from './IfBlock';
1010
import KeyBlock from './KeyBlock';

src/compiler/compile/render_dom/wrappers/MustacheTag.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import Wrapper from './shared/Wrapper';
55
import MustacheTag from '../../nodes/MustacheTag';
66
import RawMustacheTag from '../../nodes/RawMustacheTag';
77
import { x } from 'code-red';
8-
import { Identifier } from 'estree';
8+
import { Identifier, Expression } from 'estree';
9+
import ElementWrapper from './Element';
10+
import AttributeWrapper from './Element/Attribute';
911

1012
export default class MustacheTagWrapper extends Tag {
1113
var: Identifier = { type: 'Identifier', name: 't' };
@@ -14,10 +16,40 @@ export default class MustacheTagWrapper extends Tag {
1416
super(renderer, block, parent, node);
1517
}
1618

17-
render(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
19+
render(block: Block, parent_node: Identifier, parent_nodes: Identifier, data: Record<string, unknown> | undefined) {
20+
const contenteditable_attributes =
21+
this.parent instanceof ElementWrapper &&
22+
this.parent.attributes.filter((a) => a.node.name === 'contenteditable');
23+
24+
const spread_attributes =
25+
this.parent instanceof ElementWrapper &&
26+
this.parent.attributes.filter((a) => a.node.is_spread);
27+
28+
let contenteditable_attr_value: Expression | true | undefined = undefined;
29+
if (contenteditable_attributes.length > 0) {
30+
const attribute = contenteditable_attributes[0] as AttributeWrapper;
31+
if ([true, 'true', ''].includes(attribute.node.get_static_value())) {
32+
contenteditable_attr_value = true;
33+
} else {
34+
contenteditable_attr_value = x`${attribute.get_value(block)}`;
35+
}
36+
} else if (spread_attributes.length > 0 && data.element_data_name) {
37+
contenteditable_attr_value = x`${data.element_data_name}['contenteditable']`;
38+
}
39+
1840
const { init } = this.rename_this_method(
1941
block,
20-
value => x`@set_data(${this.var}, ${value})`
42+
value => {
43+
if (contenteditable_attr_value) {
44+
if (contenteditable_attr_value === true) {
45+
return x`@set_data_contenteditable(${this.var}, ${value})`;
46+
} else {
47+
return x`@set_data_maybe_contenteditable(${this.var}, ${value}, ${contenteditable_attr_value})`;
48+
}
49+
} else {
50+
return x`@set_data(${this.var}, ${value})`;
51+
}
52+
}
2153
);
2254

2355
block.add_element(

src/compiler/compile/render_dom/wrappers/shared/Wrapper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export default class Wrapper {
8585
);
8686
}
8787

88-
render(_block: Block, _parent_node: Identifier, _parent_nodes: Identifier) {
88+
render(_block: Block, _parent_node: Identifier, _parent_nodes: Identifier, _data: Record<string, any> = undefined) {
8989
throw Error('Wrapper class is not renderable');
9090
}
9191
}

src/compiler/compile/utils/a11y.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ export function is_hidden_from_screen_reader(tag_name: string, attribute_map: Ma
6868
return aria_hidden_value === true || aria_hidden_value === 'true';
6969
}
7070

71+
export function has_disabled_attribute(attribute_map: Map<string, Attribute>) {
72+
const disabled_attr = attribute_map.get('disabled');
73+
const disabled_attr_value = disabled_attr && disabled_attr.get_static_value();
74+
if (disabled_attr_value) {
75+
return true;
76+
}
77+
78+
const aria_disabled_attr = attribute_map.get('aria-disabled');
79+
if (aria_disabled_attr) {
80+
const aria_disabled_attr_value = aria_disabled_attr.get_static_value();
81+
if (aria_disabled_attr_value === true) {
82+
return true;
83+
}
84+
}
85+
86+
return false;
87+
}
88+
7189
const non_interactive_element_role_schemas: ARIARoleRelationConcept[] = [];
7290

7391
elementRoles.entries().forEach(([schema, roles]) => {

0 commit comments

Comments
 (0)