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 14 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
47 changes: 46 additions & 1 deletion docs/reference/actions.md
Expand Up @@ -65,11 +65,56 @@ input type=submit | click
select | change
textarea | input



NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved
## 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

If you need to support other keys, you can customize the modifier using custom schema.
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved

```javascript
import { Application, defaultSchema } from "@hotwired/stimulus"

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

const app = Application.start(document.documentElement, customSchema);
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved
```

NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved

### 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
44 changes: 44 additions & 0 deletions 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()
}
}
3 changes: 3 additions & 0 deletions examples/index.js
Expand Up @@ -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)
10 changes: 10 additions & 0 deletions examples/public/examples.css
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions examples/server.js
Expand Up @@ -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) => {
Expand Down
42 changes: 42 additions & 0 deletions examples/views/tabs.ejs
@@ -0,0 +1,42 @@
<% include layout/head %>

<div data-controller="tabs" data-tabs-current-class="tabpanel--current" aria-label="example">
<p>This tabbed interface is operated by focusing on a button and pressing the left and right keys.</p>
<div role="tablist">
<button
id="tab1"
role="tab"
tabindex="0"
data-action="keydown.left->tabs#previous keydown.right->tabs#next click->tabs#open"
data-tabs-target="tab"
aria-controls="panel1"
>tab1</button>
<button
id="tab2"
role="tab"
tabindex="0"
data-action="keydown.left->tabs#previous keydown.right->tabs#next click->tabs#open"
data-tabs-target="tab"
aria-controls="panel2"
>tab2</button>
</div>

<div
id="panel1"
role="tabpanel"
tabindex="0"
data-tabs-target="tabpanel"
class="tabpanel tabpanel--current"
aria-labelledby="tab1"
>🐵</div>
<div
id="panel2"
role="tabpanel"
tabindex="0"
data-tabs-target="tabpanel"
class="tabpanel"
aria-labelledby="tab2"
>🙈</div>
</div>

<% include layout/tail %>
28 changes: 23 additions & 5 deletions 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 {
Expand All @@ -10,24 +11,37 @@ 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<ActionDescriptor>) {
constructor(element: Element, index: number, descriptor: Partial<ActionDescriptor>, schema: Schema) {
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")
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(key: string): boolean {
if (!(this.keyFilter && this.keyMappings[this.keyFilter])) {
return false;
}

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

Choose a reason for hiding this comment

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

Hey ! My first PR review on this repo :D
Are you only checking if keyFilter exists in keyMappings? If yes, can this be simplified to

Suggested change
isFilterTarget(key: string): boolean {
if (!(this.keyFilter && this.keyMappings[this.keyFilter])) {
return false;
}
return this.keyMappings[this.keyFilter] !== key
isFilterTarget(key: string): boolean {
if(this.keyMappings.hasOwnProperty(this.keyFilter)){
return this.keyMappings[this.keyFilter] !== key;
}
return false;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I'll apply it if they decide they still need this PR. 😄

}

get params() {
Expand All @@ -47,6 +61,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 } = {
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
2 changes: 1 addition & 1 deletion src/core/binding_observer.ts
Expand Up @@ -79,7 +79,7 @@ export class BindingObserver implements ValueListObserverDelegate<Action> {
// 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
}
Expand Down
15 changes: 14 additions & 1 deletion src/core/schema.ts
Expand Up @@ -3,11 +3,24 @@ export interface Schema {
actionAttribute: string
targetAttribute: string
targetAttributeForScope(identifier: string): string
keyMappings: {[key: string]: string}
}

export const defaultSchema: Schema = {
controllerAttribute: "data-controller",
actionAttribute: "data-action",
targetAttribute: "data-target",
targetAttributeForScope: identifier => `data-${identifier}-target`
targetAttributeForScope: identifier => `data-${identifier}-target`,
keyMappings: {
"enter": "Enter",
"tab": "Tab",
"esc": "Escape",
"space": " ",
"up": "ArrowUp",
"down": "ArrowDown",
"left": "ArrowLeft",
"right": "ArrowRight",
"home": "Home",
"end": "End"
}
}
2 changes: 1 addition & 1 deletion src/tests/cases/application_test_case.ts
Expand Up @@ -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
}
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