Skip to content

Commit 966079b

Browse files
authored
Merge branch 'version-4' into legacy-packages
2 parents 0068873 + 39333b1 commit 966079b

File tree

30 files changed

+583
-61
lines changed

30 files changed

+583
-61
lines changed

.github/workflows/ci.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,17 @@ jobs:
1111
timeout-minutes: 15
1212
strategy:
1313
matrix:
14-
node-version: [14, 16, 18]
15-
os: [ubuntu-latest, windows-latest, macOS-latest]
14+
include:
15+
- node-version: 14
16+
os: ubuntu-latest
17+
- node-version: 14
18+
os: windows-latest
19+
- node-version: 14
20+
os: macOS-latest
21+
- node-version: 16
22+
os: ubuntu-latest
23+
- node-version: 18
24+
os: ubuntu-latest
1625
steps:
1726
- uses: actions/checkout@v3
1827
- uses: actions/setup-node@v3
@@ -37,7 +46,17 @@ jobs:
3746
timeout-minutes: 10
3847
strategy:
3948
matrix:
40-
os: [ubuntu-latest, windows-latest, macOS-latest]
49+
include:
50+
- node-version: 14
51+
os: ubuntu-latest
52+
- node-version: 14
53+
os: windows-latest
54+
- node-version: 14
55+
os: macOS-latest
56+
- node-version: 16
57+
os: ubuntu-latest
58+
- node-version: 18
59+
os: ubuntu-latest
4160
steps:
4261
- uses: actions/checkout@v3
4362
- uses: actions/setup-node@v3

CHANGELOG.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
## Unreleased (4.0)
44

