Skip to content

Commit

Permalink
Action Descriptor Syntax: Support Outlet listeners
Browse files Browse the repository at this point in the history
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
  • Loading branch information
seanpdoyle committed Dec 11, 2022
1 parent 2ff5440 commit 7c58c27
Show file tree
Hide file tree
Showing 18 changed files with 260 additions and 55 deletions.
27 changes: 26 additions & 1 deletion docs/reference/actions.md
Expand Up @@ -125,11 +125,36 @@ The list of supported modifier keys is shown below.
| `meta` | Command key on MacOS |
| `shift` | |

### Outlet Events

Sometimes a controller needs to listen for events dispatched on elements made available through its [Outlets](./outlets).

You can append an [outlet controller's identifier](./outlets#attributes-and-names) prefixed by `@` (along with any filter modifier) in an action descriptor to install the event listener on that outlet's element, as in the following example:

<meta data-controller="callout" data-callout-text-value="close@element">

```html
<button type="button"
data-controller="disclosure"
data-disclosure-element-outlet="#dialog"
data-action="click->disclosure#expand close@element->disclosure#collapse">
Click to expand a modal dialog
</button>

<dialog id="dialog" data-controller="element">
A modal dialog
</dialog>
```

In this example, the `<button>` element will route any [close][close-event] events dispatched by the `<dialog id="dialog">` element to its `disclosure#collapse` action, despite the `close` event bubbling up a different part of the document.

[close-event]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event

### Global Events

Sometimes a controller needs to listen for events dispatched on the global `window` or `document` objects.

You can append `@window` or `@document` to the event name (along with any filter modifer) in an action descriptor to install the event listener on `window` or `document`, respectively, as in the following example:
You can append `@window` or `@document` to the event name (along with any filter modifier) in an action descriptor to install the event listener on `window` or `document`, respectively, as in the following example:

<meta data-controller="callout" data-callout-text-value="resize@window">

Expand Down
25 changes: 25 additions & 0 deletions docs/reference/outlets.md
Expand Up @@ -177,3 +177,28 @@ Would result in:
Missing "data-controller=result" attribute on outlet element for
"search" controller`
```

## Actions

An element that declares outlets can listen for events dispatched on its outlets' elements.

To attach an event listener whenever an associated outlet connects to the document, declare the host element's action descriptor with [outlet controller's identifier](./outlets#attributes-and-names) prefixed by `@` (along with any filter modifier), as in the following example:

<meta data-controller="callout" data-callout-text-value="close@element">

```html
<button type="button"
data-controller="disclosure"
data-disclosure-element-outlet="#dialog"
data-action="click->disclosure#expand close@element->disclosure#collapse">
Click to expand a modal dialog
</button>

<dialog id="dialog" data-controller="element">
A modal dialog
</dialog>
```

In this example, the `<button>` element will route any [close][close-event] events dispatched by the `<dialog id="dialog">` element to its `disclosure#collapse` action, despite the `close` event bubbling up a different part of the document.

[close-event]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/close_event
27 changes: 27 additions & 0 deletions examples/controllers/disclosure_controller.js
@@ -0,0 +1,27 @@
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)
}

elementOutletDisconnected() {
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()
}
}
7 changes: 7 additions & 0 deletions examples/controllers/element_controller.js
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
showModal() {
this.element.showModal()
}
}
6 changes: 6 additions & 0 deletions examples/index.js
Expand Up @@ -3,6 +3,12 @@ import "@hotwired/turbo"

const application = Application.start()

import DisclosureController from "./controllers/disclosure_controller"
application.register("disclosure", DisclosureController)

import ElementController from "./controllers/element_controller"
application.register("element", ElementController)

import ClipboardController from "./controllers/clipboard_controller"
application.register("clipboard", ClipboardController)

Expand Down
12 changes: 12 additions & 0 deletions examples/public/examples.css
Expand Up @@ -9,6 +9,18 @@ main {
justify-content: flex-start;
}

dialog:not([open]) {
display: none;
}

dialog {
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 0;
}

