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

Outlets API #576

Merged
merged 11 commits into from Nov 17, 2022
Merged

Outlets API #576

merged 11 commits into from Nov 17, 2022

Conversation

marcoroth
Copy link
Member

@marcoroth marcoroth commented Aug 28, 2022

This pull request introduces a new Outlets API to Stimulus. Heavily influenced by HEY's Stimulus Outlet Properties.

Outlets

The concept of outlets is very similar to Stimulus Targets. While a target is a specifically marked element within the scope of the controller element, an outlet is a reference to one or many other Stimulus Controller instances on the same page.

The important difference is that outlets don't necessarily have to be within the scope of the controller element, as they can be anywhere on the page.

Outlet declaration

The Outlets API adds support for a static outlets array on controllers. This array declares which other controller identifiers can be used as outlets on this controller:

// list_controller.js

export default class extends Controller {
  static outlets = [ "item" ]

  connect () {
    this.itemOutlets.forEach(item => ...)
  }
}

Attributes and selectors

In order to declare a controller instance as an outlet on the "host" controller you need the add a data-attribute to the host controller element in the form of:

data-[identifier]-[outlet]-outlet="[selector]"
<div id="pagination" data-controller="pagination">
  <!-- ... -->
</div>

<!-- ... -->

<div class="list" data-controller"list" data-list-pagination-outlet="#pagination">
  <!-- ... -->
</div>

If you try to declare an element as an outlet which doesn't have the corresponding data-controller element on it, Stimulus will throw an exception:

