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

Introduce once and passive events to actions #232

Merged
merged 4 commits into from
Aug 6, 2019
Merged
Show file tree
Hide file tree
Changes from 3 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
22 changes: 22 additions & 0 deletions docs/reference/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,28 @@ You can append `@window` or `@document` to the event name in an action descripto
</div>
```

### Options

Sometimes you may need to pass [additional options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Parameters) to the _Event Listener_ attached to the action.

* Options are set by adding one of the allowed tokens to the end of the _action descriptor_
* Options are separated from the method by a column `:`
* Add an exclamation mark `!` before a token to negate its value

**The followng option tokens are allowed:**

Tokens | EventListener option
------------- | ---------------------
`capture` | `{ capture: true }`
`once` | `{ once: true }`
`passive` | `{ passive: true }`
`!passive` | `{ passive: false }`


**Options can be combined if needed**

`click->controller#method:!passive:once`

## Event Objects

An _action method_ is the method in a controller which serves as an action's event listener.
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,20 @@
"dependencies": {
"@types/qunit": "^2.5.2",
"@types/webpack-env": "^1.13.3",
"karma": "^3.1.4",
"karma": "^4.1.0",
"karma-chrome-launcher": "^2.2.0",
"karma-qunit": "^2.1.0",
"karma-qunit": "^3.1.2",
"karma-sauce-launcher": "^2.0.2",
"karma-webpack": "^3.0.0",
"karma-webpack": "^3.0.5",
"lerna": "^3.8.5",
"qunit": "^2.6.2",
"qunit": "^2.9.2",
"rimraf": "^2.6.2",
"rollup": "^1.1.0",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-uglify": "^6.0.1",
"ts-loader": "^4.5.0",
"typedoc": "^0.11.1",
"typescript": "^3.2.2",
"webpack": "^4.17.1"
"webpack": "^4.33.0"
}
}
14 changes: 8 additions & 6 deletions packages/@stimulus/core/src/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export class Action {
readonly index: number
readonly eventTarget: EventTarget
readonly eventName: string
readonly eventOptions: AddEventListenerOptions
readonly identifier: string
readonly methodName: string

Expand All @@ -14,12 +15,13 @@ export class Action {
}

constructor(element: Element, index: number, descriptor: Partial<ActionDescriptor>) {
this.element = element
this.index = index
this.eventTarget = descriptor.eventTarget || element
this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name")
this.identifier = descriptor.identifier || error("missing identifier")
this.methodName = descriptor.methodName || error("missing method name")
this.element = element
this.index = index
this.eventTarget = descriptor.eventTarget || element
this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name")
this.eventOptions = descriptor.eventOptions || {}
this.identifier = descriptor.identifier || error("missing identifier")
this.methodName = descriptor.methodName || error("missing method name")
}

toString() {
Expand Down
21 changes: 15 additions & 6 deletions packages/@stimulus/core/src/action_descriptor.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
export interface ActionDescriptor {
eventTarget: EventTarget
eventOptions: AddEventListenerOptions
eventName: string
identifier: string
methodName: string
}

// capture nos.: 12 23 4 43 1 5 56 7 76
const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#(.+))?$/
// capture nos.: 12 23 4 43 1 5 56 7 768 9 98
const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/

export function parseDescriptorString(descriptorString: string): Partial<ActionDescriptor> {
const source = descriptorString.trim()
const matches = source.match(descriptorPattern) || []

return {
eventTarget: parseEventTarget(matches[4]),
eventName: matches[2],
identifier: matches[5],
methodName: matches[7]
eventTarget: parseEventTarget(matches[4]),
eventName: matches[2],
eventOptions: parseEventOptions(matches[9]),
identifier: matches[5],
methodName: matches[7]
}
}

Expand All @@ -27,6 +30,12 @@ function parseEventTarget(eventTargetName: string): EventTarget | undefined {
}
}

function parseEventOptions(eventOptions: string = ""): AddEventListenerOptions {
return eventOptions.split(":").reduce((options, token) =>
Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) })
, {})
}
adrienpoly marked this conversation as resolved.
Show resolved Hide resolved

export function stringifyEventTarget(eventTarget: EventTarget) {
if (eventTarget == window) {
return "window"
Expand Down
4 changes: 4 additions & 0 deletions packages/@stimulus/core/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export class Binding {
return this.action.eventTarget
}

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

get identifier(): string {
return this.context.identifier
}
Expand Down
21 changes: 13 additions & 8 deletions packages/@stimulus/core/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,22 +52,23 @@ export class Dispatcher implements BindingObserverDelegate {
}

private fetchEventListenerForBinding(binding: Binding): EventListener {
const { eventTarget, eventName } = binding
return this.fetchEventListener(eventTarget, eventName)
const { eventTarget, eventName, eventOptions } = binding
return this.fetchEventListener(eventTarget, eventName, eventOptions)
}

private fetchEventListener(eventTarget: EventTarget, eventName: string): EventListener {
private fetchEventListener(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions): EventListener {
const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget)
let eventListener = eventListenerMap.get(eventName)
const cacheKey = this.cacheKey(eventName, eventOptions)
let eventListener = eventListenerMap.get(cacheKey)
if (!eventListener) {
eventListener = this.createEventListener(eventTarget, eventName)
eventListenerMap.set(eventName, eventListener)
eventListener = this.createEventListener(eventTarget, eventName, eventOptions)
eventListenerMap.set(cacheKey, eventListener)
}
return eventListener
}

private createEventListener(eventTarget: EventTarget, eventName: string): EventListener {
const eventListener = new EventListener(eventTarget, eventName)
private createEventListener(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions): EventListener {
const eventListener = new EventListener(eventTarget, eventName, eventOptions)
if (this.started) {
eventListener.connect()
}
Expand All @@ -82,4 +83,8 @@ export class Dispatcher implements BindingObserverDelegate {
}
return eventListenerMap
}

private cacheKey(eventName: string, eventOptions: any): string {
return eventName + JSON.stringify(Object.keys(eventOptions).sort().map(key => [key, eventOptions[key]]))
}
}
11 changes: 7 additions & 4 deletions packages/@stimulus/core/src/event_listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ import { Binding } from "./binding"
export class EventListener implements EventListenerObject {
readonly eventTarget: EventTarget
readonly eventName: string
readonly eventOptions: AddEventListenerOptions
private unorderedBindings: Set<Binding>

constructor(eventTarget: EventTarget, eventName: string) {
constructor(eventTarget: EventTarget, eventName: string, eventOptions: AddEventListenerOptions) {
this.eventTarget = eventTarget
this.eventName = eventName
this.unorderedBindings = new Set
this.eventOptions = eventOptions
this.unorderedBindings = new Set()
}

connect() {
this.eventTarget.addEventListener(this.eventName, this, false)
this.eventTarget.addEventListener(this.eventName, this, this.eventOptions)
adrienpoly marked this conversation as resolved.
Show resolved Hide resolved
}

disconnect() {
this.eventTarget.removeEventListener(this.eventName, this, false)
this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions)
}

// Binding observer delegate
Expand Down Expand Up @@ -48,6 +50,7 @@ export class EventListener implements EventListenerObject {
return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0
})
}

}