5-
* Minimum supported Node version is now Node 14
6-
* Minimum supported webpack version is now webpack 5
5+
* **breaking** Minimum supported Node version is now Node 14
6+
* **breaking** Minimum supported webpack version is now webpack 5
7+
* **breaking** Minimum supported TypeScript version is now TypeScript 5 (it will likely work with lower versions, but we make no guarantees about that)
8+
* **breaking** Stricter types for `createEventDispatcher` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
9+
* **breaking** Stricter types for `Action` and `ActionReturn` (see PR for migration instructions) ([#7224](https://github.com/sveltejs/svelte/pull/7224))
10+
* Add `a11y no-noninteractive-element-interactions` rule ([#8391](https://github.com/sveltejs/svelte/pull/8391))
11+
* Add `a11y-no-static-element-interactions`rule ([#8251](https://github.com/sveltejs/svelte/pull/8251))
12+
* Bind `null` option and input values consistently ([#8312](https://github.com/sveltejs/svelte/issues/8312))
713

814
## Unreleased (3.0)
915

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
},
9191
"types": "types/runtime/index.d.ts",
9292
"scripts": {
93-
"test": "npm run test:unit && npm run test:integration",
93+
"test": "npm run test:unit && npm run test:integration && echo \"manually check that there are no type errors in test/types by opening the files in there\"",
9494
"test:integration": "mocha --exit",
9595
"test:unit": "mocha --config .mocharc.unit.js --exit",
9696
"quicktest": "mocha --exit",

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,20 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t
288288

289289
---
290290

291+
### `a11y-no-noninteractive-element-interactions`
292+
293+
A non-interactive element does not support event handlers (mouse and key handlers). Non-interactive elements include `<main>`, `<area>`, `<h1>` (,`<h2>`, etc), `<p>`, `<img>`, `<li>`, `<ul>` and `<ol>`. Non-interactive [WAI-ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) include `article`, `banner`, `complementary`, `img`, `listitem`, `main`, `region` and `tooltip`.
294+
295+
```sv
296+
<!-- `A11y: Non-interactive element <li> should not be assigned mouse or keyboard event listeners.` -->
297+
<li on:click={() => {}} />
298+
299+
<!-- `A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.` -->
300+
<div role="listitem" on:click={() => {}} />
301+
```
302+
303+
---
304+
291305
### `a11y-no-noninteractive-element-to-interactive-role`
292306

293307
[WAI-ARIA](https://www.w3.org/TR/wai-aria-1.1/#usage_intro) roles should not be used to convert a non-interactive element to an interactive element. Interactive ARIA roles include `button`, `link`, `checkbox`, `menuitem`, `menuitemcheckbox`, `menuitemradio`, `option`, `radio`, `searchbox`, `switch` and `textbox`.

src/compiler/compile/compiler_warnings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,18 @@ export default {
115115
code: 'a11y-no-redundant-roles',
116116
message: `A11y: Redundant role '${role}'`
117117
}),
118+
a11y_no_static_element_interactions: (element: string, handlers: string[]) => ({
119+
code: 'a11y-no-static-element-interactions',
120+
message: `A11y: <${element}> with ${handlers.join(', ')} ${handlers.length === 1 ? 'handler' : 'handlers'} must have an ARIA role`
121+
}),
118122
a11y_no_interactive_element_to_noninteractive_role: (role: string | boolean, element: string) => ({
119123
code: 'a11y-no-interactive-element-to-noninteractive-role',
120124
message: `A11y: <${element}> cannot have role '${role}'`
121125
}),
126+
a11y_no_noninteractive_element_interactions: (element: string) => ({
127+
code: 'a11y-no-noninteractive-element-interactions',
128+
message: `A11y: Non-interactive element <${element}> should not be assigned mouse or keyboard event listeners.`
129+
}),
122130
a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({
123131
code: 'a11y-no-noninteractive-element-to-interactive-role',
124132
message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'`

src/compiler/compile/nodes/Element.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import StyleDirective from './StyleDirective';
1111
import Text from './Text';
1212
import { namespaces } from '../../utils/namespaces';
1313
import map_children from './shared/map_children';
14-
import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable';
14+
import { is_name_contenteditable, get_contenteditable_attr, has_contenteditable_attr } from '../utils/contenteditable';
1515
import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns';
1616
import fuzzymatch from '../../utils/fuzzymatch';
1717
import list from '../../utils/list';
@@ -102,6 +102,15 @@ const a11y_interactive_handlers = new Set([
102102
'mouseup'
103103
]);
104104

105+
const a11y_recommended_interactive_handlers = new Set([
106+
'click',
107+
'mousedown',
108+
'mouseup',
109+
'keypress',
110+
'keydown',
111+
'keyup'
112+
]);
113+
105114
const a11y_nested_implicit_semantics = new Map([
106115
['header', 'banner'],
107116
['footer', 'contentinfo']
@@ -738,17 +747,19 @@ export default class Element extends Node {
738747
}
739748
}
740749

750+
const role = attribute_map.get('role');
751+
const role_static_value = role?.get_static_value() as ARIARoleDefinitionKey;
752+
const role_value = (role ? role_static_value : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
753+
741754
// no-noninteractive-tabindex
742-
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) {
755+
if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(role_static_value)) {
743756
const tab_index = attribute_map.get('tabindex');
744757
if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) {
745758
component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex);
746759
}
747760
}
748761

749762
// role-supports-aria-props
750-
const role = attribute_map.get('role');
751-
const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey;
752763
if (typeof role_value === 'string' && roles.has(role_value)) {
753764
const { props } = roles.get(role_value);
754765
const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props)));
@@ -762,6 +773,45 @@ export default class Element extends Node {
762773
}
763774
});
764775
}
776+
777+
// no-noninteractive-element-interactions
778+
if (
779+
!has_contenteditable_attr(this) &&
780+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
781+
!is_presentation_role(role_static_value) &&
782+
((!is_interactive_element(this.name, attribute_map) &&
783+
is_non_interactive_roles(role_static_value)) ||
784+
(is_non_interactive_element(this.name, attribute_map) && !role))
785+
) {
786+
const has_interactive_handlers = handlers.some((handler) => a11y_recommended_interactive_handlers.has(handler.name));
787+
if (has_interactive_handlers) {
788+
component.warn(this, compiler_warnings.a11y_no_noninteractive_element_interactions(this.name));
789+
}
790+
}
791+
792+
const has_dynamic_role = attribute_map.get('role') && !attribute_map.get('role').is_static;
793+
794+
// no-static-element-interactions
795+
if (
796+
!has_dynamic_role &&
797+
!is_hidden_from_screen_reader(this.name, attribute_map) &&
798+
!is_presentation_role(role_static_value) &&
799+
!is_interactive_element(this.name, attribute_map) &&
800+
!is_interactive_roles(role_static_value) &&
801+
!is_non_interactive_element(this.name, attribute_map) &&
802+
!is_non_interactive_roles(role_static_value) &&
803+
!is_abstract_role(role_static_value)
804+
) {
805+
const interactive_handlers = handlers
806+
.map((handler) => handler.name)
807+
.filter((handlerName) => a11y_interactive_handlers.has(handlerName));
808+
if (interactive_handlers.length > 0) {
809+
component.warn(
810+
this,
811+
compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers)
812+
);
813+
}
814+
}
765815
}
766816

767817
validate_special_cases() {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export default class AttributeWrapper extends BaseAttributeWrapper {
172172
}
173173

174174
if (is_indirectly_bound_value) {
175-
const update_value = b`${element.var}.value = ${element.var}.__value;`;
175+
const update_value = b`@set_input_value(${element.var}, ${element.var}.__value);`;
176176
block.chunks.hydrate.push(update_value);
177177

178178
updater = b`

src/compiler/compile/utils/a11y.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ const non_interactive_roles = new Set(
1919
// 'toolbar' does not descend from widget, but it does support
2020
// aria-activedescendant, thus in practice we treat it as a widget.
2121
// focusable tabpanel elements are recommended if any panels in a set contain content where the first element in the panel is not focusable.
22-
!['toolbar', 'tabpanel'].includes(name) &&
22+
// 'generic' is meant to have no semantic meaning.
23+
!['toolbar', 'tabpanel', 'generic'].includes(name) &&
2324
!role.superClass.some((classes) => classes.includes('widget'))
2425
);
2526
})
@@ -31,7 +32,11 @@ const non_interactive_roles = new Set(
3132
);
3233

3334
const interactive_roles = new Set(
34-
non_abstract_roles.filter((name) => !non_interactive_roles.has(name))
35+
non_abstract_roles.filter((name) =>
36+
!non_interactive_roles.has(name) &&
37+
// 'generic' is meant to have no semantic meaning.
38+
name !== 'generic'
39+
)
3540
);
3641

3742
export function is_non_interactive_roles(role: ARIARoleDefinitionKey) {

src/runtime/action/index.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* Actions can return an object containing the two properties defined in this interface. Both are optional.
33
* - update: An action can have a parameter. This method will be called whenever that parameter changes,
4-
* immediately after Svelte has applied updates to the markup.
4+
* immediately after Svelte has applied updates to the markup. `ActionReturn` and `ActionReturn<never>` both
5+
* mean that the action accepts no parameters, which makes it illegal to set the `update` method.
56
* - destroy: Method that is called after the element is unmounted
67
*
78
* Additionally, you can specify which additional attributes and events the action enables on the applied element.
@@ -25,8 +26,8 @@
2526
*
2627
* Docs: https://svelte.dev/docs#template-syntax-element-directives-use-action
2728
*/
28-
export interface ActionReturn<Parameter = any, Attributes extends Record<string, any> = Record<never, any>> {
29-
update?: (parameter: Parameter) => void;
29+
export interface ActionReturn<Parameter = never, Attributes extends Record<string, any> = Record<never, any>> {
30+
update?: [Parameter] extends [never] ? never : (parameter: Parameter) => void;
3031
destroy?: () => void;
3132
/**
3233
* ### DO NOT USE THIS
@@ -42,15 +43,21 @@ export interface ActionReturn<Parameter = any, Attributes extends Record<string,
4243
* The following example defines an action that only works on `<div>` elements
4344
* and optionally accepts a parameter which it has a default value for:
4445
* ```ts
45-
* export const myAction: Action<HTMLDivElement, { someProperty: boolean }> = (node, param = { someProperty: true }) => {
46+
* export const myAction: Action<HTMLDivElement, { someProperty: boolean } | undefined> = (node, param = { someProperty: true }) => {
4647
* // ...
4748
* }
4849
* ```
50+
* `Action<HTMLDivElement>` and `Action<HTMLDiveElement, never>` both signal that the action accepts no parameters.
51+
*
4952
* You can return an object with methods `update` and `destroy` from the function and type which additional attributes and events it has.
5053
* See interface `ActionReturn` for more details.
5154
*
5255
* Docs: https://svelte.dev/docs#template-syntax-element-directives-use-action
5356
*/
54-
export interface Action<Element = HTMLElement, Parameter = any, Attributes extends Record<string, any> = Record<never, any>> {
55-
<Node extends Element>(node: Node, parameter?: Parameter): void | ActionReturn<Parameter, Attributes>;
57+
export interface Action<Element = HTMLElement, Parameter = never, Attributes extends Record<string, any> = Record<never, any>> {
58+
<Node extends Element>(...args: [Parameter] extends [never] ? [node: Node] : undefined extends Parameter ? [node: Node, parameter?: Parameter] : [node: Node, parameter: Parameter]): void | ActionReturn<Parameter, Attributes>;
5659
}
60+
61+
// Implementation notes:
62+
// - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode
63+
// - [X] extends [never] is needed, X extends never would reduce the whole resulting type to never and not to one of the condition outcomes

src/runtime/internal/lifecycle.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ export function onDestroy(fn: () => any) {
5656
get_current_component().$$.on_destroy.push(fn);
5757
}
5858

59+
export interface EventDispatcher<EventMap extends Record<string, any>> {
60+
// Implementation notes:
61+
// - undefined extends X instead of X extends undefined makes this work better with both strict and nonstrict mode
62+
// - [X] extends [never] is needed, X extends never would reduce the whole resulting type to never and not to one of the condition outcomes
63+
<Type extends keyof EventMap>(
64+
...args: [EventMap[Type]] extends [never] ? [type: Type, parameter?: null | undefined, options?: DispatchOptions] :
65+
null extends EventMap[Type] ? [type: Type, parameter?: EventMap[Type], options?: DispatchOptions] :
66+
undefined extends EventMap[Type] ? [type: Type, parameter?: EventMap[Type], options?: DispatchOptions] :
67+
[type: Type, parameter: EventMap[Type], options?: DispatchOptions]): boolean;
68+
}
69+
5970
export interface DispatchOptions {
6071
cancelable?: boolean;
6172
}
@@ -68,20 +79,23 @@ export interface DispatchOptions {
6879
* [CustomEvent](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent).
6980
* These events do not [bubble](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_bubbling_and_capture).
7081
* The `detail` argument corresponds to the [CustomEvent.detail](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/detail)
71-
* property and can contain any type of data.
82+
* property and can contain any type of data.
83+
*
84+
* The event dispatcher can be typed to narrow the allowed event names and the type of the `detail` argument:
85+
* ```ts
86+
* const dispatch = createEventDispatcher<{
87+
* loaded: never; // does not take a detail argument
88+
* change: string; // takes a detail argument of type string, which is required
89+
* optional: number | null; // takes an optional detail argument of type number
90+
* }>();
91+
* ```
7292
*
7393
* https://svelte.dev/docs#run-time-svelte-createeventdispatcher
7494
*/
75-
export function createEventDispatcher<EventMap extends {} = any>(): <
76-
EventKey extends Extract<keyof EventMap, string>
77-
>(
78-
type: EventKey,
79-
detail?: EventMap[EventKey],
80-
options?: DispatchOptions
81-
) => boolean {
95+
export function createEventDispatcher<EventMap extends Record<string, any> = any>(): EventDispatcher<EventMap> {
8296
const component = get_current_component();
8397

84-
return (type: string, detail?: any, { cancelable = false } = {}): boolean => {
98+
return ((type: string, detail?: any, { cancelable = false } = {}): boolean => {
8599
const callbacks = component.$$.callbacks[type];
86100

87101
if (callbacks) {
@@ -95,7 +109,7 @@ export function createEventDispatcher<EventMap extends {} = any>(): <
95109
}
96110

97111
return true;
98-
};
112+
}) as EventDispatcher<EventMap>;
99113
}
100114

101115
/**

0 commit comments

Comments
 (0)