Missing "data-controller=pagination" attribute on outlet element for "list" controller`

The selector can be any valid CSS selector.

<div class="item" data-controller="item">Item 1</div>
<div class="item" data-controller="item">Item 2</div>
<div class="item" data-controller="item">Item 3</div>
<div class="item" data-controller="item">Item 4</div>

<!-- ... -->

<div class="list" data-controller"list" data-list-item-outlet=".item">
  <!-- ... -->
</div>

Controller properties

Stimulus automatically generates five properties for every outlet identifier in the array:

Kind Property name Return Type Effect
Existential has[Name]Outlet Boolean Tests for presence of a name outlet
Singular [name]Outlet Controller Returns the Controller instance of the first name outlet or throws an exception if none is present
Plural [name]Outlets Array<Controller> Returns the Controller instances of all name outlets
Singular [name]OutletElement Element Returns the Controller Element of the first name outlet or throws an exception if none is present
Plural [name]OutletElements Array<Element>  Returns the Controller Element's of all name outlets

Accessing controllers and elements

Since you get back a Controller instance from the properties you are also able to access the Values, Classes, Targets and all of the other properties and functions that controller instance defines, for example:

this.itemOutlet.idValue
this.itemOutlet.childrenTargets
this.itemOutlet.activeClasses

You are also able to invoke any of the functions the controller defines.

// item_controller.js

export default class extends Controller {
  markAsDone(event) {
    // ...
  }
}

// list_controller.js

export default class extends Controller {
  static outlets = [ "item" ]

  markAllAsDone(event) {
    this.itemOutlets.forEach(item => item.markAsDone(event))
  }
}

Similarly with the Outlet Element:

this.imageOutletElement.dataset.value
this.imageOutletElement.getAttribute("src")
this.imageOutletElements.map(image => image.id)

Outlet callbacks

Outlet callbacks are specially named functions called by Stimulus to let you respond to whenever an outlet is added or removed.

To observe outlet changes, define a function named [name]OutletConnected() or [name]OutletDisconnected().

// list_controller.js

export default class extends Controller {
  static outlets = [ "item" ]

  itemOutletConnected(outlet, element) {
    // ...
  }

  itemOutletDisconnected(outlet, element) {
    // ...
  }
}

Outlets are assumed to be present

When you access an outlet property in a controller, you assert that at least one corresponding outlet is present. If the declaration is missing are no matching outlet is found Stimulus will throw an exception:

Missing outlet element "item" for "list" controller

Optional outlets

If an outlet is optional, you must first check if an outlet is present using the existential property:

if (this.hasItemOutlet) {
  doSomethingWith(this.itemOutlet)
}

Resolves #35 and Resolves #552.

Feedback and any other ideas are very welcome!

@mhenrixon
Copy link

This is brilliant! I’ve had the need for this on multiple occasions and ended up with really hacky solutions to make it work.

@adrianthedev
Copy link

This looks great! It's something that I felt the need to use before.

Is there a reason we can't just get the outlet using a data attribute selector?

-<div class="pagination" data-controller="pagination">
+<div data-list-outlet="pagination" data-controller="pagination" class="hidden">
  <!-- ... -->
</div>

-<div data-controller="list" data-list-pagination-outlet=".pagination">
+<div data-controller="list">
  <!-- ... -->
</div>
// list_controller.js

export default class extends Controller {
  static outlets = [ "pagination" ]

  connect () {
    this.paginationOutlet.classList.remove('hidden')
  }
}

This way, you don't have to think about "what selector should I place here" and also avoid the possibility of having clashing selectors (multiple .pagination classes). It's more straightforward and in line with how we declare targets.

TBH, I think this behavior can be achieved by enabling targets to be found outside the controller container.

@lb-
Copy link
Contributor

lb- commented Aug 28, 2022

Really nice idea - in some of my POC work I found that there are cases where we do want to be more explicit about some controller causing an 'output' to change in another container.

@seanpdoyle
Copy link
Contributor

This way, you don't have to think about "what selector should I place here" and also avoid the possibility of having clashing selectors (multiple .pagination classes). It's more straightforward and in line with how we declare targets.

TBH, I think this behavior can be achieved by enabling targets to be found outside the controller container.

I really like the idea of using the [data-$IDENTIFIER-outlet] properties instead of ad hoc CSS selectors, since it fits within existing Stimulus idioms. I prefer that as the default.

With that in mind, one benefit of using ad hoc CSS selectors is that multiple occurrences of the same controller in different parts of the document can include or exclude outlets in the absence of descendant scoping.

For example, controllers decide which [data-$IDENTIFIER-target] elements are and are not targets for that Controller instance based on whether or not they're nested within the [data-controller~="$IDENTIFIER"] element. Since Outlets "break out" of that scoping, they don't have those same constraints. With CSS selectors, controllers instance have an opportunity to control their Outlet scope.

@marcoroth @adrianthedev Most of the potential use cases for Outlets I've encountered are implicitly singleton controllers (only one occurrence on the page). Have you considered situations where multiple controllers using outlets (with potentially conflicting scopes) co-exist in a document?

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Aug 28, 2022

Since Outlets "break out" of that scoping, they don't have those same constraints. With CSS selectors, controllers instance have an opportunity to control their Outlet scope.

Could a controller set its own scope for all its outlets, instead of per outlet? Maybe something like an attribute that's a CSS selector or [id] attribute (defaulting to body or html) that could be useful for coordinating between outlet controllers that need to coexist, while still relying on a target-like data attributes (instead of CSS selectors) for element retrieval?

@lb-
Copy link
Contributor

lb- commented Aug 28, 2022

One use case for the selector approach -

We have a search form that the user can type in and it Async requests search results, we won't be using Turbo only Stimulus.

This search field mostly appears in the header and we cannot easily make the results list container be in the scope of the form.

The outlets approach is perfect for this scenario.

However, we realised (POC code only) that the search form can also appear in some modals. This means we have to pass in an ID (or some way to select the right container) so that the search results did not appear in all results list containers.

In saying that - there are always other ways to do this and maybe the selector approach could be optional?

@marcoroth
Copy link
Member Author

marcoroth commented Aug 28, 2022

@adrianthedev I like the idea of having the attribute as data-[identifier]-outlet=[outlet] on the outlet controller element itself, which would work, technically.

The reason why the outlet declaration is on the "parent" controller element is because this is where you usually have the control to define which outlets you want to have on this specific controller instance. It's hard to put the declaration on the "children" controller elements because they have no idea if, where and how they are being used as outlets. Which would also lead to the problem, that you kinda have a two way relationship, where they children need to know about their parents, and the parents need to know about their children, which is not ideal.

Let's say you have the list controller which has the item controller as an outlet:

// item_controller.js
export default class extends Controller {

}

// list_controller.js
export default class extends Controller {
  static outlets = [ "item"]
}

With the declaration on the list controller element, you can define the item outlets per instance, which wouldn't work the other way around.

<div id="list_1" data-controller="list" data-list-item-outlet=".list_1.item"></div>
<div id="list_2" data-controller="list" data-list-item-outlet=".list_2.item"></div>

<div class="list_1 item" data-controller="item"></div>
<div class="list_1 item" data-controller="item"></div>
<div class="list_2 item" data-controller="item"></div>
<div class="list_2 item" data-controller="item"></div>

But also, since the item controller elements could be used independently we don't want to tie them to their parent/s. They could be used on a page where we don't even have any list controller elements on the page.

I think the most compelling argument to put them on the parent controller element is because you could also be dealing with markup that you don't have any control over. Two situation come to mind: a) the markup might be coming from another partial/view/component or b) the markup might not be 100% related with the thing you are building the controller for (think like an admin--list controller which is just being used on the admin page but also uses the item controller as an outlet. You wouldn't want to clutter the regular item markup to annotate its relationship to the list, admin--list or any other controller which might use item as their outlet).

@adrianthedev
Copy link

I understand the situation where you can't manipulate the DOM and you want to select that part of the DOM. But how is that different from writing a document.querySelector('.item')? Albeit, with the class being dynamic document.querySelector(this.itemClassValue) (given we set up a static values = {itemClass: String} value getter).

I mean, as I see it, the value this feature brings is that it wraps the whole document.querySelector and the itemClassValue getter for you? It that right? I mean, I'm often wrong 😅

@marcoroth
Copy link
Member Author

@adrianthedev I mean, yeah that's the basic idea. It's basically an abstraction on top of this.application.getControllerForElementAndIdentifier() so you don't have to write the boilerplate in every controller.

You could implement the idea of outlets today in your controller with something like:

// list_controller.s

export default class extends Controller {
  static values = {
    itemClass: String
  }

  connect() {
    this.itemOutlets.forEach(outlet => ...)
  }

  get itemOutletElements() {
    return document.querySelectorAll(this.itemClassValue)
  }
 
  get itemOutlets() {
    return Array.from(this.itemOutletElements).map(element => this.application.getControllerForElementAndIdentifier(element, "item")
  }
}

The approach using the `SelectorObserver` is much more reliable to fire
the outlet connected and disconnected callbacks.

It's now matching against the selector provided in the
`data-[controller]-[outlet]-outlet` attribute instead of just looking
for elements which have a `data-controller` attribute to appear.
Previously, when a `data-controller` appeared we checked it's attribute
value to see if it was relevant as an outlet for the current controller.

Using the `SelectorObserver` we now solve an edge case where the user
would dynamically add/remove an attribute to the outlet element which
would then make that element relevant or not relevant anymore as an
outlet for the current controller. We need to know that so that we can
reliably fire the outlet callbacks. Previously I wouldn't have fired the
callbacks.

For every outlet we define we now create a separate instance of the
`SelectorObserver` which also handles that we just match the relevant
outlets by adding the `data-controller~=[outletName]` selector to the
CSS selector we lookup.
@NakajimaTakuya
Copy link
Contributor

OUTLET is a really desirable feature.
Thanks for promoting the suggestion.
We have already installed and used OUTLET, too, and have made a few discoveries there.

We would like to have the ability to add alias to an outlet.
Right now we define an outlet as an array, but we want to be able to give it a hash in addition to the array.
And if we can make it possible to give it an alias by that, we will be happy.

This is because stimulus recommends a rule to convert hoge/foo_controller.js to the controller name hoge--foo when loading it.
If you refer to a controller following this in an outlet, it will be very cumbersome to access.

static outlets = ['hoge-Foo']; // -x to X

someMethod() {
  this['hoge-Foo'].xxx();
}

I'd be happy if it could be put like this.

static outlets = {'hoge--foo': 'hogeFoo'}; 

someMethod() {
  this.hogeFooOutlet.xxx();
}
```

