feat: Add `nonself` event modifier for non-interactive elements

pull/9029/head
Oscar Hermoso 3 years ago
parent 7af165de09
commit 3651e34c0c

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: add `|nonself` event modifier for non-interactive elements with event listeners

@ -59,6 +59,7 @@ The following modifiers are available:
- `capture` — fires the handler during the _capture_ phase instead of the _bubbling_ phase - `capture` — fires the handler during the _capture_ phase instead of the _bubbling_ phase
- `once` — remove the handler after the first time it runs - `once` — remove the handler after the first time it runs
- `self` — only trigger handler if `event.target` is the element itself - `self` — only trigger handler if `event.target` is the element itself
- `nonself` — only trigger handler when `event.target` is not the element itself, e.g. if the event bubbled from a child element
- `trusted` — only trigger handler if `event.isTrusted` is `true`. I.e. if the event is triggered by a user action. - `trusted` — only trigger handler if `event.isTrusted` is `true`. I.e. if the event is triggered by a user action.
Modifiers can be chained together, e.g. `on:click|once|capture={...}`. Modifiers can be chained together, e.g. `on:click|once|capture={...}`.

@ -23,6 +23,7 @@ The full list of modifiers:
- `capture` — fires the handler during the _capture_ phase instead of the _bubbling_ phase ([MDN docs](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_bubbling_and_capture)) - `capture` — fires the handler during the _capture_ phase instead of the _bubbling_ phase ([MDN docs](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_bubbling_and_capture))
- `once` — remove the handler after the first time it runs - `once` — remove the handler after the first time it runs
- `self` — only trigger handler if event.target is the element itself - `self` — only trigger handler if event.target is the element itself
- `nonself` — only trigger handler when `event.target` is not the element itself, e.g. if the event bubbled from a child element
- `trusted` — only trigger handler if `event.isTrusted` is `true`. I.e. if the event is triggered by a user action. - `trusted` — only trigger handler if `event.isTrusted` is `true`. I.e. if the event is triggered by a user action.
You can chain modifiers together, e.g. `on:click|once|capture={...}`. You can chain modifiers together, e.g. `on:click|once|capture={...}`.

