Skip to content

Commit

Permalink
Support custom Action Options
Browse files Browse the repository at this point in the history
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
[turbo-events]: https://turbo.hotwired.dev/reference/events
  • Loading branch information
seanpdoyle committed Jul 28, 2022
1 parent 0c9f628 commit a301681
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 42 deletions.
67 changes: 61 additions & 6 deletions docs/reference/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>` 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.
Expand Down Expand Up @@ -202,10 +257,10 @@ Data attribute | Param | Type

```html
<div data-controller="item spinner">
<button data-action="item#upvote spinner#start"
data-item-id-param="12345"
<button data-action="item#upvote spinner#start"
data-item-id-param="12345"
data-item-url-param="/votes"
data-item-payload-param='{"value":"1234567"}'
data-item-payload-param='{"value":"1234567"}'
data-item-active-param="true">…</button>
</div>
```
Expand All @@ -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)
}
```

Expand All @@ -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)
}
```

Expand Down
5 changes: 1 addition & 4 deletions src/core/action.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -78,4 +76,3 @@ function typecast(value: any): any {
return value
}
}

37 changes: 33 additions & 4 deletions src/core/action_descriptor.ts
Original file line number Diff line number Diff line change
@@ -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<string, ActionDescriptorFilter>
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))?->)?(.+?)(#([^:]+?))(:(.+))?$/

Expand All @@ -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) })
, {})
Expand Down
7 changes: 7 additions & 0 deletions src/core/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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() {
Expand All @@ -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[]) {
Expand Down
40 changes: 18 additions & 22 deletions src/core/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +20,7 @@ export class Binding {
return this.action.eventTarget
}

get eventOptions(): EventModifiers {
get eventOptions(): AddEventListenerOptions {
return this.action.eventOptions
}

Expand All @@ -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)
}
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
5 changes: 0 additions & 5 deletions src/core/event_modifiers.ts

This file was deleted.

3 changes: 2 additions & 1 deletion src/tests/cases/application_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions src/tests/modules/core/event_options_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export default class EventOptionsTests extends LogControllerTestCase {
fixtureHTML = `
<div data-controller="c d">
<button></button>
<details></details>
</div>
<div id="outside"></div>
`
Expand Down Expand Up @@ -210,15 +211,58 @@ 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")
}

get buttonElement() {
return this.findElement("button")
}

get detailsElement() {
return this.findElement("details")
}
}

0 comments on commit a301681

Please sign in to comment.