diff --git a/docs/reference/actions.md b/docs/reference/actions.md index 1b28f452..45366577 100644 --- a/docs/reference/actions.md +++ b/docs/reference/actions.md @@ -101,6 +101,13 @@ Action option | DOM event listener option `:passive` | `{ passive: true }` `:!passive` | `{ passive: false }` +On top of that, Stimulus also supports the following action options which are not natively supported by the DOM event listener options: + +Custom action option | Description +-------------------- | ----------- +`:stop` | calls `.stopPropagation()` on the event before invoking the method +`:prevent` | calls `.preventDefault()` on the event before invoking the method + ## Event Objects An _action method_ is the method in a controller which serves as an action's event listener. diff --git a/src/core/action.ts b/src/core/action.ts index ab60d692..25aebbf3 100644 --- a/src/core/action.ts +++ b/src/core/action.ts @@ -1,13 +1,14 @@ import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } from "./action_descriptor" import { Token } from "../mutation-observers" import { camelize } from "./string_helpers" +import { EventModifiers } from "./event_modifiers" export class Action { readonly element: Element readonly index: number readonly eventTarget: EventTarget readonly eventName: string - readonly eventOptions: AddEventListenerOptions + readonly eventOptions: EventModifiers readonly identifier: string readonly methodName: string diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts index da6a32d1..8ddeef44 100644 --- a/src/core/action_descriptor.ts +++ b/src/core/action_descriptor.ts @@ -1,6 +1,8 @@ +import { EventModifiers } from "./event_modifiers" + export interface ActionDescriptor { eventTarget: EventTarget - eventOptions: AddEventListenerOptions + eventOptions: EventModifiers eventName: string identifier: string methodName: string @@ -29,7 +31,7 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined { } } -function parseEventOptions(eventOptions: string): AddEventListenerOptions { +function parseEventOptions(eventOptions: string): EventModifiers { return eventOptions.split(":").reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }) , {}) diff --git a/src/core/binding.ts b/src/core/binding.ts index d8f1a6eb..5e5fe768 100644 --- a/src/core/binding.ts +++ b/src/core/binding.ts @@ -3,6 +3,7 @@ import { ActionEvent } from "./action_event" import { Context } from "./context" import { Controller } from "./controller" import { Scope } from "./scope" +import { EventModifiers } from "./event_modifiers" export class Binding { readonly context: Context @@ -21,7 +22,7 @@ export class Binding { return this.action.eventTarget } - get eventOptions(): AddEventListenerOptions { + get eventOptions(): EventModifiers { return this.action.eventOptions } @@ -31,6 +32,9 @@ export class Binding { handleEvent(event: Event) { if (this.willBeInvokedByEvent(event)) { + this.processStopPropagation(event); + this.processPreventDefault(event); + this.invokeWithEvent(event) } } @@ -47,6 +51,18 @@ export class Binding { throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`) } + private processStopPropagation(event: Event) { + if (this.eventOptions.stop) { + event.stopPropagation(); + } + } + + private processPreventDefault(event: Event) { + if (this.eventOptions.prevent) { + event.preventDefault(); + } + } + private invokeWithEvent(event: Event) { const { target, currentTarget } = event try { diff --git a/src/core/event_modifiers.ts b/src/core/event_modifiers.ts new file mode 100644 index 00000000..e105f4a9 --- /dev/null +++ b/src/core/event_modifiers.ts @@ -0,0 +1,4 @@ +export interface EventModifiers extends AddEventListenerOptions { + stop?: boolean; + prevent?: boolean; +} diff --git a/src/tests/modules/core/event_options_tests.ts b/src/tests/modules/core/event_options_tests.ts index 4a955f69..0dbd178e 100644 --- a/src/tests/modules/core/event_options_tests.ts +++ b/src/tests/modules/core/event_options_tests.ts @@ -131,10 +131,73 @@ export default class EventOptionsTests extends LogControllerTestCase { ) } + async "test stop option with implicit event"() { + this.elementActionValue = "click->c#log" + this.actionValue = "c#log2:stop" + await this.nextFrame + + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log2", eventType: "click" } + ) + } + + async "test stop option with explicit event"() { + this.elementActionValue = "keydown->c#log" + this.actionValue = "keydown->c#log2:stop" + await this.nextFrame + + await this.triggerEvent(this.buttonElement, "keydown") + + this.assertActions( + { name: "log2", eventType: "keydown" } + ) + } + + async "test event propagation without stop option"() { + this.elementActionValue = "click->c#log" + this.actionValue = "c#log2" + await this.nextFrame + + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log2", eventType: "click" }, + { name: "log", eventType: "click" } + ) + } + + async "test prevent option with implicit event"() { + this.actionValue = "c#log:prevent" + await this.nextFrame + + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions( + { name: "log", eventType: "click", defaultPrevented: true } + ) + } + + async "test prevent option with explicit event"() { + this.actionValue = "keyup->c#log:prevent" + await this.nextFrame + + await this.triggerEvent(this.buttonElement, "keyup") + + this.assertActions( + { name: "log", eventType: "keyup", defaultPrevented: true } + ) + } + set actionValue(value: string) { this.buttonElement.setAttribute("data-action", value) } + set elementActionValue(value: string) { + this.element.setAttribute("data-action", value) + } + get element() { return this.findElement("div") }