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 + + + + A modal dialog + +``` + +In this example, the ` + + + A modal dialog + +``` + +In this example, the ` + + + + + +<%- 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..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..7a3ae38f 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -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) } 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 + } }