@ -1,2 +1,2 @@
// This file is automatically generated // This file is automatically generated
export default new Set(["HtmlTag","HtmlTagHydration","ResizeObserverSingleton","SvelteComponent","SvelteComponentDev","SvelteComponentTyped","SvelteElement","action_destroyer","add_attribute","add_classes","add_flush_callback","add_iframe_resize_listener","add_location","add_render_callback","add_styles","add_transform","afterUpdate","append","append_dev","append_empty_stylesheet","append_hydration","append_hydration_dev","append_styles","assign","attr","attr_dev","attribute_to_object","beforeUpdate","bind","binding_callbacks","blank_object","bubble","check_outros","children","claim_comment","claim_component","claim_element","claim_html_tag","claim_space","claim_svg_element","claim_text","clear_loops","comment","component_subscribe","compute_rest_props","compute_slots","construct_svelte_component","construct_svelte_component_dev","contenteditable_truthy_values","createEventDispatcher","create_animation","create_bidirectional_transition","create_component","create_custom_element","create_in_transition","create_out_transition","create_slot","create_ssr_component","current_component","custom_event","dataset_dev","debug","destroy_block","destroy_component","destroy_each","detach","detach_after_dev","detach_before_dev","detach_between_dev","detach_dev","dirty_components","dispatch_dev","each","element","element_is","empty","end_hydrating","ensure_array_like","ensure_array_like_dev","escape","escape_attribute_value","escape_object","exclude_internal_props","fix_and_destroy_block","fix_and_outro_and_destroy_block","fix_position","flush","flush_render_callbacks","getAllContexts","getContext","get_all_dirty_from_scope","get_binding_group_value","get_current_component","get_custom_elements_slots","get_root_for_style","get_slot_changes","get_spread_object","get_spread_update","get_store_value","get_svelte_dataset","globals","group_outros","handle_promise","hasContext","has_prop","head_selector","identity","init","init_binding_group","init_binding_group_dynamic","insert","insert_dev","insert_hydration","insert_hydration_dev","intros","invalid_attribute_name_character","is_client","is_crossorigin","is_empty","is_function","is_promise","is_void","listen","listen_dev","loop","loop_guard","merge_ssr_styles","missing_component","mount_component","noop","not_equal","now","null_to_empty","object_without_properties","onDestroy","onMount","once","outro_and_destroy_block","prevent_default","prop_dev","query_selector_all","raf","resize_observer_border_box","resize_observer_content_box","resize_observer_device_pixel_content_box","run","run_all","safe_not_equal","schedule_update","select_multiple_value","select_option","select_options","select_value","self","setContext","set_attributes","set_current_component","set_custom_element_data","set_custom_element_data_map","set_data","set_data_contenteditable","set_data_contenteditable_dev","set_data_dev","set_data_maybe_contenteditable","set_data_maybe_contenteditable_dev","set_dynamic_element_data","set_input_type","set_input_value","set_now","set_raf","set_store_value","set_style","set_svg_attributes","space","split_css_unit","spread","src_url_equal","srcset_url_equal","start_hydrating","stop_immediate_propagation","stop_propagation","subscribe","svg_element","text","tick","time_ranges_to_array","to_number","toggle_class","transition_in","transition_out","trusted","update_await_block_branch","update_keyed_each","update_slot","update_slot_base","validate_component","validate_dynamic_element","validate_each_keys","validate_slots","validate_store","validate_void_dynamic_element","xlink_attr"]); export default new Set(["HtmlTag","HtmlTagHydration","ResizeObserverSingleton","SvelteComponent","SvelteComponentDev","SvelteComponentTyped","SvelteElement","action_destroyer","add_attribute","add_classes","add_flush_callback","add_iframe_resize_listener","add_location","add_render_callback","add_styles","add_transform","afterUpdate","append","append_dev","append_empty_stylesheet","append_hydration","append_hydration_dev","append_styles","assign","attr","attr_dev","attribute_to_object","beforeUpdate","bind","binding_callbacks","blank_object","bubble","check_outros","children","claim_comment","claim_component","claim_element","claim_html_tag","claim_space","claim_svg_element","claim_text","clear_loops","comment","component_subscribe","compute_rest_props","compute_slots","construct_svelte_component","construct_svelte_component_dev","contenteditable_truthy_values","createEventDispatcher","create_animation","create_bidirectional_transition","create_component","create_custom_element","create_in_transition","create_out_transition","create_slot","create_ssr_component","current_component","custom_event","dataset_dev","debug","destroy_block","destroy_component","destroy_each","detach","detach_after_dev","detach_before_dev","detach_between_dev","detach_dev","dirty_components","dispatch_dev","each","element","element_is","empty","end_hydrating","ensure_array_like","ensure_array_like_dev","escape","escape_attribute_value","escape_object","exclude_internal_props","fix_and_destroy_block","fix_and_outro_and_destroy_block","fix_position","flush","flush_render_callbacks","getAllContexts","getContext","get_all_dirty_from_scope","get_binding_group_value","get_current_component","get_custom_elements_slots","get_root_for_style","get_slot_changes","get_spread_object","get_spread_update","get_store_value","get_svelte_dataset","globals","group_outros","handle_promise","hasContext","has_prop","head_selector","identity","init","init_binding_group","init_binding_group_dynamic","insert","insert_dev","insert_hydration","insert_hydration_dev","intros","invalid_attribute_name_character","is_client","is_crossorigin","is_empty","is_function","is_promise","is_void","listen","listen_dev","loop","loop_guard","merge_ssr_styles","missing_component","mount_component","nonself","noop","not_equal","now","null_to_empty","object_without_properties","onDestroy","onMount","once","outro_and_destroy_block","prevent_default","prop_dev","query_selector_all","raf","resize_observer_border_box","resize_observer_content_box","resize_observer_device_pixel_content_box","run","run_all","safe_not_equal","schedule_update","select_multiple_value","select_option","select_options","select_value","self","setContext","set_attributes","set_current_component","set_custom_element_data","set_custom_element_data_map","set_data","set_data_contenteditable","set_data_contenteditable_dev","set_data_dev","set_data_maybe_contenteditable","set_data_maybe_contenteditable_dev","set_dynamic_element_data","set_input_type","set_input_value","set_now","set_raf","set_store_value","set_style","set_svg_attributes","space","split_css_unit","spread","src_url_equal","srcset_url_equal","start_hydrating","stop_immediate_propagation","stop_propagation","subscribe","svg_element","text","tick","time_ranges_to_array","to_number","toggle_class","transition_in","transition_out","trusted","update_await_block_branch","update_keyed_each","update_slot","update_slot_base","validate_component","validate_dynamic_element","validate_each_keys","validate_slots","validate_store","validate_void_dynamic_element","xlink_attr"]);

