Skip to content

Commit 11837fd

Browse files
committed
Support custom Action Options
As a follow-up to [#535][] and [#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 `<details>` 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 `<details open>`. Inversely, they could choose to route `toggle` events denoted with `:!open` _only_ when the `<details>` 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 `<form>` element submits, but does not distinguish between success or failure. A `:success` modifier could skip events with an unsuccessful HTTP response code. [#535]: #535 [#546]: #546 [toggle]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement/toggle_event [turbo-events]: https://turbo.hotwired.dev/reference/events
1 parent 0c9f628 commit 11837fd

File tree

8 files changed

+160
-36
lines changed

8 files changed

+160
-36
lines changed

docs/reference/actions.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,61 @@ Custom action option | Description
109109
`:prevent` | calls `.preventDefault()` on the event before invoking the method
110110
`:self` | only invokes the method if the event was fired by the element itself
111111

112+
You can register your own action options with the `Application.registerActionOption` method.
113+
114+
For example, consider that a `<details>` element will dispatch a [toggle][]
115+
event whenever it's toggled. A custom `:open` action option would help
116+
to route events whenever the element is toggled _open_:
117+
118+
```javascript
119+
import { Application } from "@hotwired/stimulus"
120+
121+
const application = Application.start()
122+
123+
application.registerActionOption("open", ({ event }) => {
124+
if (event.type == "toggle") {
125+
return event.target.open == true
126+
} else {
127+
return true
128+
}
129+
})
130+
```
131+
132+
Similarly, a custom `:!open` action option could route events whenever the
133+
element is toggled _closed_. Declaring the action descriptor option with a `!`
134+
prefix will yield a `value` argument set to `false` in the callback:
135+
136+
```javascript
137+
import { Application } from "@hotwired/stimulus"
138+
139+
const application = Application.start()
140+
141+
application.registerActionOption("open", ({ event, value }) => {
142+
if (event.type == "toggle") {
143+
return event.target.open == value
144+
} else {
145+
return true
146+
}
147+
})
148+
```
149+
150+
In order to prevent the event from being routed to the controller action, the
151+
`registerActionOption` callback function must return `false`. Otherwise, to
152+
route the event to the controller action, return `true`.
153+
154+
The callback accepts a single object argument with the following keys:
155+
156+
Name | Description
157+
--------|------------
158+
name | String: The option's name (`"open"` in the example above)
159+
value | Boolean: The value of the option (`:open` would yield `true`, `:!open` would yield `false`)
160+
event | [Event][]: The event instance
161+
element | [Element]: The element where the action descriptor is declared
162+
163+
[toggle]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLDetailsElement/toggle_event
164+
[Event]: https://developer.mozilla.org/en-US/docs/web/api/event
165+
[Element]: https://developer.mozilla.org/en-US/docs/Web/API/element
166+
112167
## Event Objects
113168

114169
An _action method_ is the method in a controller which serves as an action's event listener.

src/core/action.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } from "./action_descriptor"
22
import { Token } from "../mutation-observers"
33
import { camelize } from "./string_helpers"
4-
import { EventModifiers } from "./event_modifiers"
5-
64
export class Action {
75
readonly element: Element
86
readonly index: number
97
readonly eventTarget: EventTarget
108
readonly eventName: string
11-
readonly eventOptions: EventModifiers
9+
readonly eventOptions: AddEventListenerOptions
1210
readonly identifier: string
1311
readonly methodName: string
1412

@@ -78,4 +76,3 @@ function typecast(value: any): any {
7876
return value
7977
}
8078
}
81-

src/core/action_descriptor.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,42 @@
1-
import { EventModifiers } from "./event_modifiers"
2-
31
export interface ActionDescriptor {
42
eventTarget: EventTarget
5-
eventOptions: EventModifiers
3+
eventOptions: AddEventListenerOptions
64
eventName: string
75
identifier: string
86
methodName: string
97
}
108

