diff --git a/docs/reference/actions.md b/docs/reference/actions.md
index 41a88c70..aebae6ab 100644
--- a/docs/reference/actions.md
+++ b/docs/reference/actions.md
@@ -65,11 +65,71 @@ input type=submit | click
select | change
textarea | input
+
+## KeyboardEvent Filter
+
+There may be cases where [KeyboardEvent](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent) Actions should only call the Controller method when certain keystrokes are used.
+
+You can install an event listener that responds only to the `Escape` key by adding `.esc` to the event name of the action descriptor, as in the following example.
+
+```html
+
+
+```
+
+This will only work if the event being fired is a keyboard event.
+
+The correspondence between these filter and keys is shown below.
+
+Filter | Key Name
+-------- | --------
+enter | Enter
+tab | Tab
+esc | Escape
+space | " "
+up | ArrowUp
+down | ArrowDown
+left | ArrowLeft
+right | ArrowRight
+home | Home
+end | End
+[a-z] | [a-z]
+[0-9] | [0-9]
+
+If you need to support other keys, you can customize the modifiers using a custom schema.
+
+```javascript
+import { Application, defaultSchema } from "@hotwired/stimulus"
+
+const customSchema = {
+ ...defaultSchema,
+ keyMappings: { ...defaultSchema.keyMappings, at: "@" },
+}
+
+const app = Application.start(document.documentElement, customSchema)
+```
+
+If you want to subscribe to a compound filter using a modifier key, you can write it like `ctrl+a`.
+
+```html
+...
+```
+
+The list of supported modifier keys is shown below.
+
+| Modifier | Notes |
+| -------- | ------------------ |
+| `alt` | `option` on MacOS |
+| `ctrl` | |
+| `meta` | Command key on MacOS |
+| `shift` | |
+
### Global Events
Sometimes a controller needs to listen for events dispatched on the global `window` or `document` objects.
-You can append `@window` or `@document` to the event name in an action descriptor to install the event listener on `window` or `document`, respectively, as in the following example:
+You can append `@window` or `@document` to the event name (along with any filter modifer) in an action descriptor to install the event listener on `window` or `document`, respectively, as in the following example:
diff --git a/examples/controllers/tabs_controller.js b/examples/controllers/tabs_controller.js
new file mode 100644
index 00000000..f78ac5eb
--- /dev/null
+++ b/examples/controllers/tabs_controller.js
@@ -0,0 +1,44 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = [ "tab", "tabpanel" ]
+ static classes = [ "current" ]
+ static values = { index: { default: 0, type: Number } }
+
+ next() {
+ if (this.indexValue < this.lastIndex) {
+ this.indexValue++
+ return
+ }
+ this.indexValue = 0
+ }
+
+ previous() {
+ if (this.indexValue > 0) {
+ this.indexValue--
+ return
+ }
+ this.indexValue = this.lastIndex
+ }
+
+ open(evt) {
+ this.indexValue = this.tabTargets.indexOf(evt.currentTarget)
+ }
+
+ get lastIndex() {
+ return this.tabTargets.length - 1
+ }
+
+ indexValueChanged(current, old) {
+ let panels = this.tabpanelTargets
+ let tabs = this.tabTargets
+
+ if (old != null) {
+ panels[old].classList.remove(...this.currentClasses)
+ tabs[old].tabIndex = -1
+ }
+ panels[current].classList.add(...this.currentClasses)
+ tabs[current].tabIndex = 0
+ tabs[current].focus()
+ }
+}
diff --git a/examples/index.js b/examples/index.js
index baa80321..f40397a5 100644
--- a/examples/index.js
+++ b/examples/index.js
@@ -16,3 +16,6 @@ application.register("hello", HelloController)
import SlideshowController from "./controllers/slideshow_controller"
application.register("slideshow", SlideshowController)
+
+import TabsController from "./controllers/tabs_controller"
+application.register("tabs", TabsController)
diff --git a/examples/public/examples.css b/examples/public/examples.css
index 5f74abc0..bc436cce 100644
--- a/examples/public/examples.css
+++ b/examples/public/examples.css
@@ -94,3 +94,13 @@ main {
min-width: 16em;
}
+.tabpanel {
+ border: 1px solid #dedede;
+ display: none;
+ margin-top: .4rem;
+ padding: 0.8rem;
+ font-size: 6rem;
+}
+.tabpanel--current {
+ display: block;
+}
diff --git a/examples/server.js b/examples/server.js
index 85084218..a5573f33 100644
--- a/examples/server.js
+++ b/examples/server.js
@@ -22,6 +22,7 @@ const pages = [
{ path: "/clipboard", title: "Clipboard" },
{ path: "/slideshow", title: "Slideshow" },
{ path: "/content-loader", title: "Content Loader" },
+ { path: "/tabs", title: "Tabs" },
]
app.get("/", (req, res) => {
diff --git a/examples/views/tabs.ejs b/examples/views/tabs.ejs
new file mode 100644
index 00000000..5a1b8254
--- /dev/null
+++ b/examples/views/tabs.ejs
@@ -0,0 +1,42 @@
+<%- include("layout/head") %>
+
+
+
This tabbed interface is operated by focusing on a button and pressing the left and right keys.
+
+ tab1
+ tab2
+
+
+
🐵
+
🙈
+
+
+<%- include("layout/tail") %>
diff --git a/src/core/action.ts b/src/core/action.ts
index 2111e8a6..902af037 100644
--- a/src/core/action.ts
+++ b/src/core/action.ts
@@ -1,5 +1,6 @@
import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } from "./action_descriptor"
import { Token } from "../mutation-observers"
+import { Schema } from "./schema"
import { camelize } from "./string_helpers"
export class Action {
readonly element: Element
@@ -9,12 +10,14 @@ export class Action {
readonly eventOptions: AddEventListenerOptions
readonly identifier: string
readonly methodName: string
+ readonly keyFilter: string
+ readonly schema: Schema
- static forToken(token: Token) {
- return new this(token.element, token.index, parseActionDescriptorString(token.content))
+ static forToken(token: Token, schema: Schema) {
+ return new this(token.element, token.index, parseActionDescriptorString(token.content), schema)
}
- constructor(element: Element, index: number, descriptor: Partial) {
+ constructor(element: Element, index: number, descriptor: Partial, schema: Schema) {
this.element = element
this.index = index
this.eventTarget = descriptor.eventTarget || element
@@ -22,11 +25,40 @@ export class Action {
this.eventOptions = descriptor.eventOptions || {}
this.identifier = descriptor.identifier || error("missing identifier")
this.methodName = descriptor.methodName || error("missing method name")
+ this.keyFilter = descriptor.keyFilter || ""
+ this.schema = schema
}
toString() {
- const eventNameSuffix = this.eventTargetName ? `@${this.eventTargetName}` : ""
- return `${this.eventName}${eventNameSuffix}->${this.identifier}#${this.methodName}`
+ const eventFilter = this.keyFilter ? `.${this.keyFilter}` : ""
+ const eventTarget = this.eventTargetName ? `@${this.eventTargetName}` : ""
+ return `${this.eventName}${eventFilter}${eventTarget}->${this.identifier}#${this.methodName}`
+ }
+
+ isFilterTarget(event: KeyboardEvent): boolean {
+ if (!this.keyFilter) {
+ return false
+ }
+
+ const filteres = this.keyFilter.split("+")
+ const modifiers = ["meta", "ctrl", "alt", "shift"]
+ const [meta, ctrl, alt, shift] = modifiers.map((modifier) => filteres.includes(modifier))
+
+ if (event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== alt || event.shiftKey !== shift) {
+ return true
+ }
+
+ const standardFilter = filteres.filter((key) => !modifiers.includes(key))[0]
+ if (!standardFilter) {
+ // missing non modifier key
+ return false
+ }
+
+ if (!Object.prototype.hasOwnProperty.call(this.keyMappings, standardFilter)) {
+ error(`contains unkown key filter: ${this.keyFilter}`)
+ }
+
+ return this.keyMappings[standardFilter].toLowerCase() !== event.key.toLowerCase()
}
get params() {
@@ -46,6 +78,10 @@ export class Action {
private get eventTargetName() {
return stringifyEventTarget(this.eventTarget)
}
+
+ private get keyMappings() {
+ return this.schema.keyMappings
+ }
}
const defaultEventNames: { [tagName: string]: (element: Element) => string } = {
diff --git a/src/core/action_descriptor.ts b/src/core/action_descriptor.ts
index 82bb3e3c..a6604733 100644
--- a/src/core/action_descriptor.ts
+++ b/src/core/action_descriptor.ts
@@ -1,11 +1,3 @@
-export interface ActionDescriptor {
- eventTarget: EventTarget
- eventOptions: AddEventListenerOptions
- eventName: string
- identifier: string
- methodName: string
-}
-
export type ActionDescriptorFilters = Record
export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean
type ActionDescriptorFilterOptions = {
@@ -37,18 +29,28 @@ export const defaultActionDescriptorFilters: ActionDescriptorFilters = {
},
}
-// capture nos.: 12 23 4 43 1 5 56 7 768 9 98
-const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/
+export interface ActionDescriptor {
+ eventTarget: EventTarget
+ eventOptions: AddEventListenerOptions
+ eventName: string
+ identifier: string
+ methodName: string
+ keyFilter: string
+}
+
+// capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6
+const descriptorPattern = /^(?:(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/
export function parseActionDescriptorString(descriptorString: string): Partial {
const source = descriptorString.trim()
const matches = source.match(descriptorPattern) || []
return {
- eventTarget: parseEventTarget(matches[4]),
- eventName: matches[2],
- eventOptions: matches[9] ? parseEventOptions(matches[9]) : {},
- identifier: matches[5],
- methodName: matches[7],
+ eventTarget: parseEventTarget(matches[3]),
+ eventName: matches[1],
+ eventOptions: matches[6] ? parseEventOptions(matches[6]) : {},
+ identifier: matches[4],
+ methodName: matches[5],
+ keyFilter: matches[2],
}
}
diff --git a/src/core/binding.ts b/src/core/binding.ts
index c8561a46..2c3edc04 100644
--- a/src/core/binding.ts
+++ b/src/core/binding.ts
@@ -81,6 +81,11 @@ export class Binding {
private willBeInvokedByEvent(event: Event): boolean {
const eventTarget = event.target
+
+ if (event instanceof KeyboardEvent && this.action.isFilterTarget(event)) {
+ return false
+ }
+
if (this.element === eventTarget) {
return true
} else if (eventTarget instanceof Element && this.element.contains(eventTarget)) {
diff --git a/src/core/binding_observer.ts b/src/core/binding_observer.ts
index fd56295e..62cc8355 100644
--- a/src/core/binding_observer.ts
+++ b/src/core/binding_observer.ts
@@ -79,7 +79,7 @@ export class BindingObserver implements ValueListObserverDelegate {
// Value observer delegate
parseValueForToken(token: Token): Action | undefined {
- const action = Action.forToken(token)
+ const action = Action.forToken(token, this.schema)
if (action.identifier == this.identifier) {
return action
}
diff --git a/src/core/schema.ts b/src/core/schema.ts
index c327ed4d..20845d20 100644
--- a/src/core/schema.ts
+++ b/src/core/schema.ts
@@ -4,6 +4,7 @@ export interface Schema {
targetAttribute: string
targetAttributeForScope(identifier: string): string
outletAttributeForScope(identifier: string, outlet: string): string
+ keyMappings: { [key: string]: string }
}
export const defaultSchema: Schema = {
@@ -12,4 +13,25 @@ export const defaultSchema: Schema = {
targetAttribute: "data-target",
targetAttributeForScope: (identifier) => `data-${identifier}-target`,
outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`,
+ keyMappings: {
+ enter: "Enter",
+ tab: "Tab",
+ esc: "Escape",
+ space: " ",
+ up: "ArrowUp",
+ down: "ArrowDown",
+ left: "ArrowLeft",
+ right: "ArrowRight",
+ home: "Home",
+ end: "End",
+ // [a-z]
+ ...objectFromEntries("abcdefghijklmnopqrstuvwxyz".split("").map((c) => [c, c])),
+ // [0-9]
+ ...objectFromEntries("0123456789".split("").map((n) => [n, n])),
+ },
+}
+
+function objectFromEntries(array: [string, any][]): object {
+ // polyfill
+ return array.reduce((memo, [k, v]) => ({ ...memo, [k]: v }), {})
}
diff --git a/src/tests/cases/application_test_case.ts b/src/tests/cases/application_test_case.ts
index 0c337afc..14b01750 100644
--- a/src/tests/cases/application_test_case.ts
+++ b/src/tests/cases/application_test_case.ts
@@ -2,7 +2,7 @@ import { Application } from "../../core/application"
import { DOMTestCase } from "./dom_test_case"
import { Schema, defaultSchema } from "../../core/schema"
-class TestApplication extends Application {
+export class TestApplication extends Application {
handleError(error: Error, _message: string, _detail: object) {
throw error
}
diff --git a/src/tests/cases/dom_test_case.ts b/src/tests/cases/dom_test_case.ts
index 6cfe2244..438239ef 100644
--- a/src/tests/cases/dom_test_case.ts
+++ b/src/tests/cases/dom_test_case.ts
@@ -51,6 +51,15 @@ export class DOMTestCase extends TestCase {
return event
}
+ async triggerKeyboardEvent(selectorOrTarget: string | EventTarget, type: string, options: KeyboardEventInit = {}) {
+ const eventTarget = typeof selectorOrTarget == "string" ? this.findElement(selectorOrTarget) : selectorOrTarget
+ const event = new KeyboardEvent(type, options)
+
+ eventTarget.dispatchEvent(event)
+ await this.nextFrame
+ return event
+ }
+
findElement(selector: string) {
const element = this.fixtureElement.querySelector(selector)
if (element) {
diff --git a/src/tests/modules/core/action_keyboard_filter_tests.ts b/src/tests/modules/core/action_keyboard_filter_tests.ts
new file mode 100644
index 00000000..da9252e3
--- /dev/null
+++ b/src/tests/modules/core/action_keyboard_filter_tests.ts
@@ -0,0 +1,185 @@
+import { TestApplication } from "../../cases/application_test_case"
+import { LogControllerTestCase } from "../../cases/log_controller_test_case"
+import { Schema, defaultSchema } from "../../../core/schema"
+import { Application } from "../../../core/application"
+
+const customSchema = { ...defaultSchema, keyMappings: { ...defaultSchema.keyMappings, a: "a", b: "b" } }
+
+export default class ActionKeyboardFilterTests extends LogControllerTestCase {
+ schema: Schema = customSchema
+ application: Application = new TestApplication(this.fixtureElement, this.schema)
+
+ identifier = ["a"]
+ fixtureHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ `
+
+ async "test ignore event handlers associated with modifiers other than Enter"() {
+ const button = this.findElement("#button1")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "Enter" })
+ this.assertActions(
+ { name: "log", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than Space"() {
+ const button = this.findElement("#button1")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: " " })
+ this.assertActions(
+ { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than Tab"() {
+ const button = this.findElement("#button2")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "Tab" })
+ this.assertActions(
+ { name: "log", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than Escape"() {
+ const button = this.findElement("#button2")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "Escape" })
+ this.assertActions(
+ { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than ArrowUp"() {
+ const button = this.findElement("#button3")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowUp" })
+ this.assertActions(
+ { name: "log", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than ArrowDown"() {
+ const button = this.findElement("#button3")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowDown" })
+ this.assertActions(
+ { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than ArrowLeft"() {
+ const button = this.findElement("#button4")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowLeft" })
+ this.assertActions(
+ { name: "log", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than ArrowRight"() {
+ const button = this.findElement("#button4")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "ArrowRight" })
+ this.assertActions(
+ { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than Home"() {
+ const button = this.findElement("#button5")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "Home" })
+ this.assertActions(
+ { name: "log", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test ignore event handlers associated with modifiers other than End"() {
+ const button = this.findElement("#button5")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "End" })
+ this.assertActions(
+ { name: "log2", identifier: "a", eventType: "keydown", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keydown", currentTarget: button }
+ )
+ }
+
+ async "test keyup"() {
+ const button = this.findElement("#button6")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keyup", { key: "End" })
+ this.assertActions(
+ { name: "log", identifier: "a", eventType: "keyup", currentTarget: button },
+ { name: "log3", identifier: "a", eventType: "keyup", currentTarget: button }
+ )
+ }
+
+ async "test global event"() {
+ const button = this.findElement("#button7")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "Escape", bubbles: true })
+ this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: document })
+ }
+
+ async "test custom keymapping: a"() {
+ const button = this.findElement("#button8")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "a" })
+ this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button })
+ }
+
+ async "test custom keymapping: b"() {
+ const button = this.findElement("#button8")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "b" })
+ this.assertActions({ name: "log2", identifier: "a", eventType: "keydown", currentTarget: button })
+ }
+
+ async "test custom keymapping: unknown c"() {
+ const button = this.findElement("#button8")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "c" })
+ this.assertActions()
+ }
+
+ async "test ignore event handlers associated with modifiers other than shift+a"() {
+ const button = this.findElement("#button9")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "A", shiftKey: true })
+ this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button })
+ }
+
+ async "test ignore event handlers associated with modifiers other than a"() {
+ const button = this.findElement("#button9")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "a" })
+ this.assertActions({ name: "log2", identifier: "a", eventType: "keydown", currentTarget: button })
+ }
+
+ async "test ignore event handlers associated with modifiers other than ctrol+shift+a"() {
+ const button = this.findElement("#button9")
+ await this.nextFrame
+ await this.triggerKeyboardEvent(button, "keydown", { key: "A", ctrlKey: true, shiftKey: true })
+ this.assertActions({ name: "log3", identifier: "a", eventType: "keydown", currentTarget: button })
+ }
+}