svelte icon indicating copy to clipboard operation
svelte copied to clipboard

on:* attribute to delegate all events

Open btakita opened this issue 6 years ago • 31 comments

An on: attribute, or something similar, would make it convenient to delegate all events from a component or html tag,

// All events on a are delegated through the component.
<a on:>My Link</a>

btakita avatar May 21 '19 20:05 btakita

on:* maybe?

ekhaled avatar May 23 '19 00:05 ekhaled

Anything that involves listening to all events on a DOM element isn't practical. There was a conversation in Discord between @mindrones and me about this on December 6, 2018, that you can read for more information. Some relevant quotes:

The best I thing I can find with a quick google for binding to all DOM events on an element is to iterate through all the methods on the element whose names begin with on which isn't too reliable

I don't see how to implement this feature for DOM events without an awful unreliable hack that I don't think Svelte wants to be responsible for

People can of course do this in their own code if they want, but I really don't think it should be an official feature

Conduitry avatar May 24 '19 15:05 Conduitry

Yesterday on Discord, Tor brought up the idea of exposing an "event target" for a component. If you tried to add a listener on a component, it'd be delegated to that target instead.

I think this would be a fine syntax for that: instead of preemptively adding listeners to everything, each components' $on could either be the default or just a proxy to a child component's $on or child dom node's addEventListener. This could work, no?

mrkishi avatar May 24 '19 15:05 mrkishi

Oh that's interesting. So we wouldn't be forwarding all of them, just the ones that consumers of the component are trying to listen to. I don't see any technical DOM limitations getting in the way of that. There do still seem to be some questions though: Which events would attaching a handler attempt to proxy? all of them? Can we do this in a way that doesn't impact people not using it? Reopening to discuss this.

Conduitry avatar May 24 '19 15:05 Conduitry

just to add my 2 cents here... In svelte 2 we could monkey-patch Component.prototype.fire to emulate this. This allowed us to have a near-enough implementation of Higher Order Components.

Since the internals have changed in svelte 3... the only way we can have some semblance of HoCs.. is if we have compiler support.

ekhaled avatar May 24 '19 15:05 ekhaled

Monkey patching Component.prototype.fire only helped with listening to all events on a component, not on a DOM element.

There are a few different things going on here:

  • <element on:*> - this is the event target thing that @mrkishi mentioned. There are some details to iron out but it seems possible.
  • <element on:*={handler}> - this is not possible to implement in a sane way
  • <Component on:*> - probably possible, using a similar event target thing, but for component events
  • <Component on:*={handler}> - probably also possible. The interface would actually be simpler than it was in v2, since we are now using CustomEvents which have a type field, and we don't need to pass the type and the event payload separately

Conduitry avatar May 24 '19 15:05 Conduitry

I just realized that <Component on:*={handler}> where the component contains <element on:*> would have to have caveats about not being able to report proxied DOM events from that element. So I'm withdrawing my support for the handler syntax. I think just allowing the forwarding on:* on DOM elements and components makes sense.

Conduitry avatar May 24 '19 20:05 Conduitry

If anyone needs this functionality, I've made a primitive approach to forward all standard UI events, plus any others you specify:

https://github.com/hperrin/svelte-material-ui/blob/273ded17c978ece3dd87f32a58dd9839e5c61325/components/forwardEvents.js

You can see how it's used in the Button component:

https://github.com/hperrin/svelte-material-ui/blob/273ded17c978ece3dd87f32a58dd9839e5c61325/components/button/index.svelte#L3

This is effectively the same as using a bunch of on:event directives with no handlers, but it will create less code. However, it uses internal Svelte features that may change without warning in the future.

Edit: Updated on 2023-03-05 here: https://github.com/sveltejs/svelte/issues/2837#issuecomment-1455114529

hperrin avatar Jul 29 '19 19:07 hperrin

Here is how I think it should get implemented,

  • The compiler will be able to scope on:* with it's component when it has been implemented, so it shouldn't be a big change.
  • EventHandler gets compiled to run-time listen(...). This function can be changed to a subscribe/unsubscribe pattern, that maps the node and it's modifiers in some kind of data structures (if possible use fragment scope ids as keys).
  • Every time there is a new event it gets bound to the document with some new run time function as handler that will delegate the event to the appropriate callbacks and it's modifiers are then applied.
  • The listen(...) will return an unsubscribe that will be called when the fragment is destroyed, it should be fine to remove an event if there are no handlers left for that event.
  • I think this presents better opportunities to implement a bubbling mechanism as well
  • Reduces number of attaching/de-attaching listeners, seems more likely to improve performance

Few more advantages, https://gomakethings.com/why-event-delegation-is-a-better-way-to-listen-for-events-in-vanilla-js/