.logo {
width: 6ex;
height: 6ex;
Expand Down
1 change: 1 addition & 0 deletions examples/public/main.css
Expand Up @@ -46,6 +46,7 @@ button {
background: #ccc;
border-left-color: #fff;
border-top-color: #fff;
cursor: pointer;
}

button:active {
Expand Down
1 change: 1 addition & 0 deletions examples/server.js
Expand Up @@ -20,6 +20,7 @@ app.use(webpackMiddleware(webpack(webpackConfig)))
const pages = [
{ path: "/hello", title: "Hello" },
{ path: "/clipboard", title: "Clipboard" },
{ path: "/disclosures", title: "Disclosures" },
{ path: "/slideshow", title: "Slideshow" },
{ path: "/content-loader", title: "Content Loader" },
{ path: "/tabs", title: "Tabs" },
Expand Down
16 changes: 16 additions & 0 deletions examples/views/disclosures.ejs
@@ -0,0 +1,16 @@
<%- include("layout/head") %>

<dialog id="dialog" class="container" 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 close@element->disclosure#collapse">Open dialog</button>

<%- include("layout/tail") %>
34 changes: 23 additions & 11 deletions src/core/action.ts
@@ -1,32 +1,32 @@
import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } from "./action_descriptor"
import { ActionDescriptor, parseActionDescriptorString } from "./action_descriptor"
import { Token } from "../mutation-observers"
import { Schema } from "./schema"
import { Context } from "./context"
import { camelize } from "./string_helpers"
export class Action {
readonly element: Element
readonly index: number
readonly eventTarget: EventTarget
private readonly eventTargetName: string | undefined
readonly eventName: string
readonly eventOptions: AddEventListenerOptions
readonly identifier: string
readonly methodName: string
readonly keyFilter: string
readonly schema: Schema
readonly context: Context

static forToken(token: Token, schema: Schema) {
return new this(token.element, token.index, parseActionDescriptorString(token.content), schema)
static forToken(token: Token, context: Context) {
return new this(token.element, token.index, parseActionDescriptorString(token.content), context)
}

constructor(element: Element, index: number, descriptor: Partial<ActionDescriptor>, schema: Schema) {
constructor(element: Element, index: number, descriptor: Partial<ActionDescriptor>, context: Context) {
this.element = element
this.index = index
this.eventTarget = descriptor.eventTarget || element
this.eventTargetName = descriptor.eventTargetName
this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name")
this.eventOptions = descriptor.eventOptions || {}
this.identifier = descriptor.identifier || error("missing identifier")
this.methodName = descriptor.methodName || error("missing method name")
this.keyFilter = descriptor.keyFilter || ""
this.schema = schema
this.context = context
}

toString() {
Expand Down Expand Up @@ -75,8 +75,20 @@ export class Action {
return params
}

private get eventTargetName() {
return stringifyEventTarget(this.eventTarget)
get schema() {
return this.context.schema
}

get eventTargets(): EventTarget[] {
if (this.eventTargetName == "window") {
return [window]
} else if (this.eventTargetName == "document") {
return [document]
} else if (typeof this.eventTargetName == "string") {
return this.context.controller.outlets.findAll(this.eventTargetName)
} else {
return [this.element]
}
}

private get keyMappings() {
Expand Down
24 changes: 4 additions & 20 deletions src/core/action_descriptor.ts
Expand Up @@ -30,16 +30,16 @@ export const defaultActionDescriptorFilters: ActionDescriptorFilters = {
}

export interface ActionDescriptor {
eventTarget: EventTarget
eventTargetName: string
eventOptions: AddEventListenerOptions
eventName: string
identifier: string
methodName: string
keyFilter: string
}

// capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6
const descriptorPattern = /^(?:(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/
// capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6
const descriptorPattern = /^(?:(.+?)(?:\.(.+?))?(?:@(.+?))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/

export function parseActionDescriptorString(descriptorString: string): Partial<ActionDescriptor> {
const source = descriptorString.trim()
Expand All @@ -53,7 +53,7 @@ export function parseActionDescriptorString(descriptorString: string): Partial<A
}

return {
eventTarget: parseEventTarget(matches[3]),
eventTargetName: matches[3],
eventName,
eventOptions: matches[6] ? parseEventOptions(matches[6]) : {},
identifier: matches[4],
Expand All @@ -62,24 +62,8 @@ export function parseActionDescriptorString(descriptorString: string): Partial<A
}
}

function parseEventTarget(eventTargetName: string): EventTarget | undefined {
if (eventTargetName == "window") {
return window
} else if (eventTargetName == "document") {
return document
}
}

function parseEventOptions(eventOptions: string): AddEventListenerOptions {
return eventOptions
.split(":")
.reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {})
}

export function stringifyEventTarget(eventTarget: EventTarget) {
if (eventTarget == window) {
return "window"
} else if (eventTarget == document) {
return "document"
}
}
4 changes: 2 additions & 2 deletions src/core/binding.ts
Expand Up @@ -16,8 +16,8 @@ export class Binding {
return this.action.index
}

get eventTarget(): EventTarget {
return this.action.eventTarget
get eventTargets(): EventTarget[] {
return this.action.eventTargets
}

get eventOptions(): AddEventListenerOptions {
Expand Down
7 changes: 6 additions & 1 deletion src/core/binding_observer.ts
Expand Up @@ -37,6 +37,11 @@ export class BindingObserver implements ValueListObserverDelegate<Action> {
}
}

refresh() {
this.stop()
this.start()
}

get element() {
return this.context.element
}
Expand Down Expand Up @@ -79,7 +84,7 @@ export class BindingObserver implements ValueListObserverDelegate<Action> {
// Value observer delegate

parseValueForToken(token: Token): Action | undefined {
const action = Action.forToken(token, this.schema)
const action = Action.forToken(token, this.context)
if (action.identifier == this.identifier) {
return action
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/context.ts
Expand Up @@ -122,10 +122,12 @@ export class Context implements ErrorHandler, TargetObserverDelegate, OutletObse
// Outlet observer delegate

outletConnected(outlet: Controller, element: Element, name: string) {
this.bindingObserver.refresh()
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletConnected`, outlet, element)
}

outletDisconnected(outlet: Controller, element: Element, name: string) {
this.bindingObserver.refresh()
this.invokeControllerMethod(`${namespaceCamelize(name)}OutletDisconnected`, outlet, element)
}

Expand Down
36 changes: 22 additions & 14 deletions src/core/dispatcher.ts
Expand Up @@ -38,11 +38,15 @@ export class Dispatcher implements BindingObserverDelegate {
// Binding observer delegate

bindingConnected(binding: Binding) {
this.fetchEventListenerForBinding(binding).bindingConnected(binding)
for (const eventListener of this.fetchEventListenersForBinding(binding)) {
eventListener.bindingConnected(binding)
}
}

bindingDisconnected(binding: Binding, clearEventListeners = false) {
this.fetchEventListenerForBinding(binding).bindingDisconnected(binding)
for (const eventListener of this.fetchEventListenersForBinding(binding)) {
eventListener.bindingDisconnected(binding)
}
if (clearEventListeners) this.clearEventListenersForBinding(binding)
}

Expand All @@ -53,25 +57,29 @@ export class Dispatcher implements BindingObserverDelegate {
}

private clearEventListenersForBinding(binding: Binding) {
const eventListener = this.fetchEventListenerForBinding(binding)
if (!eventListener.hasBindings()) {
eventListener.disconnect()
this.removeMappedEventListenerFor(binding)
for (const eventListener of this.fetchEventListenersForBinding(binding)) {
if (!eventListener.hasBindings()) {
eventListener.disconnect()
this.removeMappedEventListenerFor(binding)
}
}
}

private removeMappedEventListenerFor(binding: Binding) {
const { eventTarget, eventName, eventOptions } = binding
const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget)
const cacheKey = this.cacheKey(eventName, eventOptions)
const { eventTargets, eventName, eventOptions } = binding

for (const eventTarget of eventTargets) {
const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget)
const cacheKey = this.cacheKey(eventName, eventOptions)

eventListenerMap.delete(cacheKey)
if (eventListenerMap.size == 0) this.eventListenerMaps.delete(eventTarget)
eventListenerMap.delete(cacheKey)
if (eventListenerMap.size == 0) this.eventListenerMaps.delete(eventTarget)
}
}

private fetchEventListenerForBinding(binding: Binding): EventListener {
const { eventTarget, eventName, eventOptions } = binding
return this.fetchEventListener(eventTarget, eventName, eventOptions)
private fetchEventListenersForBinding(binding: Binding): EventListener[] {
const { eventTargets, eventName, eventOptions } = binding
return eventTargets.map((eventTarget) => this.fetchEventListener(eventTarget, eventName, eventOptions))
}

private fetchEventListener(
Expand Down

0 comments on commit 7c58c27

Please sign in to comment.