Skip to content

Commit

Permalink
Add support for mod key to be dynamic based on Mac/Windows
Browse files Browse the repository at this point in the history
- Adds `mod` to the `defaultSchema` which will resolve to `Meta` on a MacOS like device and `Control` on Windows like
- Uses `mod` to be used as a key filter modifier for either `metaKey` or `ctrlKey` based on the resolved value
- Add unit tests and documentation for the `mod` key, and an example to the slideshow page
- Closes hotwired#654
  • Loading branch information
lb- committed Feb 7, 2024
1 parent 245b06f commit b59ab24
Show file tree
Hide file tree
Showing 5 changed files with 79 additions and 13 deletions.
13 changes: 7 additions & 6 deletions docs/reference/actions.md
Expand Up @@ -120,12 +120,13 @@ If you want to subscribe to a compound filter using a modifier key, you can writ

The list of supported modifier keys is shown below.

| Modifier | Notes |
| -------- | ------------------ |
| `alt` | `option` on MacOS |
| `ctrl` | |
| `meta` | Command key on MacOS |
| `shift` | |
| Modifier | Notes |
| -------- | ------------------------------------------------------ |
| `alt` | `option` on MacOS |
| `ctrl` | |
| `meta` | `⌘ command` key on MacOS |
| `shift` | |
| `mod` | `⌘ command` (Meta) key on MacOS, `ctrl` key on Windows |

### Global Events

Expand Down
6 changes: 5 additions & 1 deletion examples/views/slideshow.ejs
@@ -1,13 +1,17 @@
<%- include("layout/head") %>

<div data-controller="slideshow" data-slideshow-current-slide-class="slide--current">
<div data-controller="slideshow" data-slideshow-current-slide-class="slide--current" data-action="keydown.mod+j@document->slideshow#previous keydown.mod+k@document->slideshow#next">
<button data-action="slideshow#previous">←</button>
<button data-action="slideshow#next">→</button>

<div data-slideshow-target="slide" class="slide">🐵</div>
<div data-slideshow-target="slide" class="slide">🙈</div>
<div data-slideshow-target="slide" class="slide">🙉</div>
<div data-slideshow-target="slide" class="slide">🙊</div>

<p>
Hint: Use keyboard shortcuts to navigate the slideshow: <kbd>mod + j</kbd> & <kbd>mod + k</kbd>
</p>
</div>

<%- include("layout/tail") %>
13 changes: 9 additions & 4 deletions src/core/action.ts
Expand Up @@ -4,7 +4,7 @@ import { Schema } from "./schema"
import { camelize } from "./string_helpers"
import { hasProperty } from "./utils"

const allModifiers = ["meta", "ctrl", "alt", "shift"]
const allModifiers = ["meta", "ctrl", "alt", "shift", "mod"]