function extendEvent(event: Event) {
Expand Down
4 changes: 2 additions & 2 deletions packages/@stimulus/core/src/tests/cases/action_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ export default class ActionTests extends LogControllerTestCase {
}

async "test non-bubbling events"() {
await this.triggerEvent("span", "click", false)
await this.triggerEvent("span", "click", { bubbles: false })
this.assertNoActions()
await this.triggerEvent("button", "click", false)
await this.triggerEvent("button", "click", { bubbles: false })
this.assertActions({ eventType: "click" })
}

Expand Down
145 changes: 145 additions & 0 deletions packages/@stimulus/core/src/tests/cases/event_options_tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { LogControllerTestCase } from "../log_controller_test_case"

export default class EventOptionsTests extends LogControllerTestCase {
identifier = ["c", "d"]
fixtureHTML = `
<div data-controller="c d">
<button></button>
</div>
<div id="outside"></div>
`
async "test different syntaxes for once action"() {
this.actionValue = "click->c#log:once d#log2:once c#log3:once"

await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")
await this.triggerEvent(this.buttonElement, "click")

this.assertActions(
{ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
{ name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement },
{ name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
)
}

async "test mix once and standard actions"() {
this.actionValue = "c#log:once d#log2 c#log3"

await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")
await this.triggerEvent(this.buttonElement, "click")

this.assertActions(
{ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
{ name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement },
{ name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
{ name: "log2", identifier: "d", eventType: "click", currentTarget: this.buttonElement },
{ name: "log3", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
)
}

async "test stop propagation with once"() {
this.actionValue = "c#stop:once c#log"

await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")

this.assertActions(
{ name: "stop", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
)

await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")
this.assertActions(
{ name: "stop", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
{ name: "log", identifier: "c", eventType: "click", currentTarget: this.buttonElement },
)
}

async "test global once actions"() {
this.actionValue = "keydown@window->c#log:once"

await this.nextFrame
await this.triggerEvent("#outside", "keydown")
await this.triggerEvent("#outside", "keydown")

this.assertActions({ name: "log", eventType: "keydown" })
}

async "test edge case when updating action list with setAttribute preserves once history"() {
this.actionValue = "c#log:once"
await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")
await this.triggerEvent(this.buttonElement, "click")

//modify with a setAttribute and c#log should not be called anyhow
this.actionValue = "c#log2 c#log:once d#log"
await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")

this.assertActions(
{ name: "log", identifier: "c" },
{ name: "log2", identifier: "c" },
{ name: "log", identifier: "d" },
)
}

async "test default passive action"() {
this.actionValue = "scroll->c#logPassive:passive"
await this.nextFrame

await this.triggerEvent(this.buttonElement, "scroll", { setDefaultPrevented: false })
this.assertActions({ name: "logPassive", eventType: "scroll", passive: true })
}

async "test global passive actions"() {
this.actionValue = "mouseup@window->c#logPassive:passive"
await this.nextFrame

await this.triggerEvent("#outside", "mouseup", { setDefaultPrevented: false })
this.assertActions({ name: "logPassive", eventType: "mouseup", passive: true })
}

async "test passive false actions"() {
// by default touchmove is true in chrome
this.actionValue = "touchmove@window->c#logPassive:!passive"
await this.nextFrame

await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false })
this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false })
}

async "test multiple options"() {
// by default touchmove is true in chrome
this.actionValue = "touchmove@window->c#logPassive:once:!passive"
await this.nextFrame

await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false })
await this.triggerEvent("#outside", "touchmove", { setDefaultPrevented: false })
this.assertActions({ name: "logPassive", eventType: "touchmove", passive: false })
}

async "test wrong options are silently ignored"() {
this.actionValue = "c#log:wrong:verywrong"
await this.nextFrame
await this.triggerEvent(this.buttonElement, "click")
await this.triggerEvent(this.buttonElement, "click")

this.assertActions(
{ name: "log", identifier: "c" },
{ name: "log", identifier: "c" }
)
}

set actionValue(value: string) {
this.buttonElement.setAttribute("data-action", value)
}

get element() {
return this.findElement("div")
}

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