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 `getModKey` 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 1a67215
Show file tree
Hide file tree
Showing 5 changed files with 77 additions and 12 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.schema.getModKey()
return (
event.metaKey !== (meta || modKey === "Meta") ||
event.ctrlKey !== (ctrl || modKey === "Control") ||
event.altKey !== alt ||
event.shiftKey !== shift
)
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/core/schema.ts
Expand Up @@ -5,6 +5,7 @@ export interface Schema {
targetAttributeForScope(identifier: string): string
outletAttributeForScope(identifier: string, outlet: string): string
keyMappings: { [key: string]: string }
getModKey(): string
}

export const defaultSchema: Schema = {
Expand All @@ -31,6 +32,10 @@ export const defaultSchema: Schema = {
// [0-9]
...objectFromEntries("0123456789".split("").map((n) => [n, n])),
},
getModKey: ((_: { key?: string }) => () => {
if (!_.key) _.key = /Mac|iPod|iPhone|iPad/.test(window?.navigator?.platform || "") ? "Meta" : "Control"
return _.key // memoize the modifier key on first call to avoid platform sniffing on every call
})({ key: "" }),
}

function objectFromEntries(array: [string, any][]): object {
Expand Down
52 changes: 51 additions & 1 deletion src/tests/modules/core/action_keyboard_filter_tests.ts
Expand Up @@ -22,6 +22,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 +178,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 +198,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 getModKey resolved value is based on the platform"() {
const expectedKeyMapping = navigator.platform?.match(/Mac|iPod|iPhone|iPad/) ? "Meta" : "Control"
this.assert.equal(defaultSchema.getModKey(), 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.getModKey = () => "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.getModKey = () => "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 1a67215

Please sign in to comment.