deviprsd avatar Jul 29 '19 22:07 deviprsd

@Conduitry Perhaps this was already discussed somewhere, but I haven't seen it, so, here is how I handle event forwarding in VueJs. I believe this could be adapted to Svelte.

I am omitting some template boilerplate for brevity.

Parent.vue

<child @click="clickHandler" @input="inputHandler" @whatever="whateverHandler" />

Child.vue

<input v-on="$listeners" />

The above v-on="$listeners" code binds the in the child component to any events that the parent component is interested in, not all events. $listeners is passed into the child from the parent, which knows what events are interesting to it.

I don't know if passing an array of event listeners down to the child from the parent can be implemented in Svelte 3, but if it could be, then we could use a similar syntax, for instance:

Parent.svelte

<Child 
  on:click="clickHandler" 
  on:input="inputHandler" 
  on:whatever="whateverHandler" 
/>

Child.svelte

<input on:$listeners />

Or the more familiar destructuring

<input { ...$listeners } />

pbastowski avatar Aug 19 '19 20:08 pbastowski

If anyone needs this functionality, I've made a primitive approach to forward all standard UI events, plus any others you specify:

https://github.com/hperrin/svelte-material-ui/blob/273ded17c978ece3dd87f32a58dd9839e5c61325/components/forwardEvents.js

You can see how it's used in the Button component:

https://github.com/hperrin/svelte-material-ui/blob/273ded17c978ece3dd87f32a58dd9839e5c61325/components/button/index.svelte#L3

This is effectively the same as using a bunch of on:event directives with no handlers, but it will create less code. However, it uses internal Svelte features that may change without warning in the future.

I get error when i try to use this on components, says that I'm supposed to use it only on DOM elements.

jerriclynsjohn avatar Sep 26 '19 16:09 jerriclynsjohn

@jerriclynsjohn How are you trying to use it?

hperrin avatar Nov 08 '19 19:11 hperrin

The on:* attribute would be a great addition for tools like Storybook.

Indeed, to add some context on our component stories, we have to build some view components. Here is a use case with a view component adding a .container around the tested one:

The story:

import { action } from '@storybook/addon-actions';
import { decorators } from '../stories';
import Container from '../views/Container.svelte';
import MyComponent from './MyComponent.svelte';

export default {
  title: 'MyComponent',
  component: MyComponent,
  decorators,
};

export const Default = () => {
  return {
    Component: Container,
    props: {
      component: MyComponent,
      props: {},
    },
    on: {
      // Here we listen on validate event to show it on the Storybook dashboard.
      validate: action('validate'),
    },
  };
};

The tested component:

<script>
  import { createEventDispatcher } from 'svelte';
  const validate = () => {
    dispatch('validate');
  }
</script>

<Button on:click={validate}>
  Validate
</Button>

The container view:

<script>
  import { boolean } from '@storybook/addon-knobs';

  export let component;
  export let props;
  export let contained = boolean('container', true, 'layout');
</script>

<div class="{contained ? 'container' : ''}">
  <svelte:component this={component} {...props} on:* />
</div>

The on:* here would forward the event triggered by the child component.

Currently, I don't have any workaround to make event listening working on storybook with a view wrapper.

Do you have any state about this issue?

soullivaneuh avatar Jan 23 '20 20:01 soullivaneuh

@hperrin I tried your workaround. It works very well for native browser events, but nor for custom events.

I directly downloaded your forwardEventsBuilder function and used it on this Container.svelte file:

<script>
  import { boolean } from '@storybook/addon-knobs';
  import {current_component} from 'svelte/internal';
  import { forwardEventsBuilder } from '../forwardEvents';

  const forwardEvents = forwardEventsBuilder(current_component, [
    // Custom event addition.
    'validate',
  ]);

  export let component;
  export let props;
  export let contained = boolean('container', true, 'layout');
</script>

<div class="{contained ? 'container' : ''}" use:forwardEvents>
  <svelte:component this={component} {...props} />
</div>

As you can see, I put the validate event on the builder events list argument.

Here is a simple test button component with a custom event dispatch:

<script>
  import { createEventDispatcher } from 'svelte';

  const dispatch = createEventDispatcher();
</script>

<button on:click={() => dispatch('validate') }>TEST</button>

And the related Storybook story:

import { action } from '@storybook/addon-actions';
import {
  text, boolean, select,
} from '@storybook/addon-knobs';
import Container from '../views/Container.svelte';
import Button from './Button.svelte';
import ButtonView from '../views/ButtonView.svelte';

export default {
  title: 'Button',
  component: Button,
  parameters: {
    notes: 'This is a button. You can click on it.',
  },
};