@ -244,6 +244,7 @@ const valid_modifiers = new Set([
'passive', 'passive',
'nonpassive', 'nonpassive',
'self', 'self',
'nonself',
'trusted' 'trusted'
]); ]);
const passive_events = new Set(['wheel', 'touchstart', 'touchmove', 'touchend', 'touchcancel']); const passive_events = new Set(['wheel', 'touchstart', 'touchmove', 'touchend', 'touchcancel']);
@ -599,7 +600,9 @@ export default class Element extends Node {
} }
validate_attributes_a11y() { validate_attributes_a11y() {
const { component, attributes, handlers } = this; const { component, attributes, handlers } = this;
/** @type {Map<string, Attribute>} */
const attribute_map = new Map(); const attribute_map = new Map();
/** @type {Map<string, EventHandler>} */
const handlers_map = new Map(); const handlers_map = new Map();
attributes.forEach((attribute) => attribute_map.set(attribute.name, attribute)); attributes.forEach((attribute) => attribute_map.set(attribute.name, attribute));
handlers.forEach((handler) => handlers_map.set(handler.name, handler)); handlers.forEach((handler) => handlers_map.set(handler.name, handler));
@ -781,7 +784,7 @@ export default class Element extends Node {
} }
}); });
// click-events-have-key-events // click-events-have-key-events
if (handlers_map.has('click')) { if (handlers_map.get('click')?.modifiers.has('nonself') === false) {
const role = attribute_map.get('role'); const role = attribute_map.get('role');
const is_non_presentation_role = const is_non_presentation_role =
role?.is_static && role?.is_static &&
@ -852,8 +855,10 @@ export default class Element extends Node {
is_non_interactive_roles(role_static_value)) || is_non_interactive_roles(role_static_value)) ||
(is_non_interactive_element(this.name, attribute_map) && !role)) (is_non_interactive_element(this.name, attribute_map) && !role))
) { ) {
const has_interactive_handlers = handlers.some((handler) => const has_interactive_handlers = handlers.some(
a11y_recommended_interactive_handlers.has(handler.name) (handler) =>
a11y_recommended_interactive_handlers.has(handler.name) &&
!handler.modifiers.has('nonself')
); );
if (has_interactive_handlers) { if (has_interactive_handlers) {
component.warn( component.warn(
@ -874,13 +879,19 @@ export default class Element extends Node {
!is_non_interactive_roles(role_static_value) && !is_non_interactive_roles(role_static_value) &&
!is_abstract_role(role_static_value) !is_abstract_role(role_static_value)
) { ) {
const interactive_handlers = handlers const interactive_handler_names = handlers
.map((handler) => handler.name) .filter(
.filter((handlerName) => a11y_interactive_handlers.has(handlerName)); (handler) =>
if (interactive_handlers.length > 0) { a11y_interactive_handlers.has(handler.name) && !handler.modifiers.has('nonself')
)
.map((handler) => handler.name);
if (interactive_handler_names.length > 0) {
component.warn( component.warn(
this, this,
compiler_warnings.a11y_no_static_element_interactions(this.name, interactive_handlers) compiler_warnings.a11y_no_static_element_interactions(
this.name,
interactive_handler_names
)
); );
} }
} }
@ -1266,6 +1277,12 @@ export default class Element extends Node {
compiler_errors.invalid_event_modifier_combination('passive', 'nonpassive') compiler_errors.invalid_event_modifier_combination('passive', 'nonpassive')
); );
} }
if (handler.modifiers.has('self') && handler.modifiers.has('nonself')) {
return component.error(
handler,
compiler_errors.invalid_event_modifier_combination('self', 'nonself')
);
}
handler.modifiers.forEach((modifier) => { handler.modifiers.forEach((modifier) => {
if (!valid_modifiers.has(modifier)) { if (!valid_modifiers.has(modifier)) {
return component.error( return component.error(

@ -50,6 +50,7 @@ export default class EventHandlerWrapper {
if (this.node.modifiers.has('stopImmediatePropagation')) if (this.node.modifiers.has('stopImmediatePropagation'))
snippet = x`@stop_immediate_propagation(${snippet})`; snippet = x`@stop_immediate_propagation(${snippet})`;
if (this.node.modifiers.has('self')) snippet = x`@self(${snippet})`; if (this.node.modifiers.has('self')) snippet = x`@self(${snippet})`;
if (this.node.modifiers.has('nonself')) snippet = x`@nonself(${snippet})`;
if (this.node.modifiers.has('trusted')) snippet = x`@trusted(${snippet})`; if (this.node.modifiers.has('trusted')) snippet = x`@trusted(${snippet})`;
const args = []; const args = [];
const opts = ['nonpassive', 'passive', 'once', 'capture'].filter((mod) => const opts = ['nonpassive', 'passive', 'once', 'capture'].filter((mod) =>

@ -399,6 +399,15 @@ export function self(fn) {
}; };
} }
/**
* @returns {(event: any) => void} */
export function nonself(fn) {
return function (event) {
// @ts-ignore
if (event.target !== event.currentTarget) fn.call(this, event);
};
}
/** /**
* @returns {(event: any) => void} */ * @returns {(event: any) => void} */
export function trusted(fn) { export function trusted(fn) {

@ -0,0 +1,18 @@
export default {
html: `
<div>
<button>click me</button>
</div>
`,
async test({ assert, component, target, window }) {
const button = target.querySelector('button');
const event = new window.MouseEvent('click', { bubbles: true });
await button.parentNode.dispatchEvent(event);
assert.ok(!component.clicked);
await button.dispatchEvent(event);
assert.ok(component.clicked);
}
};

@ -0,0 +1,13 @@
<script>
export let clicked;
let f;
function handle_click(event) {
clicked = true;
}
f = handle_click;
</script>
<div on:click|nonself={f}>
<button>click me</button>
</div>

@ -0,0 +1,18 @@
export default {
html: `
<div>
<button>click me</button>
</div>
`,
async test({ assert, component, target, window }) {
const button = target.querySelector('button');
const event = new window.MouseEvent('click', { bubbles: true });
await button.parentNode.dispatchEvent(event);
assert.ok(!component.clicked);
await button.dispatchEvent(event);
assert.ok(component.clicked);
}
};

@ -0,0 +1,11 @@
<script>
export let clicked;
function handle_click(event) {
clicked = true;
}
</script>
<div on:click|nonself={handle_click}>
<button>click me</button>
</div>

@ -7,10 +7,12 @@ export default {
async test({ assert, component, target, window }) { async test({ assert, component, target, window }) {
const button = target.querySelector('button'); const button = target.querySelector('button');
const event = new window.MouseEvent('click'); const event = new window.MouseEvent('click', { bubbles: true });
await button.dispatchEvent(event); await button.dispatchEvent(event);
assert.ok(!component.inner_clicked); assert.ok(!component.inner_clicked);
await button.parentNode.dispatchEvent(event);
assert.ok(component.inner_clicked);
} }
}; };

@ -5,6 +5,7 @@
<button on:click={() => {}} /> <button on:click={() => {}} />
<h1 contenteditable="true" on:keydown={() => {}}>Heading</h1> <h1 contenteditable="true" on:keydown={() => {}}>Heading</h1>
<h1>Heading</h1> <h1>Heading</h1>
<div role="paragraph" on:mouseup|nonself={() => {}}><slot name="interactive-element-goes-here"></slot></div>
<!-- INVALID --> <!-- INVALID -->
<div role="listitem" on:mousedown={() => {}} /> <div role="listitem" on:mousedown={() => {}} />

@ -3,60 +3,60 @@
"code": "a11y-no-noninteractive-element-interactions", "code": "a11y-no-noninteractive-element-interactions",
"end": { "end": {
"column": 47, "column": 47,
"line": 10 "line": 11
}, },
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.", "message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
"start": { "start": {
"column": 0, "column": 0,
"line": 10 "line": 11
} }
}, },
{ {
"code": "a11y-no-noninteractive-element-interactions", "code": "a11y-no-noninteractive-element-interactions",
"end": { "end": {
"column": 58, "column": 58,
"line": 11 "line": 12
}, },
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.", "message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
"start": { "start": {
"column": 0, "column": 0,
"line": 11 "line": 12
} }
}, },
{ {
"code": "a11y-no-noninteractive-element-interactions", "code": "a11y-no-noninteractive-element-interactions",
"end": { "end": {
"column": 50, "column": 50,
"line": 12 "line": 13
}, },
"message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.", "message": "A11y: Non-interactive element <h1> should not be assigned mouse or keyboard event listeners.",
"start": { "start": {
"column": 0, "column": 0,
"line": 12 "line": 13
} }
}, },
{ {
"code": "a11y-no-noninteractive-element-interactions", "code": "a11y-no-noninteractive-element-interactions",
"end": { "end": {
"column": 28, "column": 28,
"line": 13 "line": 14
}, },
"message": "A11y: Non-interactive element <p> should not be assigned mouse or keyboard event listeners.", "message": "A11y: Non-interactive element <p> should not be assigned mouse or keyboard event listeners.",
"start": { "start": {
"column": 0, "column": 0,
"line": 13 "line": 14
} }
}, },
{ {
"code": "a11y-no-noninteractive-element-interactions", "code": "a11y-no-noninteractive-element-interactions",
"end": { "end": {
"column": 46, "column": 46,
"line": 14 "line": 15
}, },
"message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.", "message": "A11y: Non-interactive element <div> should not be assigned mouse or keyboard event listeners.",
"start": { "start": {
"column": 0, "column": 0,
"line": 14 "line": 15
} }
} }
] ]

@ -14,3 +14,4 @@
<div role='article' tabindex='0' /> <div role='article' tabindex='0' />
<article tabindex='0' /> <article tabindex='0' />
<article tabindex='{0}' /> <article tabindex='{0}' />
<div tabindex='0' on:click|nonself={() => {}}><slot name="interactive-element-goes-here"></slot></div>

@ -46,5 +46,17 @@
"column": 0, "column": 0,
"line": 16 "line": 16
} }
},
{
"code": "a11y-no-noninteractive-tabindex",
"end": {
"column": 102,
"line": 17
},
"message": "A11y: noninteractive element cannot have nonnegative tabIndex value",
"start": {
"column": 0,
"line": 17
}
} }
] ]