9+
export type ActionDescriptorFilters = Record<string, ActionDescriptorFilter>
10+
export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean
11+
type ActionDescriptorFilterOptions = {
12+
name: string
13+
value: boolean
14+
event: Event
15+
element: Element
16+
}
17+
18+
export const defaultActionDescriptorFilters: ActionDescriptorFilters = {
19+
stop({ event, value }) {
20+
if (value) event.stopPropagation()
21+
22+
return true
23+
},
24+
25+
prevent({ event, value }) {
26+
if (value) event.preventDefault()
27+
28+
return true
29+
},
30+
31+
self({ event, value, element }) {
32+
if (value) {
33+
return element === event.target
34+
} else {
35+
return true
36+
}
37+
}
38+
}
39+
1140
// capture nos.: 12 23 4 43 1 5 56 7 768 9 98
1241
const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/
1342

@@ -31,7 +60,7 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined {
3160
}
3261
}
3362

34-
function parseEventOptions(eventOptions: string): EventModifiers {
63+
function parseEventOptions(eventOptions: string): AddEventListenerOptions {
3564
return eventOptions.split(":").reduce((options, token) =>
3665
Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) })
3766
, {})

src/core/application.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { ErrorHandler } from "./error_handler"
55
import { Logger } from "./logger"
66
import { Router } from "./router"
77
import { Schema, defaultSchema } from "./schema"
8+
import { ActionDescriptorFilter, ActionDescriptorFilters, defaultActionDescriptorFilters } from "./action_descriptor"
89

