diff --git a/.changeset/tough-kids-develop.md b/.changeset/tough-kids-develop.md new file mode 100644 index 0000000000..220b00960d --- /dev/null +++ b/.changeset/tough-kids-develop.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: add `|nonself` event modifier for non-interactive elements with event listeners diff --git a/documentation/docs/02-template-syntax/05-element-directives.md b/documentation/docs/02-template-syntax/05-element-directives.md index 9df73c8c07..e23b32b292 100644 --- a/documentation/docs/02-template-syntax/05-element-directives.md +++ b/documentation/docs/02-template-syntax/05-element-directives.md @@ -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={...}`. diff --git a/documentation/tutorial/05-events/03-event-modifiers/text.md b/documentation/tutorial/05-events/03-event-modifiers/text.md index eea563fec2..64972301c2 100644 --- a/documentation/tutorial/05-events/03-event-modifiers/text.md +++ b/documentation/tutorial/05-events/03-event-modifiers/text.md @@ -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={...}`. diff --git a/packages/svelte/src/compiler/compile/internal_exports.js b/packages/svelte/src/compiler/compile/internal_exports.js index 7656f52f3a..c78f7a3397 100644 --- a/packages/svelte/src/compiler/compile/internal_exports.js +++ b/packages/svelte/src/compiler/compile/internal_exports.js @@ -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"]); \ No newline at end of file +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"]); \ No newline at end of file diff --git a/packages/svelte/src/compiler/compile/nodes/Element.js b/packages/svelte/src/compiler/compile/nodes/Element.js index 6715eb1e5d..3e31ff3305 100644 --- a/packages/svelte/src/compiler/compile/nodes/Element.js +++ b/packages/svelte/src/compiler/compile/nodes/Element.js @@ -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} */ const attribute_map = new Map(); + /** @type {Map} */ 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( diff --git a/packages/svelte/src/compiler/compile/render_dom/wrappers/Element/EventHandler.js b/packages/svelte/src/compiler/compile/render_dom/wrappers/Element/EventHandler.js index 65f6e81ab2..f62608ab5f 100644 --- a/packages/svelte/src/compiler/compile/render_dom/wrappers/Element/EventHandler.js +++ b/packages/svelte/src/compiler/compile/render_dom/wrappers/Element/EventHandler.js @@ -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) => diff --git a/packages/svelte/src/runtime/internal/dom.js b/packages/svelte/src/runtime/internal/dom.js index 18a5d5d278..9c21cc3dbc 100644 --- a/packages/svelte/src/runtime/internal/dom.js +++ b/packages/svelte/src/runtime/internal/dom.js @@ -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) { diff --git a/packages/svelte/test/runtime/samples/event-handler-dynamic-modifier-nonself/_config.js b/packages/svelte/test/runtime/samples/event-handler-dynamic-modifier-nonself/_config.js new file mode 100644 index 0000000000..5a75c91fb2 --- /dev/null +++ b/packages/svelte/test/runtime/samples/event-handler-dynamic-modifier-nonself/_config.js @@ -0,0 +1,18 @@ +export default { + html: ` +
+ +
+ `, + + 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); + } +}; diff --git a/packages/svelte/test/runtime/samples/event-handler-dynamic-modifier-nonself/main.svelte b/packages/svelte/test/runtime/samples/event-handler-dynamic-modifier-nonself/main.svelte new file mode 100644 index 0000000000..92828b665c --- /dev/null +++ b/packages/svelte/test/runtime/samples/event-handler-dynamic-modifier-nonself/main.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/packages/svelte/test/runtime/samples/event-handler-modifier-nonself/_config.js b/packages/svelte/test/runtime/samples/event-handler-modifier-nonself/_config.js new file mode 100644 index 0000000000..5a75c91fb2 --- /dev/null +++ b/packages/svelte/test/runtime/samples/event-handler-modifier-nonself/_config.js @@ -0,0 +1,18 @@ +export default { + html: ` +
+ +
+ `, + + 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); + } +}; diff --git a/packages/svelte/test/runtime/samples/event-handler-modifier-nonself/main.svelte b/packages/svelte/test/runtime/samples/event-handler-modifier-nonself/main.svelte new file mode 100644 index 0000000000..69f962e66e --- /dev/null +++ b/packages/svelte/test/runtime/samples/event-handler-modifier-nonself/main.svelte @@ -0,0 +1,11 @@ + + +
+ +
diff --git a/packages/svelte/test/runtime/samples/event-handler-modifier-self/_config.js b/packages/svelte/test/runtime/samples/event-handler-modifier-self/_config.js index 6bd96ba8aa..2d5e7d49c8 100644 --- a/packages/svelte/test/runtime/samples/event-handler-modifier-self/_config.js +++ b/packages/svelte/test/runtime/samples/event-handler-modifier-self/_config.js @@ -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); } }; diff --git a/packages/svelte/test/validator/samples/a11y-no-noninteractive-element-interactions/input.svelte b/packages/svelte/test/validator/samples/a11y-no-noninteractive-element-interactions/input.svelte index 91d0ddc382..11501e3ba6 100644 --- a/packages/svelte/test/validator/samples/a11y-no-noninteractive-element-interactions/input.svelte +++ b/packages/svelte/test/validator/samples/a11y-no-noninteractive-element-interactions/input.svelte @@ -5,6 +5,7 @@