From 98e081535a14d324adef4ae9e5dd6cb48a5d3a22 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Thu, 28 Jul 2022 13:17:07 -0400 Subject: [PATCH] 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 --- docs/reference/actions.md | 67 +++++++++++++++++-- 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 | 44 ++++++++++++ 8 files changed, 166 insertions(+), 42 deletions(-) delete mode 100644 src/core/event_modifiers.ts diff --git a/docs/reference/actions.md b/docs/reference/actions.md index ab4f9fa6..6bd6d60e 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. @@ -202,10 +257,10 @@ Data attribute | Param | Type ```html
-
``` @@ -216,13 +271,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) } ``` @@ -231,7 +286,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/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..f79a776d 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}: ActionDescriptorFilterOptions): boolean => { + if (value) event.stopPropagation() + + return true + }, + + prevent: ({ event, value }: ActionDescriptorFilterOptions): boolean => { + if (value) event.preventDefault() + + return true + }, + + self: ({ event, value, element }: ActionDescriptorFilterOptions): boolean => { + 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..077c7b0d 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,25 @@ 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 get actionDescriptorFilters() { + return this.context.application.actionDescriptorFilters } - private processPreventDefault(event: Event) { - if (this.eventOptions.prevent) { - event.preventDefault(); + private applyEventModifiers(event: Event): boolean { + const { element } = this.action + let passes = true + + for (const [name, value] of Object.entries(this.eventOptions)) { + if (name in this.actionDescriptorFilters && typeof value !== "undefined") { + const filter = this.actionDescriptorFilters[name] + + passes = passes && filter({ name, value, event, element }) + } else { + continue + } } + + return passes } private invokeWithEvent(event: Event) { @@ -77,14 +81,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..bc7c6cee 100644 --- a/src/tests/modules/core/event_options_tests.ts +++ b/src/tests/modules/core/event_options_tests.ts @@ -5,6 +5,7 @@ export default class EventOptionsTests extends LogControllerTestCase { fixtureHTML = `
+
` @@ -210,10 +211,49 @@ export default class EventOptionsTests extends LogControllerTestCase { 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 + } + }) + this.setAction(this.detailsElement, "toggle->c#log:open") + + await this.nextFrame + 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 + } + }) + this.setAction(this.detailsElement, "toggle->c#log:!open") + + await this.nextFrame + 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) } + toggleElement(details: Element) { + details.toggleAttribute("open") + return this.nextFrame + } + get element() { return this.findElement("div") } @@ -221,4 +261,8 @@ export default class EventOptionsTests extends LogControllerTestCase { get buttonElement() { return this.findElement("button") } + + get detailsElement() { + return this.findElement("details") + } }