910
export class Application implements ErrorHandler {
1011
readonly element: Element
1112
readonly schema: Schema
1213
readonly dispatcher: Dispatcher
1314
readonly router: Router
15+
readonly actionDescriptorFilters: ActionDescriptorFilters
1416
logger: Logger = console
1517
debug: boolean = false
1618

@@ -25,6 +27,7 @@ export class Application implements ErrorHandler {
2527
this.schema = schema
2628
this.dispatcher = new Dispatcher(this)
2729
this.router = new Router(this)
30+
this.actionDescriptorFilters = { ...defaultActionDescriptorFilters }
2831
}
2932

3033
async start() {
@@ -46,6 +49,10 @@ export class Application implements ErrorHandler {
4649
this.load({ identifier, controllerConstructor })
4750
}
4851

52+
registerActionOption(name: string, filter: ActionDescriptorFilter) {
53+
this.actionDescriptorFilters[name] = filter
54+
}
55+
4956
load(...definitions: Definition[]): void
5057
load(definitions: Definition[]): void
5158
load(head: Definition | Definition[], ...rest: Definition[]) {

src/core/binding.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import { ActionEvent } from "./action_event"
33
import { Context } from "./context"
44
import { Controller } from "./controller"
55
import { Scope } from "./scope"
6-
import { EventModifiers } from "./event_modifiers"
7-
86
export class Binding {
97
readonly context: Context
108
readonly action: Action
@@ -22,7 +20,7 @@ export class Binding {
2220
return this.action.eventTarget
2321
}
2422

25-
get eventOptions(): EventModifiers {
23+
get eventOptions(): AddEventListenerOptions {
2624
return this.action.eventOptions
2725
}
2826

@@ -31,10 +29,7 @@ export class Binding {
3129
}
3230

3331
handleEvent(event: Event) {
34-
if (this.willBeInvokedByEvent(event) && this.shouldBeInvokedPerSelf(event)) {
35-
this.processStopPropagation(event);
36-
this.processPreventDefault(event);
37-
32+
if (this.willBeInvokedByEvent(event) && this.applyEventModifiers(event)) {
3833
this.invokeWithEvent(event)
3934
}
4035
}
@@ -51,16 +46,25 @@ export class Binding {
5146
throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`)
5247
}
5348

54-
private processStopPropagation(event: Event) {
55-
if (this.eventOptions.stop) {
56-
event.stopPropagation();
57-
}
49+
private get actionDescriptorFilters() {
50+
return this.context.application.actionDescriptorFilters
5851
}
5952

60-
private processPreventDefault(event: Event) {
61-
if (this.eventOptions.prevent) {
62-
event.preventDefault();
53+
private applyEventModifiers(event: Event): boolean {
54+
const { element } = this.action
55+
let passes = true
56+
57+
for (const [name, value] of Object.entries(this.eventOptions)) {
58+
if (name in this.actionDescriptorFilters) {
59+
const filter = this.actionDescriptorFilters[name]
60+
61+
passes = passes && filter({ name, value, event, element })
62+
} else {
63+
continue
64+
}
6365
}
66+
67+
return passes
6468
}
6569

6670
private invokeWithEvent(event: Event) {
@@ -77,14 +81,6 @@ export class Binding {
7781
}
7882
}
7983

80-
private shouldBeInvokedPerSelf(event: Event): boolean {
81-
if (this.action.eventOptions.self === true) {
82-
return this.action.element === event.target
83-
} else {
84-
return true
85-
}
86-
}
87-
8884
private willBeInvokedByEvent(event: Event): boolean {
8985
const eventTarget = event.target
9086
if (this.element === eventTarget) {

src/core/event_modifiers.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/tests/cases/application_test_case.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ class TestApplication extends Application {
1010

1111
export class ApplicationTestCase extends DOMTestCase {
1212
schema: Schema = defaultSchema
13-
application: Application = new TestApplication(this.fixtureElement, this.schema)
13+
application!: Application
1414

1515
async runTest(testName: string) {
1616
try {
17+
this.application = new TestApplication(this.fixtureElement, this.schema)
1718
this.setupApplication()
1819
this.application.start()
1920
await super.runTest(testName)

src/tests/modules/core/event_options_tests.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default class EventOptionsTests extends LogControllerTestCase {
55
fixtureHTML = `
66
<div data-controller="c d">
77
<button></button>
8+
<details></details>
89
</div>
910
<div id="outside"></div>
1011
`
@@ -210,15 +211,58 @@ export default class EventOptionsTests extends LogControllerTestCase {
210211
this.assertNoActions()
211212
}
212213

214+
async "test custom option"() {
215+
this.application.registerActionOption("open", ({ value, event: { type, target } }) => {
216+
switch (type) {
217+
case "toggle": return target instanceof HTMLDetailsElement && target.open == value
218+
default: return true
219+
}
220+
})
221+
this.setAction(this.detailsElement, "toggle->c#log:open")
222+
223+
await this.nextFrame
224+
await this.toggleElement(this.detailsElement)
225+
await this.toggleElement(this.detailsElement)
226+
await this.toggleElement(this.detailsElement)
227+
228+
this.assertActions({ name: "log", eventType: "toggle" }, { name: "log", eventType: "toggle" })
229+
}
230+
231+
async "test inverted custom option"() {
232+
this.application.registerActionOption("open", ({ value, event: { type, target } }) => {
233+
switch (type) {
234+
case "toggle": return target instanceof HTMLDetailsElement && target.open == value
235+
default: return true
236+
}
237+
})
238+
this.setAction(this.detailsElement, "toggle->c#log:!open")
239+
240+
await this.nextFrame
241+
await this.toggleElement(this.detailsElement)
242+
await this.toggleElement(this.detailsElement)
243+
await this.toggleElement(this.detailsElement)
244+
245+
this.assertActions({ name: "log", eventType: "toggle" })
246+
}
247+
213248
setAction(element: Element, value: string) {
214249
element.setAttribute("data-action", value)
215250
}
216251

252+
toggleElement(details: Element) {
253+
details.toggleAttribute("open")
254+
return this.nextFrame
255+
}
256+
217257
get element() {
218258
return this.findElement("div")
219259
}
220260

221261
get buttonElement() {
222262
return this.findElement("button")
223263
}
264+
265+
get detailsElement() {
266+
return this.findElement("details")
267+
}
224268
}

0 commit comments

Comments
 (0)