export class Action {
readonly element: Element
Expand Down Expand Up @@ -98,9 +98,14 @@ export class Action {
}

private keyFilterDissatisfied(event: KeyboardEvent | MouseEvent, filters: Array<string>): boolean {
const [meta, ctrl, alt, shift] = allModifiers.map((modifier) => filters.includes(modifier))

return event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== alt || event.shiftKey !== shift
const [meta, ctrl, alt, shift, mod] = allModifiers.map((modifier) => filters.includes(modifier))
const modKey = mod && this.keyMappings.mod
return (
event.metaKey !== (meta || modKey === "Meta") ||
event.ctrlKey !== (ctrl || modKey === "Control") ||
event.altKey !== alt ||
event.shiftKey !== shift
)
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/core/schema.ts
@@ -1,3 +1,5 @@
const isMac = typeof window !== "undefined" && /Mac|iPod|iPhone|iPad/.test(window.navigator?.platform || "")

export interface Schema {
controllerAttribute: string
actionAttribute: string
Expand All @@ -14,6 +16,7 @@ export const defaultSchema: Schema = {
targetAttributeForScope: (identifier) => `data-${identifier}-target`,
outletAttributeForScope: (identifier, outlet) => `data-${identifier}-${outlet}-outlet`,
keyMappings: {
mod: isMac ? "Meta" : "Control",
enter: "Enter",
tab: "Tab",
esc: "Escape",
Expand Down
57 changes: 55 additions & 2 deletions src/tests/modules/core/action_keyboard_filter_tests.ts
Expand Up @@ -3,7 +3,10 @@ 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" } }
const customSchema = {
...defaultSchema,
keyMappings: { ...defaultSchema.keyMappings, a: "a", b: "b" } as { [key: string]: string },
}

export default class ActionKeyboardFilterTests extends LogControllerTestCase {
schema: Schema = customSchema
Expand All @@ -22,6 +25,7 @@ export default class ActionKeyboardFilterTests extends LogControllerTestCase {
<button id="button8" data-action="keydown.a->a#log keydown.b->a#log2"></button>
<button id="button9" data-action="keydown.shift+a->a#log keydown.a->a#log2 keydown.ctrl+shift+a->a#log3">
<button id="button10" data-action="jquery.custom.event->a#log jquery.a->a#log2">
<button id="button11" data-action="keydown.mod+s->a#log">
</div>
`

Expand Down Expand Up @@ -177,7 +181,7 @@ export default class ActionKeyboardFilterTests extends LogControllerTestCase {
this.assertActions({ name: "log2", identifier: "a", eventType: "keydown", currentTarget: button })
}

async "test ignore event handlers associated with modifiers other than ctrol+shift+a"() {
async "test ignore event handlers associated with modifiers other than ctrl+shift+a"() {
const button = this.findElement("#button9")
await this.nextFrame
await this.triggerKeyboardEvent(button, "keydown", { key: "A", ctrlKey: true, shiftKey: true })
Expand All @@ -197,4 +201,53 @@ export default class ActionKeyboardFilterTests extends LogControllerTestCase {
await this.triggerEvent(button, "jquery.a")
this.assertActions({ name: "log2", identifier: "a", eventType: "jquery.a", currentTarget: button })
}

async "test that the default schema keyMappings.mod value is based on the platform"() {
const expectedKeyMapping = navigator.platform?.match(/Mac|iPod|iPhone|iPad/) ? "Meta" : "Control"
this.assert.equal(defaultSchema.keyMappings.mod, expectedKeyMapping)
}

async "test ignore event handlers associated with modifiers mod+<key> (dynamic based on platform)"() {
const button = this.findElement("#button11")
await this.nextFrame
await this.triggerKeyboardEvent(button, "keydown", { key: "s", ctrlKey: true })
await this.triggerKeyboardEvent(button, "keydown", { key: "s", metaKey: true })
// We should only see one event using `mod` (which is dynamic based on platform)
this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button })

customSchema.keyMappings.mod = "Control" // set up for next test
}

async "test ignore event handlers associated with modifiers mod+<key> (set to 'Control')"() {
// see .mod setting in previous test (mocking Windows)
this.schema = {
...this.application.schema,
keyMappings: { ...this.application.schema.keyMappings, mod: "Control" },
}
const button = this.findElement("#button11")
await this.nextFrame
await this.triggerKeyboardEvent(button, "keydown", { key: "s", metaKey: true })
this.assertNoActions()
await this.triggerKeyboardEvent(button, "keydown", { key: "s", ctrlKey: true })
this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button })

customSchema.keyMappings.mod = "Meta" // set up for next test
}

async "test ignore event handlers associated with modifiers mod+<key> (set to 'Meta')"() {
// see .mod setting in previous test (mocking Windows)
this.schema = {
...this.application.schema,
keyMappings: { ...this.application.schema.keyMappings, mod: "Meta" },
}
const button = this.findElement("#button11")
await this.nextFrame
await this.triggerKeyboardEvent(button, "keydown", { key: "s", ctrlKey: true })
this.assertNoActions()
await this.triggerKeyboardEvent(button, "keydown", { key: "s", metaKey: true })
this.assertActions({ name: "log", identifier: "a", eventType: "keydown", currentTarget: button })

// Reset to default for any subsequent tests
this.schema = customSchema
}
}

0 comments on commit b59ab24

Please sign in to comment.