export const Default = () => ({
  // Container view usage.
  Component: Container,
  props: {
    // selve:component prop
    component: ButtonView,
    props: {},
  },
  on: {
    // Show the validate event trigger on the story board.
    validate: action('validate'),
  },
});

This is not working, but works with a native event like click.

Any idea of what I'm missing? :thinking:

soullivaneuh avatar Jan 25 '20 15:01 soullivaneuh

If all on:... handlers were exposed on the component in a similar way to $$props, it would be very easy to implement event forwarding in client code by iterating over them. E.g. if there were an $$on object, the forwarding could be encapsulated in a use function, e.g.:

<button use:eventForwarding={$$on}>
    <slot/>
</button>

I wrote a little workaround demo, putting the event handlers on the $$props with prefix on-. Unfortunately this of course breaks the conventions and requires the component consumer to know about this mechanism.

brunnerh avatar Feb 24 '20 23:02 brunnerh

Looks like RedHatter is doing a great job to provide this functionality but it hasn't quite made it into mainline before a conflicting change appears. Is there an alternative method now exposed for this?

matt-psaltis avatar Aug 25 '20 06:08 matt-psaltis

Anything that involves listening to all events on a DOM element isn't practical. There was a conversation in Discord between @mindrones and me about this on December 6, 2018, that you can read for more information. Some relevant quotes:

The best I thing I can find with a quick google for binding to all DOM events on an element is to iterate through all the methods on the element whose names begin with on which isn't too reliable

Hello, just quickly my idea: What about not trying to listen to all events of the element but just those that "of interest" from parent point of view.. Like this:

Component "bubbling" all events up

TextInput.svelte
<script>...</script>

<div class="field">
  <input
    on={$$listeners}                 // or this syntactic sugar mentioned earlier: on:*
  >
</div>

Parent component that uses child component isnt interested in all events

MyForm.svelte
<div class="form">
  <TextInput
    on:focus={onFieldFocus}
    on:blur={onFieldBlur}
  />
</div>

So the Svelte could take advantage of that and just try to listen to these events that have been specified in parents.. I can imagine this could generate some overhead in runtime.. or make compiling harder as it would need to track "usages" of the child component and what events are subscribed to by who. Or it could be taken as a compile-time syntactic sugar that would say something like: "I dont know what events will my parents want to listen to, so "provide" all of this element/component's events to parent.

I have no idea how Svelte works internally.. but I thought it might give insight on how to optimize the code if possible..

Have a nice day.

FilipJakab avatar Jan 06 '21 16:01 FilipJakab

As a workaround that can be used today in vanilla js, there is an option with controlling events from parent component and twenty-line action used in child component on the target tag(or multiple tags).

REPL

It gives necessary access to child component while maintaining full isolation. Child component manages independently - where and how to listen to what events

Note: Be sure NOT to use an inline function in events object because action WON'T remove listener to this event

MarkTanashchuk avatar Jan 20 '21 09:01 MarkTanashchuk

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Jun 26 '21 21:06 stale[bot]

Imagine you have a component List - used to display some list of components using fancy styling, filters, paging etc. Then you have some different components to be displayed via list: UserRow, BookRow, AddressRow etc.

<List template={UserRow} items={users}/>
<List template={AddressRow} items={addresses}/>

Then you need to perform different operations for items in the list, let's say, for UserRow it's activate/deactivate, for AddressRow it's show on the map etc. And you need to display result of these operations on the same screen where your List component is located. For that you need to pass event through the List component.

<UserStatsComponent stats={stats}/>
<List template=UserRow items={users} on:activate={updateActiveUsers} on:deactivate={updateInactiveUsers}/>

or

<MapComponent address={address}/>
<List template=AddressRow items={addresses} on:show={showOnMap}/>

In order to make it work you need to do something like this in your List component:

{#each items as item}
    <div class="row">
        <!--TODO: replace with on:* once it's finally supported-->
        <svelte:component this={template} item={item} on:activate on:deactivate on:show/>
    </div>
{/each}

Now imagine you have about 20 different components with different behavior to be displayed using List component. Like I have in one of my project's admin panel. This is not only completely inadequate but also doesn't comply with encapsulation principles: List component must not know anything about it's items methods or events.

sourcecaster avatar Jul 13 '21 19:07 sourcecaster

Hi, any update on this feature ? For people building component library it's a must have.

fabien-ml avatar Sep 16 '21 06:09 fabien-ml

Im also wanting this...it would seem sensible to allow a spread operator, to provide bindings: <svelte:component {...props} {...bindings}>

ghost avatar Sep 26 '21 15:09 ghost

IMO, here is one possible solution:

<button on:{...$$actions}>
  <slot />
</button>

So that we can do some kind of filtering on event bindings. We can also do the same with class and other bindings.

<button class:{...classNames}>
  <slot />
</button>

clitetailor avatar Dec 11 '21 16:12 clitetailor

IMO, here is one possible solution:

<button on:{...$$actions}>
  <slot />
</button>

So that we can do some kind of filtering on event bindings. We can also do the same with class and other bindings.

<button class:{...classNames}>
  <slot />
</button>

No, it's not. The whole point is to pass all events even those your component doesn't know about. Imagine that your component is a 3rd party library. And someone wants to use it but he uses his own events.

sourcecaster avatar Dec 13 '21 07:12 sourcecaster

@sourcecaster I'm not sure what you saying about but i think you missing. Parent component do know about all of its event listeners passed down to its child. And event handlers on child item should be registered based on event listeners passed via parent component parameters as many others has point out before. For example:

List.svelte

<script>
  export let template

  const listeners = {}
  for (const [event, listener] of Object.entries($$listeners)) {
    listeners[event] = ($event) => {
        // Console log on event handling.
        console.log(`${event}:`, $event);

        listener($event);
    }
  }
</script>

<svelte:component this={template}  on:{...listeners}>

App.svelte

<List template={User} on:click={...} />
<List template={Address} on:change={...} />

Or even in some fancier example:

RegisteredEvents

<ul>
{#each Object.entries($$listeners) as [event, listener]}
  <li>{event}</li>
{/each}
</ul>

People can also filter on event listeners if needed:

Hello.svelte

<script>
	import { createEventDispatcher } from 'svelte';

	const dispatch = createEventDispatcher();

	function sayHello() {
		dispatch('message', {
			text: 'Hello!'
		});
	}

        const listeners = Object.keys($$listeners)
                .filter(event => event !== "message" && event != "click")
                .reduce((acc, event) => ({ ...acc, [event]: $$listeners[event] }), {})
</script>

<element on:click={sayHello} on:{...listeners} />

The only thing i'm confused is about one case that @Conduitry said. In which case does people need the following feature because it's trivial:

<element on:*={handler} />

Another question is why does @RedHatter PR has not been merged. Is there any problems with that merge request and why haven't anyone resolve the merge conflicts yet? 🤔

clitetailor avatar Dec 13 '21 18:12 clitetailor

Since this hasn't been linked here yet, I might as well bring up this RFC of mine which proposes explicit syntax for this type of thing.

tropicaaal avatar Jan 13 '22 17:01 tropicaaal

any update ?

abdalmonem avatar Jun 19 '22 17:06 abdalmonem

any update ?

Seems #6876 is something that's out for discussion as of now. No idea what's the status though.

ptrxyz avatar Aug 02 '22 02:08 ptrxyz

https://github.com/hperrin/svelte-material-ui/blob/273ded17c978ece3dd87f32a58dd9839e5c61325/components/forwardEvents.js

Also, to any one trying to migrate a component ui library to svelte.

fredguth avatar Aug 15 '22 13:08 fredguth

Hi guys. I want to share my variation of this solution:

Helper code
import {SvelteComponent, bubble, listen, current_component} from 'svelte/internal';
import {onMount, onDestroy} from 'svelte';

export function useForwardEvents<T extends SvelteComponent | Element>(getRef: () => T, additionalEvents: string[] = []) {
  const events = [
    'focus', 'blur',
    'fullscreenchange', 'fullscreenerror', 'scroll',
    'cut', 'copy', 'paste',
    'keydown', 'keypress', 'keyup',
    'auxclick', 'click', 'contextmenu', 'dblclick', 'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseover', 'mouseout', 'mouseup', 'pointerlockchange', 'pointerlockerror', 'select', 'wheel',
    'drag', 'dragend', 'dragenter', 'dragstart', 'dragleave', 'dragover', 'drop',
    'touchcancel', 'touchend', 'touchmove', 'touchstart',
    'pointerover', 'pointerenter', 'pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'pointerout', 'pointerleave', 'gotpointercapture', 'lostpointercapture',
    ...additionalEvents
  ];
  const component = current_component;
  const destructors: (() => void)[] = [];

  function forward(e) {
    bubble(component, e);
  }

  onMount(() => {
    const ref = getRef();

    if (ref instanceof Element) {
      events.forEach((event) => {
        destructors.push(listen(ref, event, forward));
      });
    } else {
      events.forEach((event) => {
        destructors.push(ref.$on(event, forward));
      });
    }
  });

  onDestroy(() => {
    while (destructors.length) {
      destructors.pop()();
    }
  });
}
Usage
<script>
  let baseRef: Base;
  useForwardEvents(() => baseRef);
</script>

<Base bind:this={baseRef} {...$$props}  />

Will work with DOM nodes and with component instances as well.

dangreen avatar Aug 16 '22 12:08 dangreen