diff --git a/docs/reference/actions.md b/docs/reference/actions.md
index aebae6ab..0d6c4bd1 100644
--- a/docs/reference/actions.md
+++ b/docs/reference/actions.md
@@ -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:
+
+
+
+```html
+
+ Click to expand a modal dialog
+
+
+
+ A modal dialog
+
+```
+
+In this example, the `` element will route any [close][close-event] events dispatched by the `` 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:
diff --git a/docs/reference/outlets.md b/docs/reference/outlets.md
index 8f19ad21..56c7974a 100644
--- a/docs/reference/outlets.md
+++ b/docs/reference/outlets.md
@@ -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:
+
+
+
+```html
+
+ Click to expand a modal dialog
+
+
+
+ A modal dialog
+
+```
+
+In this example, the `` element will route any [close][close-event] events dispatched by the `` 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
diff --git a/examples/controllers/disclosure_controller.js b/examples/controllers/disclosure_controller.js
new file mode 100644
index 00000000..38515490
--- /dev/null
+++ b/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()
+ }
+}
diff --git a/examples/controllers/element_controller.js b/examples/controllers/element_controller.js
new file mode 100644
index 00000000..196caddc
--- /dev/null
+++ b/examples/controllers/element_controller.js
@@ -0,0 +1,7 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ showModal() {
+ this.element.showModal()
+ }
+}
diff --git a/examples/index.js b/examples/index.js
index b44c9c76..53e00194 100644
--- a/examples/index.js
+++ b/examples/index.js
@@ -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)
diff --git a/examples/public/examples.css b/examples/public/examples.css
index bc436cce..8f2acf84 100644
--- a/examples/public/examples.css
+++ b/examples/public/examples.css
@@ -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;
diff --git a/examples/public/main.css b/examples/public/main.css
index 7fbee366..7e16b789 100644
--- a/examples/public/main.css
+++ b/examples/public/main.css
@@ -46,6 +46,7 @@ button {
background: #ccc;
border-left-color: #fff;
border-top-color: #fff;
+ cursor: pointer;
}
button:active {
diff --git a/examples/server.js b/examples/server.js
index a5573f33..834fd12b 100644
--- a/examples/server.js
+++ b/examples/server.js
@@ -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" },
diff --git a/examples/views/disclosures.ejs b/examples/views/disclosures.ejs
new file mode 100644
index 00000000..dbe2bf8e
--- /dev/null
+++ b/examples/views/disclosures.ejs
@@ -0,0 +1,16 @@
+<%- include("layout/head") %>
+
+
+ This dialog is managed through a disclosure button powered by an Outlet.
+
+
+
+
+Open dialog
+
+<%- include("layout/tail") %>
diff --git a/src/core/action.ts b/src/core/action.ts
index 8fba7a10..5ccc51a4 100644
--- a/src/core/action.ts
+++ b/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, schema: Schema) {
+ constructor(element: Element, index: number, descriptor: Partial, 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() {
@@ -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() {
diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts
index fe051981..2703c02d 100644
--- a/src/core/action_descriptor.ts
+++ b/src/core/action_descriptor.ts
@@ -30,7 +30,7 @@ export const defaultActionDescriptorFilters: ActionDescriptorFilters = {
}
export interface ActionDescriptor {
- eventTarget: EventTarget
+ eventTargetName: string
eventOptions: AddEventListenerOptions
eventName: string
identifier: string
@@ -38,8 +38,8 @@ export interface ActionDescriptor {
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 {
const source = descriptorString.trim()
@@ -53,7 +53,7 @@ export function parseActionDescriptorString(descriptorString: string): Partial Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {})
}
-
-export function stringifyEventTarget(eventTarget: EventTarget) {
- if (eventTarget == window) {
- return "window"
- } else if (eventTarget == document) {
- return "document"
- }
-}
diff --git a/src/core/binding.ts b/src/core/binding.ts
index 2c3edc04..f428e82e 100644
--- a/src/core/binding.ts
+++ b/src/core/binding.ts
@@ -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 {
@@ -81,6 +81,7 @@ export class Binding {
private willBeInvokedByEvent(event: Event): boolean {
const eventTarget = event.target
+ const [actionEventTarget] = this.action.eventTargets
if (event instanceof KeyboardEvent && this.action.isFilterTarget(event)) {
return false
@@ -90,6 +91,14 @@ export class Binding {
return true
} else if (eventTarget instanceof Element && this.element.contains(eventTarget)) {
return this.scope.containsElement(eventTarget)
+ } else if (eventTarget instanceof Element && this.action.eventTargets.length == 0) {
+ return false
+ } else if (
+ eventTarget instanceof Element &&
+ actionEventTarget instanceof Element &&
+ actionEventTarget != this.action.element
+ ) {
+ return this.action.eventTargets.includes(eventTarget)
} else {
return this.scope.containsElement(this.action.element)
}
diff --git a/src/core/binding_observer.ts b/src/core/binding_observer.ts
index 62cc8355..7f2a97c5 100644
--- a/src/core/binding_observer.ts
+++ b/src/core/binding_observer.ts
@@ -79,7 +79,7 @@ export class BindingObserver implements ValueListObserverDelegate {
// 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
}
diff --git a/src/core/dispatcher.ts b/src/core/dispatcher.ts
index 04a3bb27..43d6dfac 100644
--- a/src/core/dispatcher.ts
+++ b/src/core/dispatcher.ts
@@ -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)
}
@@ -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(
diff --git a/src/tests/controllers/outlet_controller.ts b/src/tests/controllers/outlet_controller.ts
index 4c5088ea..fac268ab 100644
--- a/src/tests/controllers/outlet_controller.ts
+++ b/src/tests/controllers/outlet_controller.ts
@@ -1,5 +1,7 @@
import { Controller } from "../../core/controller"
+type OutletClickEvents = { event: Event; identifier: string }
+
class BaseOutletController extends Controller {
static outlets = ["alpha"]
@@ -24,6 +26,7 @@ export class OutletController extends BaseOutletController {
namespacedEpsilonOutletDisconnectedCallCount: Number,
}
+ outletClickEvents: OutletClickEvents[] = []
betaOutlet!: Controller | null
betaOutlets!: Controller[]
betaOutletElement!: Element | null
@@ -83,4 +86,10 @@ export class OutletController extends BaseOutletController {
if (this.hasDisconnectedClass) element.classList.add(this.disconnectedClass)
this.namespacedEpsilonOutletDisconnectedCallCountValue++
}
+
+ outletClicked(event: Event) {
+ const { identifier } = this
+
+ this.outletClickEvents.push({ identifier, event })
+ }
}
diff --git a/src/tests/modules/core/outlet_tests.ts b/src/tests/modules/core/outlet_tests.ts
index 9ddcfffc..3d09fe83 100644
--- a/src/tests/modules/core/outlet_tests.ts
+++ b/src/tests/modules/core/outlet_tests.ts
@@ -13,8 +13,9 @@ export default class OutletTests extends ControllerTestCase(OutletController) {
-
+
-
+
-
+
@@ -347,4 +348,66 @@ export default class OutletTests extends ControllerTestCase(OutletController) {
`expected "${alpha2.className}" to contain "disconnected"`
)
}
+
+ async "test action descriptor with @-prefixed outlet-name attaches event listeners"() {
+ const epsilon1 = this.findElement("#epsilon1")
+ const epsilon2 = this.findElement("#epsilon2")
+
+ await this.triggerEvent(epsilon1, "click")
+ await this.triggerEvent(epsilon2, "click")
+
+ const [clickEpsilon1, clickEpsilon2, ...rest] = this.outletClickEvents
+ this.assert.equal(clickEpsilon1.identifier, this.identifier)
+ this.assert.equal(clickEpsilon1.event.type, "click")
+ this.assert.equal(clickEpsilon1.event.target, epsilon1)
+ this.assert.equal(clickEpsilon2.identifier, this.identifier)
+ this.assert.equal(clickEpsilon2.event.type, "click")
+ this.assert.equal(clickEpsilon2.event.target, epsilon2)
+ this.assert.equal(rest.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed does not attach event listener to host element"() {
+ await this.triggerEvent(this.element, "click")
+
+ this.assert.equal(this.outletClickEvents.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed outlet-name attaches event listeners when the outlet element connects"() {
+ const epsilon1 = this.findElement("#epsilon1")
+ const epsilon2 = this.findElement("#epsilon2")
+
+ await this.setAttribute(this.element, `data-${this.identifier}-namespaced--epsilon-outlet`, "#epsilon2")
+ await this.triggerEvent(epsilon1, "click")
+ await this.triggerEvent(epsilon2, "click")
+
+ const [clickEpsilon2, ...rest] = this.outletClickEvents
+ this.assert.equal(clickEpsilon2.identifier, this.identifier)
+ this.assert.equal(clickEpsilon2.event.type, "click")
+ this.assert.equal(clickEpsilon2.event.target, epsilon2)
+ this.assert.equal(rest.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed outlet-name removes event listeners when the outlet element disconnects"() {
+ await this.removeAttribute(this.element, `data-${this.identifier}-namespaced--epsilon-outlet`)
+ await this.triggerEvent("#epsilon1", "click")
+ await this.triggerEvent("#epsilon2", "click")
+
+ this.assert.equal(this.outletClickEvents.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed outlet-name removes event listeners when the action descriptor is removed"() {
+ await this.removeAttribute(this.element, "data-action")
+ await this.triggerEvent("#epsilon1", "click")
+ await this.triggerEvent("#epsilon2", "click")
+
+ this.assert.equal(this.outletClickEvents.length, 0)
+ }
+
+ get outletClickEvents() {
+ return this.controller.outletClickEvents
+ }
+
+ get element() {
+ return this.controller.element
+ }
}