solidjs-community / solid-primitives

A library of high-quality primitives that extend SolidJS reactivity.
https://primitives.solidjs.community
MIT License
1.23k stars 121 forks source link

add `createEventDispatcher` primitive #186

Closed AyloSrd closed 2 years ago

AyloSrd commented 2 years ago

A primitive that is the equivalent of Svelte's createEventDispatcher for Solid, enabling components to dispatch custom events.

Use

createEventDispatcher would take the component props as argument, and return an event dispatcher function.

const dispatch = createEventDispatcher(props)

A component can emit events by creating an event dispatcher and calling it. The event dispatcher takes three arguments, the event name (mandatory), the optional payload, and optional dispatcher options.

<button onClick={() => dispatch('answer', 42}>Give Answer</button>

The parent component would listen to the event as it would listen to any other event on a DOM element, by passing a on + capitalized name of the event prop containing the event handler. The payload will be carried in CustomEvent.detail

<ChildComponent onAnswer={evt => console.log(evt.detail)} /> // will log 42

The third parameter is an object that one property, cancelable, which determines whether the created custom event is cancelable, and defaults to { cancelable: false }. To parallel DOM's dispatchEvent, dispatch will return false, if the custom event is cancelable and preventDefault() has been called, true in all the other cases (even if there is no event handler associated to the event).

TypeScript

The developers should have access to the same suggestions and error signalling as if they were directly accessing the event handlers from the props. dispatch should suggest all the available events, suggest the payload type once the event is chosen, and consider whether a payload is optional or not (and thus signal the error if a mandatory payload is not passed). Some exemples :

interface Props {
  onStringEvent: (evt: CustomEvent<string>) => void,
  onNumberEvent: (evt: CustomEvent<number>) => void,
  onMandatoryPayload: (evt: CustomEvent<number>) => void,
  onOptionalPayload: (evt?: CustomEvent<string>) => void,
}

dispatch() // will suggest ("stringEvent", "numberEvent", "mandatoryPayload", "optionalPayload")
dispatch('stringEvent', ) // will suggest "(eventName: "changeStep", payload: string, dispatcherOptions?: DispatcherOptions | undefined) => boolean"
dispatch('numberEvent', 'forty-two') // will throw "Error: Argument of type 'string' is not assignable to parameter of type 'number'."
dispatch('mandatoryPayload') // will throw: "Error: Expected 2-3 arguments, but got 1."
dispatch('optionalPayload') // will not complain, but suggest "(eventName: "optionalPayload", payload?: number | undefined, ...

I have created a draft npm package that does it, to test if it was possible, and here an mock app that uses it, to see how it works :

thetarnav commented 2 years ago

Hey! Thanks for the proposal! Could you please explain the advantages of this primitive over just calling a function prop? I can't find a reason for this here nor in the svelte docs. I could see a value if the events were dispatched on an element and bubble, so they could be captured higher in the tree. But they aren't, which confuses me, because I usually align custom events with DOM elements. Having an option to dispatch it on an element would potentially be a nice feature. The cancelable prop is intriguing though.. What's the pattern for manually dispatching/cascading the events up the tree? Then the defaultPrevented param will be useful there. Or maybe Context API could be used to simulate cascading without elements, hmm... 🤔 (we are discussing the primitive in the #solid-primitives channel on discord if you wanna join)

AyloSrd commented 2 years ago

Hey, thank you for the quick reply and the invitation to discuss it on discord! I have joined the channel and addressed some points there!

AyloSrd commented 2 years ago

Hi @thetarnav, I was thinking a lot of you point of dispatching/and cascading events on the tree, and I was wondering if adding the possibility to trigger the event from the parent (instead of just passing an event handler and triggering the event from the child), would be useful?

Since in the DOM we dispatch events on an element and not from within it, for the primitive to be consistent with the DOM equivalent, it should enable this:

// DOM dispatchEvent
element.dispatchEvent(new CustomEvent('testEvent', { detail: 42, cancelable: true })
// The primitive  (used in the Child component) should enable this beahvior from its parent
Component.dispatch('testEvent', 42, { cancelable: true })

I would keep the current dispatch function (maybe rename it "emit", like in vue) and change a bit the primitive to look something like this from within inside the child component:

// in the child
const [emit /*the old "dispatch"*/, dispatchable] = createComponentEvent(props)
// dispatchable should return the handler, so that it can be used alsoby the child compoment
const handleAnswer = dispatchable(evt => console.log(evt.detail + 42), 'answerEvent')
// from the parent
Child.dispatch('answerEvent', 'The answer is ') // will cause the child to log 'The answer is 42'

To me it could come in handy, as today the only way I can think of for triggering an event on children components is to pass a signal prop and observe it via CreateEffect like so (e.g. I want to trigger the focus of a custom input):

// from the parent component
const [triggerFocus, setTriggerFocus] = createSignal(0)
return (
    <button onClick={() => setTriggerFocus(triggerFocus + 1)}>focus</button>
    <CustomInput triggerFocus={triggerFocus()} />
)
// in the child
createEffect(() => {
    inputRef.focus()
}, props.triggerFocus)

Does it make sense to you ? Do you think it would be a useful enhancement or is the idea too farfetched ?

atk commented 2 years ago

I just had an idea: we could add an eventMapper directive that received an objects with event names and a string or array of strings with the name(s) of custom events it should be mapped to. It would look like this:

<button use:eventMapper={{click: 'subscribe'}}>

Clicking the button then dispatches the custom subscribe event. Do you think this could be a helpful addition?

thetarnav commented 2 years ago

@AyloSrd I would avoid any attempt to "instanciate" the components—component is just a function returning HTML Elements, so this syntax doesn't sit well with me:

Child.dispatch("event")

Not sure how would that work too Instead of focusing on components, we should be focusing on the primitives. And having a way to send events from parent to child is a good problem to solve, as the child -> parent is already covered by the createEventDispatcher. The current way to do this is this:

const Parent: Component = props => {
   let handleToggle!: (state: boolean) => void

   onMount(() => {
      toggleListener(true)
   })

   return <Child handleToggle={listener => (handleToggle = listener)} />
}

const Child: Component<{ handleToggle: (listener: (state: boolean) => void) => void }> = props => {
   props.handleToggle(state => {
      console.log("new state:", state)
   })
   return <></>
}

When using the event-bus package it could be this:

import { createSimpleEmitter, GenericListen } from "@solid-primitives/event-bus";

const Parent: Component = props => {
   const [listen, emit] = createSimpleEmitter<boolean>();

   onMount(() => {
      emit(true)
   })

   return <>
      <Child handleToggle={listen} />
      <Child handleToggle={listen} />
      <Child handleToggle={listen} />
   </>
}

const Child: Component<{ handleToggle: GenericListen<[boolean]> }> = props => {
   props.handleToggle(state => {
      console.log("new state:", state)
   })
   return <></>
}

So I think that primitive for the parent -> child communication should follow a similar API design, but just use Custom Events as a payload.

AyloSrd commented 2 years ago

@atk I think it would be very useful! How would you pass the payload though?

@thetarnav Yep i agree, I shpuld'nt try to instanciate. Thx for the exemples, I think of a similar way then