Skip to content

Commit

Permalink
Add modifier to filter keyboard events. (#442)
Browse files Browse the repository at this point in the history
* Add modifier to filter keyboard events.

* Fixed that some keys were not working.

* add ie support

* added tests for filtering keyboard events

* fix modifier position

The modifier was in the wrong position.
It should be before the target.

* before: evt@global.modifier->...
* after:  evt.modifier@global->...

* Remove support for ie11

* fix action.toString

before:`${event}@${target}.${filter}->${identifier}#${methodName}`
after: `${event}.${filter}@${target}->${identifier}#${methodName}`

* Document added about keyboard event filters

* support: Modifiable keymapping

* add example: tabs(for modifier)

* add docs of custom keymapping

* fixed merge miss

* fixed example

* add modifier key(meta, ctrl, alt, shift)

* add default keyMapping [a-z], [0-9]

* Added documentation on composite filters

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* fixed sample code(The behavior of select all in listbox is ctrl+a)

* Update docs/reference/actions.md

Co-authored-by: LB (Ben Johnston) <mail@lb.ee>

* lint --fix

Co-authored-by: Takuya Nakajima [LIFULL] <nakajita@PCZ04304.local>
Co-authored-by: LB (Ben Johnston) <mail@lb.ee>
  • Loading branch information
3 people committed Nov 21, 2022
1 parent 88b2641 commit f0af5bd
Show file tree
Hide file tree
Showing 14 changed files with 442 additions and 23 deletions.
62 changes: 61 additions & 1 deletion docs/reference/actions.md
Expand Up @@ -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
<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]

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
<div data-action="keydown.ctrl+a->listbox#selectAll" role="option" tabindex="0">...</div>
```

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:

<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") %>
46 changes: 41 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,55 @@ 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 (!Object.prototype.hasOwnProperty.call(this.keyMappings, standardFilter)) {
error(`contains unkown key filter: ${this.keyFilter}`)
}

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

get params() {
Expand All @@ -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 } = {
Expand Down
32 changes: 17 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,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<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
22 changes: 22 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,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 }), {})
}
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

0 comments on commit f0af5bd

Please sign in to comment.