Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support custom Action Options #567

Merged
merged 2 commits into from Jul 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/reference/actions.md
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
5 changes: 1 addition & 4 deletions 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

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
@@ -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 }) {
if (value) event.stopPropagation()

return true
},

prevent({ event, value }) {
if (value) event.preventDefault()

return true
},

self({ event, value, element }) {
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
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: 17 additions & 23 deletions src/core/binding.ts
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,23 @@ 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 applyEventModifiers(event: Event): boolean {
const { element } = this.action
const { actionDescriptorFilters } = this.context.application

let passes = true

for (const [name, value] of Object.entries(this.eventOptions)) {
if (name in actionDescriptorFilters) {
const filter = actionDescriptorFilters[name]

private processPreventDefault(event: Event) {
if (this.eventOptions.prevent) {
event.preventDefault();
passes = passes && filter({ name, value, event, element })
} else {
continue
}
}

return passes
}

private invokeWithEvent(event: Event) {
Expand All @@ -77,14 +79,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
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