From f42f2a4661671076afecee3aeb2a3f952ddbab97 Mon Sep 17 00:00:00 2001 From: kevin tchiadeu Date: Wed, 15 Jul 2026 07:42:31 +0200 Subject: [PATCH 1/2] feat: Add optional value to event options --- docs/reference/actions.md | 39 ++++++++++++++- src/core/action_descriptor.ts | 14 ++++-- src/tests/modules/core/event_options_tests.ts | 47 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/reference/actions.md b/docs/reference/actions.md index 6aa43db3..487699f3 100644 --- a/docs/reference/actions.md +++ b/docs/reference/actions.md @@ -209,6 +209,43 @@ application.registerActionOption("open", ({ event, value }) => { }) ``` +Beyond boolean values, a custom action option can also capture a string value by +wrapping it in parentheses. Declaring the option as `:name(value)` will yield a +`value` argument set to the string `"value"` in the callback. This is useful for +parameterizing an option, such as a `:throttled(500)` option that ignores events +arriving within a configurable number of milliseconds of the previous one: + + + +```html +
+
+``` + +```javascript +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +let lastInvokedAt = 0 + +application.registerActionOption("throttled", ({ value }) => { + const wait = Number(value) + const elapsed = Date.now() - lastInvokedAt + + if (elapsed > wait) { + lastInvokedAt = Date.now() + return true + } else { + return false + } +}) +``` + +Values captured this way are always passed as strings, so convert them to the +type you need (for example, with `Number(value)`) inside the callback. + 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`. @@ -218,7 +255,7 @@ 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`) | +| value | Boolean or String: The value of the option (`:open` yields `true`, `:!open` yields `false`, `:name(value)` yields the string `"value"`) | | event | [Event][]: The event instance, including with the `params` action parameters on the submitter element | | element | [Element]: The element where the action descriptor is declared | | controller | The `Controller` instance which would receive the method call | diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts index 35a4a211..819c557c 100644 --- a/src/core/action_descriptor.ts +++ b/src/core/action_descriptor.ts @@ -4,7 +4,7 @@ export type ActionDescriptorFilters = Record export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean type ActionDescriptorFilterOptions = { name: string - value: boolean + value: boolean | string event: Event element: Element controller: Controller @@ -74,9 +74,15 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined { } function parseEventOptions(eventOptions: string): AddEventListenerOptions { - return eventOptions - .split(":") - .reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {}) + return eventOptions.split(":").reduce((options, token) => { + const valueMatch = token.match(/^([^(]+)\(([^)]*)\)$/) + + if (valueMatch) { + return Object.assign(options, { [valueMatch[1]]: valueMatch[2] }) + } + + return Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }) + }, {}) } export function stringifyEventTarget(eventTarget: EventTarget) { diff --git a/src/tests/modules/core/event_options_tests.ts b/src/tests/modules/core/event_options_tests.ts index 45027cd3..97534411 100644 --- a/src/tests/modules/core/event_options_tests.ts +++ b/src/tests/modules/core/event_options_tests.ts @@ -255,6 +255,53 @@ export default class EventOptionsTests extends LogControllerTestCase { this.assertActions({ name: "log", eventType: "toggle" }) } + async "test custom option with parenthesized value"() { + let lastValue: boolean | string | undefined + + this.application.registerActionOption("throttled", ({ value }) => { + lastValue = value + return true + }) + await this.setAction(this.buttonElement, "click->c#log:throttled(500)") + + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions({ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }) + this.assert.equal(lastValue, "500") + } + + async "test custom option value alongside boolean options"() { + const received: { [key: string]: boolean | string } = {} + + this.application.registerActionOption("throttled", ({ value }) => { + received.throttled = value + return true + }) + this.application.registerActionOption("flag", ({ value }) => { + received.flag = value + return true + }) + await this.setAction(this.buttonElement, "click->c#log:throttled(500):flag") + + await this.triggerEvent(this.buttonElement, "click") + + this.assertActions({ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }) + this.assert.equal(received.throttled, "500") + this.assert.equal(received.flag, true) + } + + async "test custom option value controls whether action runs"() { + this.application.registerActionOption("enabled", ({ value }) => value === "yes") + + await this.setAction(this.buttonElement, "click->c#log:enabled(no)") + await this.triggerEvent(this.buttonElement, "click") + this.assertNoActions() + + await this.setAction(this.buttonElement, "click->c#log:enabled(yes)") + await this.triggerEvent(this.buttonElement, "click") + this.assertActions({ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement }) + } + async "test custom action option callback event contains params"() { let lastActionEventParams: Object = {} From fd201bc13e3402488f59d4cf41996e95e4168d48 Mon Sep 17 00:00:00 2001 From: kevin tchiadeu Date: Wed, 15 Jul 2026 07:55:19 +0200 Subject: [PATCH 2/2] keep_original_syntax --- src/core/action_descriptor.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts index 819c557c..a40a5054 100644 --- a/src/core/action_descriptor.ts +++ b/src/core/action_descriptor.ts @@ -74,15 +74,17 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined { } function parseEventOptions(eventOptions: string): AddEventListenerOptions { - return eventOptions.split(":").reduce((options, token) => { - const valueMatch = token.match(/^([^(]+)\(([^)]*)\)$/) + return eventOptions + .split(":") + .reduce((options, token) => { + const valueMatch = token.match(/^([^(]+)\(([^)]*)\)$/) - if (valueMatch) { - return Object.assign(options, { [valueMatch[1]]: valueMatch[2] }) - } + if (valueMatch) { + return Object.assign(options, { [valueMatch[1]]: valueMatch[2] }) + } - return Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }) - }, {}) + return Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }) + }, {}) } export function stringifyEventTarget(eventTarget: EventTarget) {