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
- `once` — remove the handler after the first time it runs
- `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.
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))
- `once` — remove the handler after the first time it runs
- `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.
You can chain modifiers together, e.g. `on:click|once|capture={...}`.

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

@ -50,6 +50,7 @@ export default class EventHandlerWrapper {
if (this.node.modifiers.has('stopImmediatePropagation'))
snippet = x`@stop_immediate_propagation(${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})`;
const args = [];
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} */
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 }) {
const button = target.querySelector('button');
const event = new window.MouseEvent('click');
const event = new window.MouseEvent('click', { bubbles: true });
await button.dispatchEvent(event);
assert.ok(!component.inner_clicked);
await button.parentNode.dispatchEvent(event);
assert.ok(component.inner_clicked);
}
};

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

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

@ -14,3 +14,4 @@
<div role='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,
"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={() => {}} />
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<footer on:keydown={() => {}} />
<div on:click|nonself={() => {}}><slot name="interactive-element-goes-here"></slot></div>
<!-- invalid -->
<div on:keydown={() => {}} />

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

Loading…
Cancel
Save