@marcoroth
Copy link
Member Author

marcoroth commented Sep 5, 2022

Hey @NakajimaTakuya, good remark, thank you!

I added commit 305e293 which should add support for namespaced controllers.

So for your hoge/foo_controller.js example it would be like this:

static outlets = [ 'hoge--foo' ]

someMethod() {
  this.hogeFooOutlet.xxx();
}

hogeFooOutletConnected(outlet, element) {

}

hogeFooOutletDisconnected(outlet, element) {

}

and like this in the HTML:

<div data-controller="list" data-list-hoge--foo-outlet=".hoge--foos">...</div>

I know that the outlet callbacks would clash if you would add another outlet for a controller hoge_foo_controller.js, but that chance is so low and unrealistic that I think that we can ignore that detail to keep the API simple.

The HTML attributes don't clash, it would be hoge-foo for the hoge_foo_controller.js:

<div data-controller="list" data-list-hoge-foo-outlet=".hoge-foos">...</div>

But if you really need to differentiate the outlet callbacks between the a hoge--foo and a hoge-foo controller you could do the following:

hogeFooOutletConnected(outlet, element) {
  if (outlet.identifier === "hoge--foo") {
    // do something in the "hoge--foo" case
  }

  if (outlet.identifier === "hoge-foo") {
    // do something in the "hoge-foo" case
  }
}

