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 25 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
59 changes: 58 additions & 1 deletion docs/reference/actions.md
Expand Up @@ -65,11 +65,68 @@ 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
<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 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]
Comment on lines +85 to +98
Copy link
Contributor

Choose a reason for hiding this comment

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

A suggestion for refinement - using backticks on the code bits.

| 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]      |

OR - if the side borders is not really what we do (by the looks of things).

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": "@" },
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved
}

const app = Application.start(document.documentElement, customSchema);
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved
```
If you want to subscribe to a compound filter using a modifier key, you can write it like `shift+a`.
NakajimaTakuya marked this conversation as resolved.
Show resolved Hide resolved

```html
<div data-action="keydown.shift+a->listbox#selectAll" role="option" tabindex="0">...</div>
```

The list of supported modifier keys is shown below.
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this last section needed? This seems to conflict with the table above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@lb-
I included the list of modifier keys because I thought it should be explicit.
How does this conflict with the table above?

Copy link
Contributor

Choose a reason for hiding this comment

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

sorry, my bad - I was confused, I will suggest a table approach in a min if that is ok.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Certainly it would be easier to use a table structure and include explanations, given the existence of meta keys, etc.
Thanks for your kind review.
It is really helpful.


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

<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") %>
43 changes: 38 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 {
readonly element: Element
Expand All @@ -9,24 +10,52 @@ 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(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 (!this.keyMappings.hasOwnProperty(standardFilter)) {
error(`contains unkown key filter: ${this.keyFilter}`)
}

return this.keyMappings[standardFilter].toLowerCase() !== event.key.toLowerCase()
}

get params() {
Expand All @@ -46,6 +75,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
33 changes: 18 additions & 15 deletions 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<string, ActionDescriptorFilter>
export type ActionDescriptorFilter = (options: ActionDescriptorFilterOptions) => boolean
type ActionDescriptorFilterOptions = {
Expand Down Expand Up @@ -37,18 +29,29 @@ 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<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 @@ -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)) {
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
21 changes: 21 additions & 0 deletions src/core/schema.ts
Expand Up @@ -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 = {
Expand All @@ -12,4 +13,24 @@ 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}), {})
}
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