Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Any way to access $dispatch and other magic properties in "function components" ? #143

Closed
Lelectrolux opened this issue Jan 27, 2020 · 25 comments

Comments

@Lelectrolux
Copy link

Basically, I'm doing a tabs "plugin" and need to use tab.scrollIntoView(), but I can't use it as is, as alpine still has the tab element hidden (x-show'd out).

I'm currently using a setTimeout but I feel it should/could be cleaner

<div x-data="{tabs: tabs('tab1')}">...</div>
function tabs(initialTab) {
  return {
    current: initialTab
    select(tab) {
      // tab is a dom element
      // I'm using id to progressively enhance from anchor links
      // and limit markup duplication
      this.current = tab.id
      // I want to do something like this
      $nextTick(() => tab.scrollIntoView({behavior: "smooth"}))
      // I currently do this, but it feels icky
      setTimeout(() => tab.scrollIntoView({behavior: "smooth"}), 1)
    },
    // ...
  }
}

I tried to pass the $dispatch to x-data (x-data="{tabs: tabs('tab1', $dispatch)}") but as I expected it doesn't work.

I wonder if at least some of the magic properties could be stuck on the Alpine object for this use case ?

@rzenkov
Copy link
Contributor

rzenkov commented Jan 27, 2020

Why are you using object scope with external function call? You can use <div x-data="tabs('inital')>...</div>, where tabs - function which returns scope. In this case you get access for this.$dispatch and this.$nextTick and other things.
docs

@Lelectrolux
Copy link
Author

Lelectrolux commented Jan 28, 2020

Why are you using object scope with external function call ?

Kind of namespacing to prevent potential collisions. I simplified this example by keeping only the tabs part, but my data object can look more like x-data="{tabs: tabs('tab1'), otherPlugin: otherPlugin(), foo: 'bar', ...}", and then I could use x-show="tabs.isShown('tab1')" or the likes. I didn't really think about it being a potential problem. Not my first error on "this" :-)

I'm happy to know there is an easy fix, thank you @rzenkov.

But I still wonder what to do in case of property collisions and/or code namespacing/organisation. I can imagine it being messy when you compose a lot of those "plugin functions".

I leave @calebporzio and the other maintainers to decide if this is a discussion they want to have. Feel free to close the issue if not.

@SimoTod
Copy link
Collaborator

SimoTod commented Jan 28, 2020

@Lelectrolux As rzenkov pointed out, mixing global and component scoped functions is not easy.

It's a massive simplification, but you can assume that you have access your scope variables and magic properties only when they show explicitly in the attribute string value of a x-* directive.
(Pay attention with x-data, though, that one is evaluated before the component finishes its set up phase so you can't really access other "scope" variables or magic properties from there).

The string is what is passed to the magic Alpine evaluator but the body of an external function will run on a different context so if you want to use a magic property there, you have to pass it in.

For example, you can have

        <div x-data="{tabs: tabs('tab1') }" >
            <button @click="tabs.select('tab1', $dispatch)">click me</button>
            <!-- Note, dispatch is passed into the function from here -->
        </div>
        <script>
            function tabs(initialTab) {
                return {
                    current: initialTab,
                    select(tab, $dispatch) {
                        console.log(tab, $dispatch)
                    },
                }
            }
        </script>

and it would work.

Long story short:

  • you can't access magic properties from the x-data context
  • once you are in an external function context, you won't have access to the component scope anymore, so you need to pass anything you need from the function call.

I hope it makes sense to you.

@Lelectrolux
Copy link
Author

@SimoTod Yeah, as soon as I read rzenkov answer I got what I did wrong. Painfully obvious once you see it, considering the way Alpinejs works under the hood.
I already refactored my "plugin" by dropping the "namespacing" I was doing and went on with my day.
Name clashing was a problem that probably won't happen anyway in my case, and it's not a problem future me and a wrapping div won't be able to solve, I guess.
But still I miss the readability it added (easy to differentiate what came from the plugin with the tabs prefix everywhere in the dom.

I actually already tried what you proposed and it worked, but having to pass manually the $dispatch each time to the function is a non-starter.

I still wonder if there is a smart api someone closer to the source could think about to deal with that.

But like I said, feel free to close this.

@rzenkov
Copy link
Contributor

rzenkov commented Jan 29, 2020

Hmm, minute ago i found that $dispatch accessible only from template expression. Not shure we need access this.$dispatch within "function scope()", But why not?
How about this?

// in Component constructor
// Binded to this.$el
unobservedData.$dispatch = this.getDispatchFunction(el);

One purpose for this.$dispatch binded to this.$el - inter component messaging, in other cases we don't need that, or need custom element.dispatchEvent(/* */). Even if we need message to other component with additional reaction in current component we can call $dispatch in template and add listener on current component. Therefore only one purpose for this.$dispatch - add some consistency.

@calebporzio
Copy link
Collaborator

yeah, I suppose it would make sense to be able to call this.$dispatch to dispatch from the root element from an external function.

Like I think someone else mentioned, there would be a name collision with the magic $dispatch that gets added to the template string.

If it's as simple as adding a default $dispatch to the constructor of the component class AND still having the magic one attached to template expressions, that'd be great.

If there are weird gotchas or it's any more complex, we maybe should just punt on it.

Thoughts?

@rzenkov
Copy link
Contributor

rzenkov commented Feb 4, 2020

Yes,it is not as simple as it seemed at first. unobservedData.$dispatch overlaps evaluated $dispatch.

This behavior leads to saferEvalNoReturn, where it calls new Function and with block, where vars defined in $data takes precedence over additional vars.

I don't see simple way to avoid this behavior. (excepting reassigning $dispatch in $data within each saferEvalNoReturn call, but only for those expressions, which uses $dispatch).

IMHO Such mess up is not worth the end result, but docs apparently should reflect this.

@ryangjchandler
Copy link
Contributor

Moving discussion here from the issue @SimoTod mentioned above.

@ryangjchandler
Copy link
Contributor

ryangjchandler commented Apr 8, 2020

Couple of options & thoughts here:

When using the $dispatch helper in an inline expression, the target is automatically assigned to the current target variable (specifically for event handlers such as x-on:click). In this case, when somebody wants to use the $dispatch in an external event handler, can we not pass it through alongside the $event variable with the target already set in the same way? Obviously, you could do this manually with @click="handler($event, $dispatch)" too. I think I'd opt for this if we're not too worried about having access to $dispatch at the parent / root level.

With regards to @calebporzio 's thoughts on adding a default $dispatch to the component data, I think that would be the smartest move. When the expression is evaluated inside of the component, i.e. a click handler or something similar, can the $dispatch not be overwritten with the one that gets added to the template string? I don't see there being a huge problem with this approach other than the overwriting of $dispatch causing the proxy handler to re-evaluate and re-render which if this is a problem, can be circumvented inside of the setter.

@SimoTod
Copy link
Collaborator

SimoTod commented Apr 8, 2020

It makes sense to me.
The problem with the second point was about avoiding that the generic dispatch would override the inline ones. At the moment, it would do because of the structure of the safeEval function.
We can avoid that but i think we need to use a neated 'with'.
They could be 2 different PRs anyway.

@ryangjchandler
Copy link
Contributor

Yeah. I'll throw in a PR to pass the $dispatch to event handler with the event target set by default, see what @calebporzio thinks. Then we can look at the 2nd option too.

@atomgiant
Copy link
Contributor

Thanks for looking into this @ryangjchandler.

Being new to Alpine I will say the $dispatch and $event not being available in the function component was not intuitive.

I updated this CodePen with examples of all the magic variables being accessed inside a function: https://codepen.io/atomgiant/pen/ExVYdXz

All of them appear to work fine except for $event and $dispatch. The $event one seems surprising so maybe I am missing something?

My expectation was $event would reference the current event and $dispatch would dispatch based on the current $event.target which I believe lines up nicely with @ryangjchandler proposal.

If the overriding of the $dispatch is too much of an issue I'm not sure this is possible but something like $el.dispatch or $event.dispatch (assuming I can access $event) would have been fine for my needs.

@ryangjchandler
Copy link
Contributor

@atomgiant The $event variable gets passed to your handler as the first parameter.

handler($event) {
    ...
}

Should work fine currently. You have to make sure that you're doing @click="handler" and not @click="handler()".

@SimoTod
Copy link
Collaborator

SimoTod commented Apr 8, 2020

We can maybe update the documentation to make clear what is available and when as well as adding the functionality Ryan is working on.

@ryangjchandler
Copy link
Contributor

We can maybe update the documentation to make clear what is available and when as well as adding the functionality Ryan is working on.

Yeah for sure. I'm wondering now whether the documentation is growing a little too big in the README, @calebporzio any plans for a dedicated Alpine site yet?

I'll be sure to update the README when I make my PR.

@atomgiant
Copy link
Contributor

Thanks @ryangjchandler. The @click=“handler” without parens was the missing link for me. I’d be happy to help with a PR for the doc Readme if you’re interested

@ryangjchandler
Copy link
Contributor

Yeah, feel free to make the PR. Im not sure whether it makes more sense to put it under the x-on section or under the $event section. Surprise us!

@Luddinus
Copy link

Luddinus commented Apr 18, 2020

I'm not sure if my comment fits here.

I "need" the $dispatch function in custom Modal.js file because this is what I want to achieve:

// what I want
<button @click="Modal.open('modal-1')">Show modal 1</button>

// what I'm doing
<button @click="$dispatch('modal:open modal-1')">Show modal 1</button>

<div id="modal-1" x-data={...}>
   Some modal here that listen to the event "modal:open modal-1"
</div>

As you see is not a big deal but it would be nice something global like Alpine.$dispatch maybe?

Anyway, awesome work caleb.

@SimoTod
Copy link
Collaborator

SimoTod commented Apr 18, 2020

If you are already in the real javascript part, you can trigger an event in the canonical way. $dispatch is just an utility helper for the html around a standard event dispatching.

const event = new CustomEvent('modal:open:modal-1');
document.dispatchEvent(event)
<button x-data @click="Modal.open('modal-1')">open</button>

<div id="modal-1" x-data="{...}" @modal:open:modal-1.document="...">
   ... 
</div>

@HugoDF
Copy link
Contributor

HugoDF commented Jun 4, 2020

Should we be closing this seeing as @atomgiant 's docs PR has gone in?

There are a few workarounds to access $dispatch in function components and it doesn't sound like we're going to implement this.$dispatch.

  • pass them into the handler from the template :click="submit($dispatch)" and then use the parameter in the JS function: submit($dispatch) { /* use $dispatch */ }
  • you can also trigger events that Alpine.js can listen to using Simone's snippet (CustomEvent + dispatchEvent): document.dispatchEvent(new CustomEvent('event-name'))

@timfee
Copy link

timfee commented Jun 30, 2020

loving alpine, FWIW, I'm having a similar modal issue... in order to have one modal object at the <body> level, I've implemented something similar to the below -- essentially nesting components:

Inside of the <body>, somewhere, lies:

            <a href="..." @click="$dispatch('modal-action', true)">...</a>

And the outer shell is below.

This may not be the right way to do this pattern entirely, but would also be nice to access the <body> element from within the <a> explicitly. I couldn't figure out if this was supported (or encouraged) via the docs.

2c :)

  <body
        class="{{ body_class }}"
        id="body"
        x-data="{ 'isDialogOpen': false }"
        @keydown.escape="isDialogOpen = false"
        x-on:modal-action="isDialogOpen = $event.detail">
        <div x-show="isDialogOpen" id="modal" class="modal">
            <div
                x-show="isDialogOpen"
                x-transition:enter="transition ease-out duration-100 transform"
                x-transition:enter-start="opacity-0 scale-95"
                x-transition:enter-end="opacity-100 scale-100"
                x-transition:leave="transition ease-in duration-75 transform"
                x-transition:leave-start="opacity-100 scale-100"
                x-transition:leave-end="opacity-0 scale-95"
                class="modal-backdrop">
                <div class=""></div>
            </div>
            <div
                class="modal-dialog"
                role="dialog"
                aria-modal="true"
                aria-labelledby="modal-headline"> 
                <!-- modal contents -->
            </div>
        </div>
        <!-- body contents -->
</body>

Thanks again -- this + Tailwind = lifesaver!

@HugoDF
Copy link
Contributor

HugoDF commented Jun 30, 2020

@timfee having the x-data on body isn't a great idea

In this case you can make the modal be the component (have x-data on the div) and listen to @modal-action.window

@booni3
Copy link

booni3 commented Jul 11, 2020

Is there a way to replicate the $dispatch without passing it into the function? I have tried the following:

// This works but can only be caught by a window scoped listener
window.dispatchEvent(new CustomEvent('my-event'))

// This does not bubble for some reason so I can only catch it with a listener on the root element
this.$el.dispatchEvent(new CustomEvent('my-event'))

// Again does not bubble
this.$refs.abc.dispatchEvent(new CustomEvent('my-event'))

EDIT
For anyone else looking, my limited knowledge of javascript and not checking the docs meant I missed the options within the custom event. I assumed that all events would bubble by default, but it seems not. So to replicate $dispatch in your function you need to add the bubbled option.

this.$el.dispatchEvent(new CustomEvent('my-event', {bubbles: true}));

Is the same as (or at least as far as I can see)

<button x-on:click="$dispatch('my-event')"><click/button>

@ryangjchandler
Copy link
Contributor

ryangjchandler commented Aug 3, 2020

Hey everyone, I'm going to close this issue since it's quite old now and a handful of workarounds have been mentioned in the thread.

For reference, the functionality of $dispatch can be replicated as shown in the comment above, or alternatively you can pass the $dispatch helper through to your helpers using the inline expression.

If anyone feels like this functionality needs to be re-evaluated, please re-open the issue or create a new one and reference this.

@MPJHorner
Copy link

Anyone wondering how to pass a param, you need to pass it as 'detail' in the object.
For example
$dispatch('open-modal', 'country_select')
Becomes
window.dispatchEvent(new CustomEvent('open-modal', {detail: 'country_select', 'bubbles': true}));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests