diff --git a/docs/reference/actions.md b/docs/reference/actions.md
index aebae6ab..829bd64c 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:
@@ -317,10 +342,10 @@ Data attribute | Param | Type
```html
- …
```
@@ -331,13 +356,13 @@ It will call both `ItemController#upvote` and `SpinnerController#start`, but onl
// ItemController
upvote(event) {
// { id: 12345, url: "/votes", active: true, payload: { value: 1234567 } }
- console.log(event.params)
+ console.log(event.params)
}
// SpinnerController
start(event) {
// {}
- console.log(event.params)
+ console.log(event.params)
}
```
@@ -346,7 +371,7 @@ If we don't need anything else from the event, we can destruct the params:
```js
upvote({ params }) {
// { id: 12345, url: "/votes", active: true, payload: { value: 1234567 } }
- console.log(params)
+ console.log(params)
}
```
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..dd009c64 100644
--- a/src/core/action.ts
+++ b/src/core/action.ts
@@ -1,37 +1,38 @@
import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } from "./action_descriptor"
import { Token } from "../mutation-observers"
-import { Schema } from "./schema"
+import { Context } from "./context"
+import { Controller } from "./controller"
import { camelize } from "./string_helpers"
export class Action {
readonly element: Element
readonly index: number
- readonly eventTarget: EventTarget
+ readonly eventTargets: EventTarget[]
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.eventTargets = parseEventTargets(context.controller, descriptor.eventTargets, element)
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() {
const eventFilter = this.keyFilter ? `.${this.keyFilter}` : ""
- const eventTarget = this.eventTargetName ? `@${this.eventTargetName}` : ""
+ const eventTarget = this.eventTargetNames ? `@${this.eventTargetNames}` : ""
return `${this.eventName}${eventFilter}${eventTarget}->${this.identifier}#${this.methodName}`
}
@@ -75,8 +76,12 @@ export class Action {
return params
}
- private get eventTargetName() {
- return stringifyEventTarget(this.eventTarget)
+ get schema() {
+ return this.context.schema
+ }
+
+ private get eventTargetNames() {
+ return stringifyEventTarget(this.eventTargets)
}
private get keyMappings() {
@@ -112,3 +117,19 @@ function typecast(value: any): any {
return value
}
}
+
+function parseEventTargets(
+ controller: Controller,
+ eventTargetName: string | undefined,
+ fallback: EventTarget
+): EventTarget[] {
+ if (eventTargetName == "window") {
+ return [window]
+ } else if (eventTargetName == "document") {
+ return [document]
+ } else if (typeof eventTargetName == "string") {
+ return controller.outlets.findAll(eventTargetName)
+ } else {
+ return [fallback]
+ }
+}
diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts
index fe051981..391fb601 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
+ eventTargets: 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) {
+export function stringifyEventTarget(eventTargets: EventTarget[]) {
+ const [eventTarget] = eventTargets
+
if (eventTarget == window) {
return "window"
} else if (eventTarget == document) {
diff --git a/src/core/binding.ts b/src/core/binding.ts
index 2c3edc04..ed690cc3 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 {
diff --git a/src/core/binding_observer.ts b/src/core/binding_observer.ts
index 62cc8355..47c3282b 100644
--- a/src/core/binding_observer.ts
+++ b/src/core/binding_observer.ts
@@ -37,6 +37,11 @@ export class BindingObserver implements ValueListObserverDelegate {
}
}
+ refresh() {
+ this.stop()
+ this.start()
+ }
+
get element() {
return this.context.element
}
@@ -79,7 +84,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/context.ts b/src/core/context.ts
index e1187add..059346f2 100644
--- a/src/core/context.ts
+++ b/src/core/context.ts
@@ -53,6 +53,7 @@ export class Context implements ErrorHandler, TargetObserverDelegate, OutletObse
refresh() {
this.outletObserver.refresh()
+ this.bindingObserver.refresh()
}
disconnect() {
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/core/outlet_observer.ts b/src/core/outlet_observer.ts
index 0208129f..ecc7f171 100644
--- a/src/core/outlet_observer.ts
+++ b/src/core/outlet_observer.ts
@@ -87,6 +87,7 @@ export class OutletObserver implements SelectorObserverDelegate {
if (!this.outletElementsByName.has(outletName, element)) {
this.outletsByName.add(outletName, outlet)
this.outletElementsByName.add(outletName, element)
+ this.context.refresh()
this.selectorObserverMap.get(outletName)?.pause(() => this.delegate.outletConnected(outlet, element, outletName))
}
}
@@ -95,6 +96,7 @@ export class OutletObserver implements SelectorObserverDelegate {
if (this.outletElementsByName.has(outletName, element)) {
this.outletsByName.delete(outletName, outlet)
this.outletElementsByName.delete(outletName, element)
+ this.context.refresh()
this.selectorObserverMap
.get(outletName)
?.pause(() => this.delegate.outletDisconnected(outlet, element, outletName))
diff --git a/src/tests/cases/dom_test_case.ts b/src/tests/cases/dom_test_case.ts
index 438239ef..fe86ad5b 100644
--- a/src/tests/cases/dom_test_case.ts
+++ b/src/tests/cases/dom_test_case.ts
@@ -60,8 +60,22 @@ export class DOMTestCase extends TestCase {
return event
}
- findElement(selector: string) {
- const element = this.fixtureElement.querySelector(selector)
+ async setAttribute(selectorOrElement: string | Element, name: string, value: string) {
+ const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement
+
+ element.setAttribute(name, value)
+ await this.nextFrame
+ }
+
+ async removeAttribute(selectorOrElement: string | Element, name: string) {
+ const element = typeof selectorOrElement == "string" ? this.findElement(selectorOrElement) : selectorOrElement
+
+ element.removeAttribute(name)
+ await this.nextFrame
+ }
+
+ findElement(selector: string) {
+ const element = this.fixtureElement.querySelector(selector)
if (element) {
return element
} else {
diff --git a/src/tests/controllers/element_controller.ts b/src/tests/controllers/element_controller.ts
new file mode 100644
index 00000000..ceb64400
--- /dev/null
+++ b/src/tests/controllers/element_controller.ts
@@ -0,0 +1,7 @@
+import { Controller } from "../../core/controller"
+
+export class ElementController extends Controller {
+ click() {
+ this.element.click()
+ }
+}
diff --git a/src/tests/controllers/outlet_controller.ts b/src/tests/controllers/outlet_controller.ts
index c3a5b840..8ee78dc5 100644
--- a/src/tests/controllers/outlet_controller.ts
+++ b/src/tests/controllers/outlet_controller.ts
@@ -12,7 +12,7 @@ class BaseOutletController extends Controller {
export class OutletController extends BaseOutletController {
static classes = ["connected", "disconnected"]
- static outlets = ["beta", "gamma", "delta", "omega", "namespaced--epsilon"]
+ static outlets = ["beta", "gamma", "delta", "omega", "namespaced--epsilon", "element"]
static values = {
alphaOutletConnectedCallCount: Number,
diff --git a/src/tests/modules/core/outlets/action_tests.ts b/src/tests/modules/core/outlets/action_tests.ts
new file mode 100644
index 00000000..2344ec8e
--- /dev/null
+++ b/src/tests/modules/core/outlets/action_tests.ts
@@ -0,0 +1,75 @@
+import { ApplicationTestCase } from "../../../cases/application_test_case"
+import { ElementController } from "../../../controllers/element_controller"
+import { LogController } from "../../../controllers/log_controller"
+import { OutletController } from "../../../controllers/outlet_controller"
+
+export default class OutletsActionTests extends ApplicationTestCase {
+ fixtureHTML = `
+
+ Click #outlet-button
+
+
+ #outlet-button
+
+ `
+
+ setupApplication() {
+ super.setupApplication()
+ this.application.register("element", ElementController)
+ this.application.register("log", LogController)
+ this.application.register("outlet", OutletController)
+ }
+
+ async "test action descriptor with @-prefixed outlet-name attaches event listeners"() {
+ const outletButton = this.findElement("#outlet-button")
+
+ await this.triggerEvent(outletButton, "click")
+
+ const [action, ...rest] = this.actionLog
+ this.assert.ok(action)
+ this.assert.equal(action.name, "log")
+ this.assert.equal(action.identifier, "log")
+ this.assert.equal(action.eventType, "click")
+ this.assert.equal(action.currentTarget, outletButton)
+ this.assert.equal(rest.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed does not attach event listener to host element"() {
+ await this.triggerEvent("#controller-element", "click")
+
+ this.assert.equal(this.actionLog.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed outlet-name attaches event listeners when connected"() {
+ const controllerElement = this.findElement("#controller-element")
+
+ await this.removeAttribute(controllerElement, "data-outlet-element-outlet")
+ await this.setAttribute(controllerElement, "data-outlet-element-outlet", "#outlet-button")
+ await this.triggerEvent("#outlet-button", "click")
+
+ this.assert.equal(this.actionLog.length, 0)
+ }
+
+ async "test action descriptor with @-prefixed outlet-name removes event listeners when disconnected"() {
+ const controllerElement = this.findElement("#controller-element")
+
+ await this.removeAttribute(controllerElement, "data-outlet-element-outlet")
+ await this.triggerEvent("#outlet-button", "click")
+
+ this.assert.equal(this.actionLog.length, 0)
+ }
+
+ get actionLog() {
+ const element = this.findElement(`[data-controller~="log"]`)
+ const controller = this.application.getControllerForElementAndIdentifier(element, "log")
+
+ if (controller instanceof LogController) {
+ return controller.actionLog
+ } else {
+ throw new Error(`controller with identifier "log" must be instance of LogController`)
+ }
+ }
+}