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

Add modifier to filter keyboard events. #442

Merged
merged 32 commits into from Nov 21, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
aad454a
Add modifier to filter keyboard events.
Sep 6, 2021
2c9769d
Fixed that some keys were not working.
Sep 6, 2021
c2e30a4
add ie support
Sep 13, 2021
49503b7
added tests for filtering keyboard events
Sep 13, 2021
18d6fa2
fix modifier position
Sep 13, 2021
10ee3de
Remove support for ie11
Sep 13, 2021
ff8cf8b
fix action.toString
Sep 14, 2021
2fe8852
Document added about keyboard event filters
Sep 14, 2021
5693106
support: Modifiable keymapping
NakajimaTakuya Mar 30, 2022
cf5473c
add example: tabs(for modifier)
NakajimaTakuya Mar 30, 2022
9d8fa19
Merge branch 'main' into keyboard-event-modifier
NakajimaTakuya Mar 30, 2022
4e13612
add docs of custom keymapping
NakajimaTakuya Mar 30, 2022
2a0bcdf
Merge branch 'keyboard-event-modifier' of https://github.com/Nakajima…
NakajimaTakuya Mar 30, 2022
4e55fea
fixed merge miss
NakajimaTakuya Mar 31, 2022
3818734
Merge branch 'main' into keyboard-event-modifier
NakajimaTakuya Nov 18, 2022
c8e4a41
fixed example
NakajimaTakuya Nov 18, 2022
53e1cdf
add modifier key(meta, ctrl, alt, shift)
NakajimaTakuya Nov 19, 2022
c3262cc
add default keyMapping [a-z], [0-9]
NakajimaTakuya Nov 19, 2022
e0fdab6
Added documentation on composite filters
NakajimaTakuya Nov 19, 2022
44a7b4a
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
a4ce7fe
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
b97faa9
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
9c31747
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
d5cb12e
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
59bcdb4
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
c1d2d6a
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
fa297c5
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
15f09c9
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
630a685
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
a3ad75c
fixed sample code(The behavior of select all in listbox is ctrl+a)
NakajimaTakuya Nov 19, 2022
1b59313
Update docs/reference/actions.md
NakajimaTakuya Nov 19, 2022
1226501
lint --fix
NakajimaTakuya Nov 21, 2022
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
31 changes: 30 additions & 1 deletion docs/reference/actions.md
Expand Up @@ -64,11 +64,40 @@ input type=submit | click
select | change
textarea | input

## KeyboardEvent Filter

You may have Actions that you wish to run only when certain keystrokes are received.
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved

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
<div data-controller="modal"
data-action="keydown.esc->modal#close" tabindex="0">
</div>
```

This will only work if the event being fired is a keyboard event.

The correspondence between these filter modifier and keys is shown below.

Modifier | Key Name
-------- | --------
enter | Enter
tab | Tab
esc | Escape
space: | " "
up | ArrowUp
down | ArrowDown
left | ArrowLeft
right | ArrowRight
home | Home
end | End

### 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 (contains filter modifer) in an action descriptor to install the event listener on `window` or `document`, respectively, as in the following example:
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved

<meta data-controller="callout" data-callout-text-value="resize@window">

Expand Down
28 changes: 26 additions & 2 deletions src/core/action.ts
Expand Up @@ -2,6 +2,19 @@ import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } f
import { Token } from "../mutation-observers"
import { camelize } from "./string_helpers"

const keyMappings: { [key: string]: string } = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this possibility be put on the Stimulus application? Maybe this way the mapping can be modified on a per application basis this way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, the comment was not clear.

The use case would be running Stimulus but being able to pass in a custom (or extended) keyMappings object when initialising the application (maybe as part of the schema) https://github.com/hotwired/stimulus/blob/main/src/core/schema.ts

// src/application.js
import { Application, defaultSchema } from "@hotwired/stimulus"

const customSchema = {
  ...defaultSchema,
  keyMappings: {...defaultSchema.keyMappings, a: "a", w: "w", s: "s", d: "d" },
}

window.Stimulus = Application.start(document.documentElement, customSchema);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for explaining it all so well! ❤️
I agree with your suggestion.
Give me a minute. :)

"enter": "Enter",
"tab": "Tab",
"esc": "Escape",
"space": " ",
"up": "ArrowUp",
"down": "ArrowDown",
"left": "ArrowLeft",
"right": "ArrowRight",
"home": "Home",
"end": "End"
}

export class Action {
readonly element: Element
readonly index: number
Expand All @@ -10,6 +23,7 @@ export class Action {
readonly eventOptions: AddEventListenerOptions
readonly identifier: string
readonly methodName: string
readonly keyFilter: string

static forToken(token: Token) {
return new this(token.element, token.index, parseActionDescriptorString(token.content))
Expand All @@ -23,11 +37,21 @@ 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 || ''
}

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(key: string): boolean {
if (!(this.keyFilter && keyMappings[this.keyFilter])) {
return false;
}

return keyMappings[this.keyFilter] !== key
}

get params(): object {
Expand Down
16 changes: 9 additions & 7 deletions src/core/action_descriptor.ts
Expand Up @@ -4,20 +4,22 @@ export interface ActionDescriptor {
eventName: string
identifier: string
methodName: string
keyFilter: string
}

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

export function parseActionDescriptorString(descriptorString: string): Partial<ActionDescriptor> {
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]
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/core/binding.ts
Expand Up @@ -63,6 +63,11 @@ export class Binding {

private willBeInvokedByEvent(event: Event): boolean {
const eventTarget = event.target

if (event instanceof KeyboardEvent && this.action.isFilterTarget(event.key)) {
return false;
}

if (this.element === eventTarget) {
return true
} else if (eventTarget instanceof Element && this.element.contains(eventTarget)) {
Expand Down
9 changes: 9 additions & 0 deletions src/tests/cases/dom_test_case.ts
Expand Up @@ -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) {
Expand Down
135 changes: 135 additions & 0 deletions src/tests/modules/core/action_keyboard_filter_tests.ts
@@ -0,0 +1,135 @@
import { LogControllerTestCase } from "../../cases/log_controller_test_case"

export default class ActionKeyboardFilterTests extends LogControllerTestCase {
identifier = ["a"]
fixtureHTML = `
<div data-controller="a" data-action="keydown.esc@document->a#log">
<button id="button1" data-action="keydown.enter->a#log keydown.space->a#log2 keydown->a#log3"></button>
<button id="button2" data-action="keydown.tab->a#log keydown.esc->a#log2 keydown->a#log3"></button>
<button id="button3" data-action="keydown.up->a#log keydown.down->a#log2 keydown->a#log3"></button>
<button id="button4" data-action="keydown.left->a#log keydown.right->a#log2 keydown->a#log3"></button>
<button id="button5" data-action="keydown.home->a#log keydown.end->a#log2 keydown->a#log3"></button>
<button id="button6" data-action="keyup.end->a#log keyup->a#log3"></button>
<button id="button7"></button>
</div>
`

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},
)
}
}