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.

+
+ + +
+ +
🐵
+
🙈
+
+ +<%- 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 }) + } +}