or something like this for the outlets:

this.hogeFooOutlets.filter(o => o.identifier === "hoge--foo")

@NakajimaTakuya
Copy link
Contributor

@marcoroth
Thanks for your consideration.
The proposal to introduce it into the naming logic to begin with seems simple.
But to me, the conditional branching on connected in case of an eventuality seemed a bit too complex.

However, I also realized that my original proposal had some problems.
I realized that the original proposal would be cumbersome for those that don't need the name conversion when multiple outlets need to be referenced.
Therefore, how about this interface like VALUES?

static outlets = ['foo', {name: 'hoge-foo', alias: 'hogeFoo'}, 'piyo'];

If you still feel that it is better to use only the name conversion rules to solve this problem, that is fine.

@marcoroth
Copy link
Member Author

marcoroth commented Sep 5, 2022

But to me, the conditional branching on connected in case of an eventuality seemed a bit too complex.

This is just the case if you have hoge--foo and hoge-foo as outlets on the same controller and need to differentiate between the two, which shouldn't be a common thing if you ask me.

Therefore, how about this interface like VALUES?

That would technically be possible, but I feel like that makes the wiring even more confusing if you can define aliases. Stimulus is designed around conventions and it feels weird to me to break that.

Maybe I don't understand the context. Can you elaborate why you would want to define aliases?

…rrent instance gets connected

There was an edge case where outlets wouldn't fire the outlet callbacks 
if they appeared later in the DOM but the current instance had a 
"dependency" on them.

To solve this the OutletObserver now "notifies" it's dependents that 
it conencted so they can refire the matching elements via the 
`refresh()` function of the ElementObserver.
@marcoroth marcoroth marked this pull request as ready for review September 5, 2022 13:09
@dhh
Copy link
Member

dhh commented Nov 17, 2022

@marcoroth Love this. Excellent API, great implementation.

Could you have a look at Error: src/core/controller.ts(5,1): error TS6133: 'OutletPropertiesBlessing' is declared but its value is never read..

@dhh dhh added this to the 3.2 milestone Nov 17, 2022
@marcoroth
Copy link
Member Author

@dhh looks like this is related to the recent merge. Will resolve 👍🏼

@dhh
Copy link
Member

dhh commented Nov 17, 2022

Could you do a doc PR to go along with this?

@marcoroth marcoroth deleted the outlets branch November 18, 2022 17:45
@marcoroth
Copy link
Member Author

Docs are coming via #604

@seb-jean
Copy link
Contributor

How could we use this feature to open a modal outside of a controller modal?

@lb-
Copy link
Contributor

lb- commented Nov 21, 2022

@seb-jean - have not tested locally but it would be something like this, however, you could solve this with event dispatching also.

Outlets approach

This should hopefully be pretty close to how you can do the modal controller interaction with outlets.

@marcoroth - any thoughts?

// controllers/modal_trigger_controller.js
class ModalTriggerController extends Controller {
  static outlets = [ "modal" ]

  trigger() {
    this.resultOutlets.forEach(controller => {
      controller.show();
    });
  }
}

export default ModalTriggerController;
// controllers/modal_controller.js
// https://getbootstrap.com/docs/5.2/components/modal/#via-javascript as example

class ModalController extends Controller {
  static targets = ['close'];

  show() {
    this._modal = this._modal || new bootstrap.Modal();
    this._modal.show();
    // rough code for example only - may not work
  }
}

export default ModalController;
<main>
  <button type="button" data-controller="modal-trigger" data-action="modal-trigger#trigger" data-modal-trigger-modal-outlet="#my-modal">Open modal</button>
</main>
<div class="modal" tabindex="-1" data-controller="modal" id="my-modal">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Modal title</h5>
        <button type="button" data-modal-target="close">X</button>
      </div>
      <div class="modal-body"><p>Modal body text goes here.</p></div>
    </div>
  </div>
