diff --git a/docs/reference/actions.md b/docs/reference/actions.md index 046438b1..c1ae6623 100644 --- a/docs/reference/actions.md +++ b/docs/reference/actions.md @@ -79,6 +79,28 @@ You can append `@window` or `@document` to the event name in an action descripto ``` +### Options + +Sometimes you may need to pass [additional options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Parameters) to the _Event Listener_ attached to the action. + +* Options are set by adding one of the allowed tokens to the end of the _action descriptor_ +* Options are separated from the method by a column `:` +* Add an exclamation mark `!` before a token to negate its value + +**The followng option tokens are allowed:** + +Tokens | EventListener option +------------- | --------------------- +`capture` | `{ capture: true }` +`once` | `{ once: true }` +`passive` | `{ passive: true }` +`!passive` | `{ passive: false }` + + +**Options can be combined if needed** + +`click->controller#method:!passive:once` + ## Event Objects An _action method_ is the method in a controller which serves as an action's event listener. diff --git a/packages/@stimulus/core/src/action.ts b/packages/@stimulus/core/src/action.ts index 27abae92..74cec048 100644 --- a/packages/@stimulus/core/src/action.ts +++ b/packages/@stimulus/core/src/action.ts @@ -6,6 +6,7 @@ export class Action { readonly index: number readonly eventTarget: EventTarget readonly eventName: string + readonly eventOptions: AddEventListenerOptions readonly identifier: string readonly methodName: string @@ -14,12 +15,13 @@ export class Action { } constructor(element: Element, index: number, descriptor: Partial) { - this.element = element - this.index = index - this.eventTarget = descriptor.eventTarget || element - this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name") - this.identifier = descriptor.identifier || error("missing identifier") - this.methodName = descriptor.methodName || error("missing method name") + this.element = element + this.index = index + this.eventTarget = descriptor.eventTarget || 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") } toString() { diff --git a/packages/@stimulus/core/src/action_descriptor.ts b/packages/@stimulus/core/src/action_descriptor.ts index 4b14f074..941d7ef2 100644 --- a/packages/@stimulus/core/src/action_descriptor.ts +++ b/packages/@stimulus/core/src/action_descriptor.ts @@ -1,21 +1,24 @@ export interface ActionDescriptor { eventTarget: EventTarget + eventOptions: AddEventListenerOptions eventName: string identifier: string methodName: string } -// capture nos.: 12 23 4 43 1 5 56 7 76 -const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#(.+))?$/ +// capture nos.: 12 23 4 43 1 5 56 7 768 9 98 +const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/ export function parseDescriptorString(descriptorString: string): Partial { const source = descriptorString.trim() const matches = source.match(descriptorPattern) || [] + return { - eventTarget: parseEventTarget(matches[4]), - eventName: matches[2], - identifier: matches[5], - methodName: matches[7] + eventTarget: parseEventTarget(matches[4]), + eventName: matches[2], + eventOptions: parseEventOptions(matches[9]), + identifier: matches[5], + methodName: matches[7] } } @@ -27,6 +30,12 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined { } } +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" diff --git a/packages/@stimulus/core/src/binding.ts b/packages/@stimulus/core/src/binding.ts index ceabd6db..e1a2f826 100644 --- a/packages/@stimulus/core/src/binding.ts +++ b/packages/@stimulus/core/src/binding.ts @@ -20,6 +20,10 @@ export class Binding { return this.action.eventTarget } + get eventOptions(): AddEventListenerOptions { + return this.action.eventOptions + } + get identifier(): string { return this.context.identifier } diff --git a/packages/@stimulus/core/src/dispatcher.ts b/packages/@stimulus/core/src/dispatcher.ts index 028a3a98..524cc8d1 100644 --- a/packages/@stimulus/core/src/dispatcher.ts +++ b/packages/@stimulus/core/src/dispatcher.ts @@ -52,22 +52,23 @@ export class Dispatcher implements BindingObserverDelegate { } private fetchEventListenerForBinding(binding: Binding): EventListener { - const { eventTarget, eventName } = binding - return this.fetchEventListener(eventTarget, eventName) + const { eventTarget, eventName, eventOptions } = binding + return this.fetchEventListener(eventTarget, eventName, eventOptions) } - private fetchEventListener(eventTarget: EventTarget, eventName: string): EventListener { + private fetchEventListener(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions): EventListener { const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget) - let eventListener = eventListenerMap.get(eventName) + const cacheKey = this.cacheKey(eventName, eventOptions) + let eventListener = eventListenerMap.get(cacheKey) if (!eventListener) { - eventListener = this.createEventListener(eventTarget, eventName) - eventListenerMap.set(eventName, eventListener) + eventListener = this.createEventListener(eventTarget, eventName, eventOptions) + eventListenerMap.set(cacheKey, eventListener) } return eventListener } - private createEventListener(eventTarget: EventTarget, eventName: string): EventListener { - const eventListener = new EventListener(eventTarget, eventName) + private createEventListener(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions): EventListener { + const eventListener = new EventListener(eventTarget, eventName, eventOptions) if (this.started) { eventListener.connect() } @@ -82,4 +83,8 @@ export class Dispatcher implements BindingObserverDelegate { } return eventListenerMap } + + private cacheKey(eventName: string, eventOptions: any): string { + return eventName + JSON.stringify(Object.keys(eventOptions).sort().map(key => [key, eventOptions[key]])) + } } diff --git a/packages/@stimulus/core/src/event_listener.ts b/packages/@stimulus/core/src/event_listener.ts index d874cb47..32fa710e 100644 --- a/packages/@stimulus/core/src/event_listener.ts +++ b/packages/@stimulus/core/src/event_listener.ts @@ -3,20 +3,22 @@ import { Binding } from "./binding" export class EventListener implements EventListenerObject { readonly eventTarget: EventTarget readonly eventName: string + readonly eventOptions: AddEventListenerOptions private unorderedBindings: Set - constructor(eventTarget: EventTarget, eventName: string) { + constructor(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions) { this.eventTarget = eventTarget this.eventName = eventName - this.unorderedBindings = new Set + this.eventOptions = eventOptions + this.unorderedBindings = new Set() } connect() { - this.eventTarget.addEventListener(this.eventName, this, false) + this.eventTarget.addEventListener(this.eventName, this, this.eventOptions) } disconnect() { - this.eventTarget.removeEventListener(this.eventName, this, false) + this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions) } // Binding observer delegate @@ -48,6 +50,7 @@ export class EventListener implements EventListenerObject { return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0 }) } + } function extendEvent(event: Event) { diff --git a/packages/@stimulus/core/src/tests/cases/action_tests.ts b/packages/@stimulus/core/src/tests/cases/action_tests.ts index 8cf244ea..5033c23e 100644 --- a/packages/@stimulus/core/src/tests/cases/action_tests.ts +++ b/packages/@stimulus/core/src/tests/cases/action_tests.ts @@ -24,9 +24,9 @@ export default class ActionTests extends LogControllerTestCase { } async "test non-bubbling events"() { - await this.triggerEvent("span", "click", false) + await this.triggerEvent("span", "click", { bubbles: false }) this.assertNoActions() - await this.triggerEvent("button", "click", false) + await this.triggerEvent("button", "click", { bubbles: false }) this.assertActions({ eventType: "click" }) } diff --git a/packages/@stimulus/core/src/tests/cases/event_options_tests.ts b/packages/@stimulus/core/src/tests/cases/event_options_tests.ts new file mode 100644 index 00000000..9c82cd27 --- /dev/null +++ b/packages/@stimulus/core/src/tests/cases/event_options_tests.ts @@ -0,0 +1,145 @@ +import { LogControllerTestCase } from "../log_controller_test_case" + +export default class EventOptionsTests extends LogControllerTestCase { + identifier = ["c", "d"] + fixtureHTML = ` +
+ +
+
+ ` + async "test different syntaxes for once action"() { + this.actionValue = "click->c#log:once d#log2:once c#log3:once" + + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, + { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + ) + } + + async "test mix once and standard actions"() { + this.actionValue = "c#log:once d#log2 c#log3" + + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, + { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + { name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement }, + { name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + ) + } + + async "test stop propagation with once"() { + this.actionValue = "c#stop:once c#log" + + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "stop", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + ) + + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + this.assertActions( + { name: "stop", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + { name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }, + ) + } + + async "test global once actions"() { + this.actionValue = "keydown@window->c#log:once" + + await this.nextFrame + await this.triggerEvent("#outside", "keydown") + await this.triggerEvent("#outside", "keydown") + + this.assertActions({ name: "log", eventType: "keydown" }) + } + + async "test edge case when updating action list with setAttribute preserves once history"() { + this.actionValue = "c#log:once" + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + await this.triggerEvent(this.buttonElement, "click") + + //modify with a setAttribute and c#log should not be called anyhow + this.actionValue = "c#log2 c#log:once d#log" + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log", identifier: "c" }, + { name: "log2", identifier: "c" }, + { name: "log", identifier: "d" }, + ) + } + + async "test default passive action"() { + this.actionValue = "scroll->c#logPassive:passive" + await this.nextFrame + + await this.triggerEvent(this.buttonElement, "scroll", { setDefaultPrevented: false }) + this.assertActions({ name: "logPassive", eventType: "scroll", passive: true }) + } + + async "test global passive actions"() { + this.actionValue = "mouseup@window->c#logPassive:passive" + await this.nextFrame + + await this.triggerEvent("#outside", "mouseup", { setDefaultPrevented: false }) + this.assertActions({ name: "logPassive", eventType: "mouseup", passive: true }) + } + + async "test passive false actions"() { + // by default touchmove is true in chrome + this.actionValue = "touchmove@window->c#logPassive:!passive" + await this.nextFrame + + await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) + this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false }) + } + + async "test multiple options"() { + // by default touchmove is true in chrome + this.actionValue = "touchmove@window->c#logPassive:once:!passive" + await this.nextFrame + + await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) + await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) + this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false }) + } + + async "test wrong options are silently ignored"() { + this.actionValue = "c#log:wrong:verywrong" + await this.nextFrame + await this.triggerEvent(this.buttonElement, "click") + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log", identifier: "c" }, + { name: "log", identifier: "c" } + ) + } + + set actionValue(value: string) { + this.buttonElement.setAttribute("data-action", value) + } + + get element() { + return this.findElement("div") + } + + get buttonElement() { + return this.findElement("button") + } +} diff --git a/packages/@stimulus/core/src/tests/log_controller.ts b/packages/@stimulus/core/src/tests/log_controller.ts index e0c2c67d..e7536271 100644 --- a/packages/@stimulus/core/src/tests/log_controller.ts +++ b/packages/@stimulus/core/src/tests/log_controller.ts @@ -7,6 +7,7 @@ export type ActionLogEntry = { eventType: string currentTarget: EventTarget | null defaultPrevented: boolean + passive: boolean } export class LogController extends Controller { @@ -45,6 +46,15 @@ export class LogController extends Controller { this.recordAction("log3", event) } + logPassive(event: Event) { + event.preventDefault() + if (event.defaultPrevented) { + this.recordAction("logPassive", event, false) + } else { + this.recordAction("logPassive", event, true) + } + } + stop(event: Event) { this.recordAction("stop", event) event.stopImmediatePropagation() @@ -54,14 +64,15 @@ export class LogController extends Controller { return (this.constructor as typeof LogController).actionLog } - private recordAction(name: string, event: Event) { + private recordAction(name: string, event: Event, passive?: boolean) { this.actionLog.push({ name, controller: this, identifier: this.identifier, eventType: event.type, currentTarget: event.currentTarget, - defaultPrevented: event.defaultPrevented + defaultPrevented: event.defaultPrevented, + passive: passive || false }) } } diff --git a/packages/@stimulus/polyfills/README.md b/packages/@stimulus/polyfills/README.md index 1ca60d14..4937f084 100644 --- a/packages/@stimulus/polyfills/README.md +++ b/packages/@stimulus/polyfills/README.md @@ -12,4 +12,6 @@ The `@stimulus/polyfills` package provides support for Stimulus in older browser * `Element.closest()` * [mutation-observer-inner-html-shim](https://www.npmjs.com/package/mutation-observer-inner-html-shim) * `MutationObserver` support for Internet Explorer 11 +* [eventlistener-polyfill](https://github.com/github/eventlistener-polyfill) + * once & passive support for Internet Explorer 11 & Edge diff --git a/packages/@stimulus/polyfills/index.js b/packages/@stimulus/polyfills/index.js index 724209cf..0280022f 100644 --- a/packages/@stimulus/polyfills/index.js +++ b/packages/@stimulus/polyfills/index.js @@ -7,3 +7,4 @@ import "core-js/fn/promise" import "core-js/fn/set" import "element-closest" import "mutation-observer-inner-html-shim" +import "eventlistener-polyfill" diff --git a/packages/@stimulus/polyfills/package.json b/packages/@stimulus/polyfills/package.json index 072ebc28..651d547c 100644 --- a/packages/@stimulus/polyfills/package.json +++ b/packages/@stimulus/polyfills/package.json @@ -12,7 +12,8 @@ "dependencies": { "core-js": "^2.5.3", "element-closest": "^2.0.2", - "mutation-observer-inner-html-shim": "^1.0.0" + "mutation-observer-inner-html-shim": "^1.0.0", + "eventlistener-polyfill": "^1.0.5" }, "publishConfig": { "access": "public" diff --git a/packages/@stimulus/test/src/dom_test_case.ts b/packages/@stimulus/test/src/dom_test_case.ts index 01f4f632..f132e6f4 100644 --- a/packages/@stimulus/test/src/dom_test_case.ts +++ b/packages/@stimulus/test/src/dom_test_case.ts @@ -1,5 +1,15 @@ import { TestCase } from "./test_case" +interface TriggerEventOptions { + bubbles?: boolean, + setDefaultPrevented?: boolean +} + +const defaultTriggerEventOptions: TriggerEventOptions = { + bubbles: true, + setDefaultPrevented: true +} + export class DOMTestCase extends TestCase { fixtureSelector: string = "#qunit-fixture" fixtureHTML: string = "" @@ -23,14 +33,17 @@ export class DOMTestCase extends TestCase { } } - async triggerEvent(selectorOrTarget: string | EventTarget, type: string, bubbles: boolean = true) { + async triggerEvent(selectorOrTarget: string | EventTarget, type: string, options: TriggerEventOptions = {}) { + const { bubbles, setDefaultPrevented } = { ...defaultTriggerEventOptions, ...options } const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget const event = document.createEvent("Events") event.initEvent(type, bubbles, true) // IE <= 11 does not set `defaultPrevented` when `preventDefault()` is called on synthetic events - event.preventDefault = function() { - Object.defineProperty(this, "defaultPrevented", { get: () => true, configurable: true }) + if (setDefaultPrevented) { + event.preventDefault = function() { + Object.defineProperty(this, "defaultPrevented", { get: () => true, configurable: true }) + } } eventTarget.dispatchEvent(event) diff --git a/packages/karma.conf.js b/packages/karma.conf.js index 37165c56..5eabaf06 100644 --- a/packages/karma.conf.js +++ b/packages/karma.conf.js @@ -121,6 +121,6 @@ if (process.env.CI) { config.reporters = ["dots", "saucelabs"] } -module.exports = function(karmaConfig) { +module.exports = function (karmaConfig) { karmaConfig.set(config) -} +} \ No newline at end of file