Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion docs/reference/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<meta data-controller="callout" data-callout-text-value=":throttled(500)">

```html
<div data-controller="gallery"
data-action="scroll->gallery#layout:throttled(500)">
</div>
```

```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`.
Expand All @@ -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 |
Expand Down
12 changes: 10 additions & 2 deletions src/core/action_descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type ActionDescriptorFilters = Record<string, ActionDescriptorFilter>
export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean
type ActionDescriptorFilterOptions = {
name: string
value: boolean
value: boolean | string
event: Event
element: Element
controller: Controller<Element>
Expand Down Expand Up @@ -76,7 +76,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) }), {})
.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) {
Expand Down
47 changes: 47 additions & 0 deletions src/tests/modules/core/event_options_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down