</div>

Non-outlets approach

This uses event dispatching but also leverages a generic 'go' (aka trigger something) controller to attach the behaviour to the button.

This leverages the action parameters to move the 'selector' and 'event name' back into the DOM - https://stimulus.hotwired.dev/reference/actions#action-parameters

The nice approach here is that there is no digging into the controller implementation and we have an almost identical DOM API with the same power.

Outlets is going to be super helpful for some cases but I do think leveraging DOM events can get you pretty far.

// controllers/dispatch_controller.js
// does not have to be this 'generic' but may be a useful tool
class DispatchController extends Controller {
  go({ params: { eventName, targetSelector } }) {
    if (targetSelector) {
      document.querySelectorAll(targetSelector).forEach(target => {
        this.dispatch(eventName, { prefix: '', bubbles: false, cancelable: false, target });
      });
      return;
    }
    this.dispatch(eventName, { prefix: '', bubbles: true, cancelable: false });
  }
}

export default DispatchController;
// controllers/modal_controller.js
// https://getbootstrap.com/docs/5.2/components/modal/#via-javascript as example

class ModalController extends Controller {
  static targets = ['close'];

  show() {
    this._modal = this._modal || new bootstrap.Modal();
    this._modal.show();
    // rough code for example only - may not work
  }
}

export default ModalController;
<main>
  <button
    type="button"
    data-controller="dispatch"
    data-action="dispatch#go"
    data-dispatch-event-name-param="modal:show"
    data-dispatch-target-selector-param="#my-modal"
   >
    Open modal
  </button>
</main>
<div class="modal" tabindex="-1" data-controller="modal" id="my-modal" data-action="modal:show->modal#show">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">Modal title</h5>
        <button type="button" data-modal-target="close">X</button>
      </div>
      <div class="modal-body"><p>Modal body text goes here.</p></div>
    </div>
  </div>
</div>

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Dec 3, 2022

@marcoroth I've had an opportunity to experiment with outlets in the context of a disclosure button that controls a native <dialog> element.

The code resembled something like:

<script type="module">
import { Application, Controller } from "@hotwired/stimulus"

const application = Application.start()

application.register("dialog", class extends Controller {
  showModal() {
    this.element.showModal()
  }
})