@ -12,6 +12,7 @@
<div role={dynamicRole} on:click={() => {}} /> <div role={dynamicRole} on:click={() => {}} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<footer on:keydown={() => {}} /> <footer on:keydown={() => {}} />
<div on:click|nonself={() => {}}><slot name="interactive-element-goes-here"></slot></div>
<!-- invalid --> <!-- invalid -->
<div on:keydown={() => {}} /> <div on:keydown={() => {}} />

@ -3,24 +3,24 @@
"code": "a11y-no-static-element-interactions", "code": "a11y-no-static-element-interactions",
"end": { "end": {
"column": 29, "column": 29,
"line": 17 "line": 18
}, },
"message": "A11y: <div> with keydown handler must have an ARIA role", "message": "A11y: <div> with keydown handler must have an ARIA role",
"start": { "start": {
"column": 0, "column": 0,
"line": 17 "line": 18
} }
}, },
{ {
"code": "a11y-no-static-element-interactions", "code": "a11y-no-static-element-interactions",
"end": { "end": {
"column": 76, "column": 76,
"line": 19 "line": 20
}, },
"message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role", "message": "A11y: <a> with mousedown, mouseup handlers must have an ARIA role",
"start": { "start": {
"column": 0, "column": 0,
"line": 19 "line": 20
} }
} }
] ]

@ -0,0 +1,14 @@
[
{
"message": "The 'self' and 'nonself' modifiers cannot be used together",
"code": "invalid-event-modifier",
"start": {
"line": 1,
"column": 5
},
"end": {
"line": 1,
"column": 45
}
}
]

@ -0,0 +1,3 @@
<div on:touchstart|nonself|self={handleWheel}>
oops
</div>

@ -1,6 +1,6 @@
[ [
{ {
"message": "Valid event modifiers are preventDefault, stopPropagation, stopImmediatePropagation, capture, once, passive, nonpassive, self or trusted", "message": "Valid event modifiers are preventDefault, stopPropagation, stopImmediatePropagation, capture, once, passive, nonpassive, self, nonself or trusted",
"code": "invalid-event-modifier", "code": "invalid-event-modifier",
"start": { "start": {
"line": 1, "line": 1,

Loading…
Cancel
Save