From da425c43c8293af860105fdd1647832fa63416c9 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Thu, 28 Jul 2022 16:45:22 -0400 Subject: [PATCH] Support custom Action Options (#567) * Support custom Action Options As a follow-up to [hotwired/stimulus#535][] and [hotwired/stimulus#546][], add support for declaring custom action modifiers in the same style as `:prevent`, `:stop`, and `:self`. Take, for example, the [toggle][] event. It's dispatched whenever a `
` element toggles either open or closed. If an application were able to declare a custom `open` modifier, it could choose to route `toggle` events denoted with `:open` _only_ when the `
`. Inversely, they could choose to route `toggle` events denoted with `:!open` _only_ when the `
` does not have `[open]`. Similarly, the same kind of customization could apply to custom events. For example, the [turbo:submit-end][turbo-events] fires after a `
` element submits, but does not distinguish between success or failure. A `:success` modifier could skip events with an unsuccessful HTTP response code. [hotwired/stimulus#535]: https://github.com/hotwired/stimulus/pull/535 [hotwired/stimulus#546]: https://github.com/hotwired/stimulus/pull/546 [toggle]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement/toggle_event [turbo-events]: https://turbo.hotwired.dev/reference/events * attempt to pass on Safari@14 failing test: https://github.com/hotwired/stimulus/runs/7566084180?check_suite_focus=true#step:6:138 --- docs/reference/actions.md | 55 +++++++++ src/core/action.ts | 5 +- src/core/action_descriptor.ts | 37 +++++- src/core/application.ts | 7 ++ src/core/binding.ts | 40 +++---- src/core/event_modifiers.ts | 5 - src/tests/cases/application_test_case.ts | 3 +- src/tests/modules/core/event_options_tests.ts | 105 +++++++++++------- 8 files changed, 181 insertions(+), 76 deletions(-) delete mode 100644 src/core/event_modifiers.ts diff --git a/docs/reference/actions.md b/docs/reference/actions.md index ab4f9fa6..41a88c70 100644 --- a/docs/reference/actions.md +++ b/docs/reference/actions.md @@ -109,6 +109,61 @@ Custom action option | Description `:prevent` | calls `.preventDefault()` on the event before invoking the method `:self` | only invokes the method if the event was fired by the element itself +You can register your own action options with the `Application.registerActionOption` method. + +For example, consider that a `
` element will dispatch a [toggle][] +event whenever it's toggled. A custom `:open` action option would help +to route events whenever the element is toggled _open_: + +```javascript +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +application.registerActionOption("open", ({ event }) => { + if (event.type == "toggle") { + return event.target.open == true + } else { + return true + } +}) +``` + +Similarly, a custom `:!open` action option could route events whenever the +element is toggled _closed_. Declaring the action descriptor option with a `!` +prefix will yield a `value` argument set to `false` in the callback: + +```javascript +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +application.registerActionOption("open", ({ event, value }) => { + if (event.type == "toggle") { + return event.target.open == value + } else { + return true + } +}) +``` + +In order to prevent the event from being routed to the controller action, the +`registerActionOption` callback function must return `false`. Otherwise, to +route the event to the controller action, return `true`. + +The callback accepts a single object argument with the following keys: + +Name | Description +--------|------------ +name | String: The option's name (`"open"` in the example above) +value | Boolean: The value of the option (`:open` would yield `true`, `:!open` would yield `false`) +event | [Event][]: The event instance +element | [Element]: The element where the action descriptor is declared + +[toggle]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement/toggle_event +[Event]: https://developer.mozilla.org/en-US/docs/web/api/event +[Element]: https://developer.mozilla.org/en-US/docs/Web/API/element + ## 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 25aebbf3..63b59dde 100644 --- a/src/core/action.ts +++ b/src/core/action.ts @@ -1,14 +1,12 @@ 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: EventModifiers + readonly eventOptions: AddEventListenerOptions readonly identifier: string readonly methodName: string @@ -78,4 +76,3 @@ function typecast(value: any): any { return value } } - diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts index 8ddeef44..260ae31a 100644 --- a/src/core/action_descriptor.ts +++ b/src/core/action_descriptor.ts @@ -1,13 +1,42 @@ -import { EventModifiers } from "./event_modifiers" - export interface ActionDescriptor { eventTarget: EventTarget - eventOptions: EventModifiers + eventOptions: AddEventListenerOptions eventName: string identifier: string methodName: string } +export type ActionDescriptorFilters = Record +export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean +type ActionDescriptorFilterOptions = { + name: string + value: boolean + event: Event + element: Element +} + +export const defaultActionDescriptorFilters: ActionDescriptorFilters = { + stop({ event, value }) { + if (value) event.stopPropagation() + + return true + }, + + prevent({ event, value }) { + if (value) event.preventDefault() + + return true + }, + + self({ event, value, element }) { + if (value) { + return element === event.target + } else { + return true + } + } +} + // capture nos.: 12 23 4 43 1 5 56 7 768 9 98 const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/ @@ -31,7 +60,7 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined { } } -function parseEventOptions(eventOptions: string): EventModifiers { +function parseEventOptions(eventOptions: string): AddEventListenerOptions { return eventOptions.split(":").reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }) , {}) diff --git a/src/core/application.ts b/src/core/application.ts index 6494551c..0efd01fc 100644 --- a/src/core/application.ts +++ b/src/core/application.ts @@ -5,12 +5,14 @@ import { ErrorHandler } from "./error_handler" import { Logger } from "./logger" import { Router } from "./router" import { Schema, defaultSchema } from "./schema" +import { ActionDescriptorFilter, ActionDescriptorFilters, defaultActionDescriptorFilters } from "./action_descriptor" export class Application implements ErrorHandler { readonly element: Element readonly schema: Schema readonly dispatcher: Dispatcher readonly router: Router + readonly actionDescriptorFilters: ActionDescriptorFilters logger: Logger = console debug: boolean = false @@ -25,6 +27,7 @@ export class Application implements ErrorHandler { this.schema = schema this.dispatcher = new Dispatcher(this) this.router = new Router(this) + this.actionDescriptorFilters = { ...defaultActionDescriptorFilters } } async start() { @@ -46,6 +49,10 @@ export class Application implements ErrorHandler { this.load({ identifier, controllerConstructor }) } + registerActionOption(name: string, filter: ActionDescriptorFilter) { + this.actionDescriptorFilters[name] = filter + } + load(...definitions: Definition[]): void load(definitions: Definition[]): void load(head: Definition | Definition[], ...rest: Definition[]) { diff --git a/src/core/binding.ts b/src/core/binding.ts index 643d9506..b33b9755 100644 --- a/src/core/binding.ts +++ b/src/core/binding.ts @@ -3,8 +3,6 @@ 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 readonly action: Action @@ -22,7 +20,7 @@ export class Binding { return this.action.eventTarget } - get eventOptions(): EventModifiers { + get eventOptions(): AddEventListenerOptions { return this.action.eventOptions } @@ -31,10 +29,7 @@ export class Binding { } handleEvent(event: Event) { - if (this.willBeInvokedByEvent(event) && this.shouldBeInvokedPerSelf(event)) { - this.processStopPropagation(event); - this.processPreventDefault(event); - + if (this.willBeInvokedByEvent(event) && this.applyEventModifiers(event)) { this.invokeWithEvent(event) } } @@ -51,16 +46,23 @@ 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 applyEventModifiers(event: Event): boolean { + const { element } = this.action + const { actionDescriptorFilters } = this.context.application + + let passes = true + + for (const [name, value] of Object.entries(this.eventOptions)) { + if (name in actionDescriptorFilters) { + const filter = actionDescriptorFilters[name] - private processPreventDefault(event: Event) { - if (this.eventOptions.prevent) { - event.preventDefault(); + passes = passes && filter({ name, value, event, element }) + } else { + continue + } } + + return passes } private invokeWithEvent(event: Event) { @@ -77,14 +79,6 @@ export class Binding { } } - private shouldBeInvokedPerSelf(event: Event): boolean { - if (this.action.eventOptions.self === true) { - return this.action.element === event.target - } else { - return true - } - } - private willBeInvokedByEvent(event: Event): boolean { const eventTarget = event.target if (this.element === eventTarget) { diff --git a/src/core/event_modifiers.ts b/src/core/event_modifiers.ts deleted file mode 100644 index d4a1a2a7..00000000 --- a/src/core/event_modifiers.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface EventModifiers extends AddEventListenerOptions { - stop?: boolean; - prevent?: boolean; - self?: boolean; -} diff --git a/src/tests/cases/application_test_case.ts b/src/tests/cases/application_test_case.ts index 5c8dfcdf..f8a6a30d 100644 --- a/src/tests/cases/application_test_case.ts +++ b/src/tests/cases/application_test_case.ts @@ -10,10 +10,11 @@ class TestApplication extends Application { export class ApplicationTestCase extends DOMTestCase { schema: Schema = defaultSchema - application: Application = new TestApplication(this.fixtureElement, this.schema) + application!: Application async runTest(testName: string) { try { + this.application = new TestApplication(this.fixtureElement, this.schema) this.setupApplication() this.application.start() await super.runTest(testName) diff --git a/src/tests/modules/core/event_options_tests.ts b/src/tests/modules/core/event_options_tests.ts index 459f42b2..5aa48541 100644 --- a/src/tests/modules/core/event_options_tests.ts +++ b/src/tests/modules/core/event_options_tests.ts @@ -5,13 +5,13 @@ export default class EventOptionsTests extends LogControllerTestCase { fixtureHTML = `
+
` async "test different syntaxes for once action"() { - this.setAction(this.buttonElement, "click->c#log:once d#log2:once c#log3:once") + await this.setAction(this.buttonElement, "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") @@ -23,9 +23,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test mix once and standard actions"() { - this.setAction(this.buttonElement, "c#log:once d#log2 c#log3") + await this.setAction(this.buttonElement, "c#log:once d#log2 c#log3") - await this.nextFrame await this.triggerEvent(this.buttonElement, "click") await this.triggerEvent(this.buttonElement, "click") @@ -39,9 +38,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test stop propagation with once"() { - this.setAction(this.buttonElement, "c#stop:once c#log") + await this.setAction(this.buttonElement, "c#stop:once c#log") - await this.nextFrame await this.triggerEvent(this.buttonElement, "click") this.assertActions( @@ -57,9 +55,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test global once actions"() { - this.setAction(this.buttonElement, "keydown@window->c#log:once") + await this.setAction(this.buttonElement, "keydown@window->c#log:once") - await this.nextFrame await this.triggerEvent("#outside", "keydown") await this.triggerEvent("#outside", "keydown") @@ -67,14 +64,13 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test edge case when updating action list with setAttribute preserves once history"() { - this.setAction(this.buttonElement, "c#log:once") - await this.nextFrame + await this.setAction(this.buttonElement, "c#log:once") + 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.setAction(this.buttonElement, "c#log2 c#log:once d#log") - await this.nextFrame + await this.setAction(this.buttonElement, "c#log2 c#log:once d#log") await this.triggerEvent(this.buttonElement, "click") this.assertActions( @@ -85,16 +81,14 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test default passive action"() { - this.setAction(this.buttonElement, "scroll->c#logPassive:passive") - await this.nextFrame + await this.setAction(this.buttonElement, "scroll->c#logPassive:passive") await this.triggerEvent(this.buttonElement, "scroll", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "scroll", passive: true }) } async "test global passive actions"() { - this.setAction(this.buttonElement, "mouseup@window->c#logPassive:passive") - await this.nextFrame + await this.setAction(this.buttonElement, "mouseup@window->c#logPassive:passive") await this.triggerEvent("#outside", "mouseup", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "mouseup", passive: true }) @@ -102,8 +96,7 @@ export default class EventOptionsTests extends LogControllerTestCase { async "test passive false actions"() { // by default touchmove is true in chrome - this.setAction(this.buttonElement, "touchmove@window->c#logPassive:!passive") - await this.nextFrame + await this.setAction(this.buttonElement, "touchmove@window->c#logPassive:!passive") await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false }) @@ -111,8 +104,7 @@ export default class EventOptionsTests extends LogControllerTestCase { async "test multiple options"() { // by default touchmove is true in chrome - this.setAction(this.buttonElement, "touchmove@window->c#logPassive:once:!passive") - await this.nextFrame + await this.setAction(this.buttonElement, "touchmove@window->c#logPassive:once:!passive") await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false }) @@ -120,8 +112,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test wrong options are silently ignored"() { - this.setAction(this.buttonElement, "c#log:wrong:verywrong") - await this.nextFrame + await this.setAction(this.buttonElement, "c#log:wrong:verywrong") + await this.triggerEvent(this.buttonElement, "click") await this.triggerEvent(this.buttonElement, "click") @@ -132,9 +124,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test stop option with implicit event"() { - this.setAction(this.element, "click->c#log") - this.setAction(this.buttonElement, "c#log2:stop") - await this.nextFrame + await this.setAction(this.element, "click->c#log") + await this.setAction(this.buttonElement, "c#log2:stop") await this.triggerEvent(this.buttonElement, "click") @@ -144,9 +135,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test stop option with explicit event"() { - this.setAction(this.element, "keydown->c#log") - this.setAction(this.buttonElement, "keydown->c#log2:stop") - await this.nextFrame + await this.setAction(this.element, "keydown->c#log") + await this.setAction(this.buttonElement, "keydown->c#log2:stop") await this.triggerEvent(this.buttonElement, "keydown") @@ -156,9 +146,8 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test event propagation without stop option"() { - this.setAction(this.element, "click->c#log") - this.setAction(this.buttonElement, "c#log2") - await this.nextFrame + await this.setAction(this.element, "click->c#log") + await this.setAction(this.buttonElement, "c#log2") await this.triggerEvent(this.buttonElement, "click") @@ -169,8 +158,7 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test prevent option with implicit event"() { - this.setAction(this.buttonElement, "c#log:prevent") - await this.nextFrame + await this.setAction(this.buttonElement, "c#log:prevent") await this.triggerEvent(this.buttonElement, "click") @@ -180,8 +168,7 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test prevent option with explicit event"() { - this.setAction(this.buttonElement, "keyup->c#log:prevent") - await this.nextFrame + await this.setAction(this.buttonElement, "keyup->c#log:prevent") await this.triggerEvent(this.buttonElement, "keyup") @@ -191,8 +178,7 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test self option"() { - this.setAction(this.buttonElement, "click->c#log:self") - await this.nextFrame + await this.setAction(this.buttonElement, "click->c#log:self") await this.triggerEvent(this.buttonElement, "click") @@ -202,16 +188,53 @@ export default class EventOptionsTests extends LogControllerTestCase { } async "test self option on parent"() { - this.setAction(this.element, "click->c#log:self") - await this.nextFrame + await this.setAction(this.element, "click->c#log:self") await this.triggerEvent(this.buttonElement, "click") this.assertNoActions() } + async "test custom option"() { + this.application.registerActionOption("open", ({ value, event: { type, target } }) => { + switch (type) { + case "toggle": return target instanceof HTMLDetailsElement && target.open == value + default: return true + } + }) + await this.setAction(this.detailsElement, "toggle->c#log:open") + + await this.toggleElement(this.detailsElement) + await this.toggleElement(this.detailsElement) + await this.toggleElement(this.detailsElement) + + this.assertActions({ name: "log", eventType: "toggle" }, { name: "log", eventType: "toggle" }) + } + + async "test inverted custom option"() { + this.application.registerActionOption("open", ({ value, event: { type, target } }) => { + switch (type) { + case "toggle": return target instanceof HTMLDetailsElement && target.open == value + default: return true + } + }) + await this.setAction(this.detailsElement, "toggle->c#log:!open") + + await this.toggleElement(this.detailsElement) + await this.toggleElement(this.detailsElement) + await this.toggleElement(this.detailsElement) + + this.assertActions({ name: "log", eventType: "toggle" }) + } + setAction(element: Element, value: string) { element.setAttribute("data-action", value) + return this.nextFrame + } + + toggleElement(details: Element) { + details.toggleAttribute("open") + return this.nextFrame } get element() { @@ -221,4 +244,8 @@ export default class EventOptionsTests extends LogControllerTestCase { get buttonElement() { return this.findElement("button") } + + get detailsElement() { + return this.findElement("details") + } }