application.register("disclosure", class extends Controller {
  static outlets = ["dialog"]

  dialogOutletConnected(controller, dialog) {
    this.element.setAttribute("aria-controls", dialog.id)
    this.element.setAttribute("aria-expanded", dialog.open)
    dialog.addEventListener("close", this.#setCollapsed)
  }

  dialogOutletDisconnected(controller, dialog) {
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
    dialog.removeEventListener("close", this.#setCollapsed)
  }

  expand() {
    for (const dialogOutlet of this.dialogOutlets) {
      dialogOutlet.showModal()
      this.#setExpanded()
    }
  }

  #setCollapsed = () => this.element.setAttribute("aria-expanded", false)
  #setExpanded = () => this.element.setAttribute("aria-expanded", true)
})
</script>

<dialog id="dialog" data-controller="dialog">
  Hello, from a dialog element!
</dialog>

<button data-controller="disclosure"
        data-disclosure-dialog-outlet="#dialog">
  Toggle #dialog
</button>

This feels like a huge improvement over prior art!

Two things that stick out to me as opportunities for improvement are the addEventListener and removeEventListener pairings. Stimulus's [data-action] attribute and its ability to manage event listener setup and teardown is one of its biggest value propositions.

I wonder if there's an opportunity to expand existing support for Action Descriptor syntax for global events (turbo:load@document, for example) to incorporate outlets.

Maybe something like:

 dialogOutletConnected(controller, dialog) {
   this.element.setAttribute("aria-controls", dialog.id)
   this.element.setAttribute("aria-expanded", dialog.open)
-  dialog.addEventListener("close", this.#setCollapsed)
 }
 
 dialogOutletDisconnected(controller, dialog) {
   this.element.removeAttribute("aria-controls")
   this.element.removeAttribute("aria-expanded")
-  dialog.removeEventListener("close", this.#setCollapsed)
 }

+close() {
+  this.#setCollapsed()
+}

Then, we could change the <button> to route the close event instead:

 <button data-controller="disclosure"
-        data-disclosure-dialog-outlet="#dialog">
+        data-disclosure-dialog-outlet="#dialog"
+        data-action="close@dialog->disclosure#setCollapsed">
   Toggle #dialog
 </button>

The close@dialog parses out to "attach a close event listener to this element's dialog outlet".

At the moment, the only global event symbols we support are document and window.

I wonder if document and window common enough controller identifiers that we'd need to start treating them as reserved words. Maybe if they do exist, we could look for outlets first before falling back to Document or Window instances.

Assuming that's possible for Stimulus to support, does that syntax feel intuitive enough? Are there other potential collisions we need to worry about?

seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 7, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [showModal][] method. Consider the following `disclosure`
controller implementation:

```js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[showModal]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 7, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [showModal][] method. Consider the following `disclosure`
controller implementation:

```js
// element_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[showModal]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 7, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 7, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 7, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Dec 11, 2022
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Jan 30, 2023
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
seanpdoyle added a commit to seanpdoyle/stimulus that referenced this pull request Aug 8, 2023
The original [idea][] for this change was outlined in a comment on
[hotwired#576].

The problem
---

Prior to this commit, any Outlet-powered references would need to manage
event listeners from within the Stimulus Controller JavaScript. For
example, consider the following HTML:

```html
<dialog id="dialog" data-controller="element">
  <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

<button type="button"
  data-controller="disclosure"
  data-disclosure-element-outlet="#dialog"
  data-action="click->disclosure#expand">
  Open dialog
</button>
```

Clicking the `button[type="button"]` opens the `dialog` element by
calling its [HTMLDialogElement.showModal()][] method. Consider the following `element` and
`disclosure` controller implementations:

```js
// element_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  showModal() {
    this.element.showModal()
  }
}

// disclosure_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["element"]

  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
    element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
    element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

  collapse = () => {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }

  expand() {
    for (const elementOutlet of this.elementOutlets) {
      elementOutlet.showModal()
      this.element.setAttribute("aria-expanded", elementOutlet.element.open)
    }
  }
}
```

Note the mirrored calls to add and remove [close][] event listeners.
Whenever the `dialog` element closes, it'll dispatch a `close` event,
which the `disclosure` controller will want to respond to.

Attaching and removing event listeners whenever an element connects or
disconnects is one of Stimulus's core capabilities, and declaring event
listeners as part of `[data-action]` is idiomatic. In spite of those
facts, the `disclosure` controller is responsible for the tedium of
managing its own event listeners.

The proposal
---

To push those declarations out of the JavaScript and back into the HTML,
this commit extends the Action Descriptor syntax to support declaring
actions with `@`-prefixed controller identifiers, in the same way that
`window` and `document` are special-cased.

With that support, the HTML changes:

```diff
 <dialog id="dialog" data-controller="element">
   <span>This dialog is managed through a disclosure button powered by an Outlet.</span>

   <form method="dialog">
     <button>Close</button>
   </form>
 </dialog>

 <button type="button"
   data-controller="disclosure"
   data-disclosure-element-outlet="#dialog"
-  data-action="click->disclosure#expand">
+  data-action="click->disclosure#expand close@element->disclosure#collapse">
   Open dialog
 </button>
```

And our `disclosure` controller has fewer responsibilities, and doesn't
need to special-case the `collapse` function's binding:

```diff
  elementOutletConnected(controller, element) {
    this.element.setAttribute("aria-controls", element.id)
    this.element.setAttribute("aria-expanded", element.open)
-   element.addEventListener("close", this.collapse)
  }

  elementOutletDisconnected() {
-   element.removeEventListener("close", this.collapse)
    this.element.removeAttribute("aria-controls")
    this.element.removeAttribute("aria-expanded")
  }

- collapse = () => {
+ collapse() {
    this.element.setAttribute("aria-expanded", false)
    this.element.focus()
  }
```

Risks
---

Changing the action descriptor syntax has more long-term maintenance
risks that other implementation changes. If we "spend" the syntax on
this type of support, we're pretty stuck with it.

Similarly, existing support for `window` and `document` as special
symbols means that we'd need to make special considerations (or
warnings) to support applications with `window`- and
`document`-identified controllers.

[hotwired#576]: hotwired#576
[idea]: hotwired#576 (comment)
[HTMLDialogElement.showModal()]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal
[close]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Do you plan to introduce OUTLET? Communicating between controllers
8 participants