Skip to content

Commit

Permalink
✨ Enhances clicks with key modifiers (#4209)
Browse files Browse the repository at this point in the history
* ✅ Adds comprehensive Modifier test

* 🧪 Adds failing test for mouse modifiers

* ✨ Allows modifier keys on click events

* 📝 Updates Documentation

* 🐛 Allows all mouse events
  • Loading branch information
ekwoka committed May 16, 2024
1 parent 6dcfefc commit 4fa8eaf
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 28 deletions.
25 changes: 17 additions & 8 deletions packages/alpinejs/src/utils/on.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,16 @@ export default function on (el, event, modifiers, callback) {
if (modifiers.includes('self')) handler = wrapHandler(handler, (next, e) => { e.target === el && next(e) })

// Handle :keydown and :keyup listeners.
handler = wrapHandler(handler, (next, e) => {
if (isKeyEvent(event)) {
// Handle :click and :auxclick listeners.
if (isKeyEvent(event) || isClickEvent(event)) {
handler = wrapHandler(handler, (next, e) => {
if (isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers)) {
return
}
}

next(e)
})
next(e)
})
}

listenerTarget.addEventListener(event, handler, options)

Expand Down Expand Up @@ -106,9 +107,13 @@ function isKeyEvent(event) {
return ['keydown', 'keyup'].includes(event)
}

function isClickEvent(event) {
return ['contextmenu','click','mouse'].some(i => event.includes(i))
}

function isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers) {
let keyModifiers = modifiers.filter(i => {
return ! ['window', 'document', 'prevent', 'stop', 'once', 'capture'].includes(i)
return ! ['window', 'document', 'prevent', 'stop', 'once', 'capture', 'self', 'away', 'outside', 'passive'].includes(i)
})

if (keyModifiers.includes('debounce')) {
Expand Down Expand Up @@ -143,7 +148,11 @@ function isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers) {

// If all the modifiers selected are pressed, ...
if (activelyPressedKeyModifiers.length === selectedSystemKeyModifiers.length) {
// AND the remaining key is pressed as well. It's a press.

// AND the event is a click. It's a pass.
if (isClickEvent(e.type)) return false

// OR the remaining key is pressed as well. It's a press.
if (keyToModifiers(e.key).includes(keyModifiers[0])) return false
}
}
Expand Down
78 changes: 59 additions & 19 deletions packages/docs/src/en/directives/on.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Here's an example of simple button that shows an alert when clicked.
<button x-on:click="alert('Hello World!')">Say Hi</button>
```

> `x-on` can only listen for events with lower case names, as HTML attributes are case-insensitive. Writing `x-on:CLICK` will listen for an event named `click`. If you need to listen for a custom event with a camelCase name, you can use the [`.camel` helper](#camel) to work around this limitation. Alternatively, you can use [`x-bind`](/directives/bind#bind-directives) to attach an `x-on` directive to an element in javascript code (where case will be preserved).
> `x-on` can only listen for events with lower case names, as HTML attributes are case-insensitive. Writing `x-on:CLICK` will listen for an event named `click`. If you need to listen for a custom event with a camelCase name, you can use the [`.camel` helper](#camel) to work around this limitation. Alternatively, you can use [`x-bind`](/directives/bind#bind-directives) to attach an `x-on` directive to an element in javascript code (where case will be preserved).
<a name="shorthand-syntax"></a>
## Shorthand syntax
Expand Down Expand Up @@ -74,23 +74,64 @@ You can directly use any valid key names exposed via [`KeyboardEvent.key`](https

For easy reference, here is a list of common keys you may want to listen for.

| Modifier | Keyboard Key |
| -------------------------- | --------------------------- |
| `.shift` | Shift |
| `.enter` | Enter |
| `.space` | Space |
| `.ctrl` | Ctrl |
| `.cmd` | Cmd |
| `.meta` | Cmd on Mac, Windows key on Windows |
| `.alt` | Alt |
| `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows |
| `.escape` | Escape |
| `.tab` | Tab |
| `.caps-lock` | Caps Lock |
| `.equal` | Equal, `=` |
| `.period` | Period, `.` |
| `.comma` | Comma, `,` |
| `.slash` | Forward Slash, `/` |
| Modifier | Keyboard Key |
| ------------------------------ | ---------------------------------- |
| `.shift` | Shift |
| `.enter` | Enter |
| `.space` | Space |
| `.ctrl` | Ctrl |
| `.cmd` | Cmd |
| `.meta` | Cmd on Mac, Windows key on Windows |
| `.alt` | Alt |
| `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows |
| `.escape` | Escape |
| `.tab` | Tab |
| `.caps-lock` | Caps Lock |
| `.equal` | Equal, `=` |
| `.period` | Period, `.` |
| `.comma` | Comma, `,` |
| `.slash` | Forward Slash, `/` |

<a name="mouse-events"></a>
## Mouse events

Like the above Keyboard Events, Alpine allows the use of some key modifiers for handling `click` events.

| Modifier | Event Key |
| -------- | --------- |
| `.shift` | shiftKey |
| `.ctrl` | ctrlKey |
| `.cmd` | metaKey |
| `.meta` | metaKey |
| `.alt` | altKey |

These work on `click`, `auxclick`, `context` and `dblclick` events, and even `mouseover`, `mousemove`, `mouseenter`, `mouseleave`, `mouseout`, `mouseup` and `mousedown`.

Here's an example of a button that changes behaviour when the `Shift` key is held down.

```alpine
<button type="button"
@click="message = 'selected'"
@click.shift="message = 'added to selection'">
@mousemove.shift="message = 'add to selection'"
@mouseout="message = 'select'"
x-text="message"></button>
```

<!-- START_VERBATIM -->
<div class="demo">
<div x-data="{ message: '' }">
<button type="button"
@click="message = 'selected'"
@click.shift="message = 'added to selection'"
@mousemove.shift="message = 'add to selection'"
@mouseout="message = 'select'"
x-text="message"></button>
</div>
</div>
<!-- END_VERBATIM -->

> Note: Normal click events with some modifiers (like `ctrl`) will automatically become `contextmenu` events in most browsers. Similarly, `right-click` events will trigger a `contextmenu` event, but will also trigger an `auxclick` event if the `contextmenu` event is prevented.
<a name="custom-events"></a>
## Custom events
Expand Down Expand Up @@ -311,4 +352,3 @@ Add this modifier if you want to execute this listener in the event's capturing
```

[→ Read more about the capturing and bubbling phase of events](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#usecapture)

158 changes: 157 additions & 1 deletion tests/cypress/integration/directives/x-on.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beChecked, notBeChecked, haveAttribute, haveData, haveText, test, beVisible, notBeVisible, html } from '../../utils'
import { beChecked, contain, notBeChecked, haveAttribute, haveData, haveText, test, beVisible, notBeVisible, html } from '../../utils'

test('data modified in event listener updates affected attribute bindings',
html`
Expand Down Expand Up @@ -671,3 +671,159 @@ test('handles await in handlers with invalid right hand expressions',
get('span').should(haveText('new string'))
}
)

test(
"handles system modifier keys on key events",
html`
<div x-data="{ keys: {
shift: false,
ctrl: false,
meta: false,
alt: false,
cmd: false
} }">
<input type="text"
@keydown.capture="Object.keys(keys).forEach(key => keys[key] = false)"
@keydown.meta.space="keys.meta = true"
@keydown.ctrl.space="keys.ctrl = true"
@keydown.shift.space="keys.shift = true"
@keydown.alt.space="keys.alt = true"
@keydown.cmd.space="keys.cmd = true"
/>
<template x-for="key in Object.keys(keys)" :key="key">
<input type="checkbox" :name="key" x-model="keys[key]">
</template>
</div>
`,({ get }) => {
get("input[name=shift]").as('shift').should(notBeChecked());
get("input[name=ctrl]").as('ctrl').should(notBeChecked());
get("input[name=meta]").as('meta').should(notBeChecked());
get("input[name=alt]").as('alt').should(notBeChecked());
get("input[name=cmd]").as('cmd').should(notBeChecked());
get("input[type=text]").as('input').trigger("keydown", { key: 'space', shiftKey: true });
get('@shift').should(beChecked());
get("@input").trigger("keydown", { key: 'space', ctrlKey: true });
get("@shift").should(notBeChecked());
get("@ctrl").should(beChecked());
get("@input").trigger("keydown", { key: 'space', metaKey: true });
get("@ctrl").should(notBeChecked());
get("@meta").should(beChecked());
get("@cmd").should(beChecked());
get("@input").trigger("keydown", { key: 'space', altKey: true });
get("@meta").should(notBeChecked());
get("@cmd").should(notBeChecked());
get("@alt").should(beChecked());
get("@input").trigger("keydown", { key: 'space' });
get("@alt").should(notBeChecked());
get("@input").trigger("keydown", { key: 'space',
ctrlKey: true, shiftKey: true, metaKey: true, altKey: true });
get("input[name=shift]").as("shift").should(beChecked());
get("input[name=ctrl]").as("ctrl").should(beChecked());
get("input[name=meta]").as("meta").should(beChecked());
get("input[name=alt]").as("alt").should(beChecked());
get("input[name=cmd]").as("cmd").should(beChecked());
}
);

test(
"handles system modifier keys on mouse events",
html`
<div x-data="{ keys: {
shift: false,
ctrl: false,
meta: false,
alt: false,
cmd: false
} }">
<button type=button
@click.capture="Object.keys(keys).forEach(key => keys[key] = false)"
@click.shift="keys.shift = true"
@click.ctrl="keys.ctrl = true"
@click.meta="keys.meta = true"
@click.alt="keys.alt = true"
@click.cmd="keys.cmd = true">
change
</button>
<template x-for="key in Object.keys(keys)" :key="key">
<input type="checkbox" :name="key" x-model="keys[key]">
</template>
</div>
`,({ get }) => {
get("input[name=shift]").as('shift').should(notBeChecked());
get("input[name=ctrl]").as('ctrl').should(notBeChecked());
get("input[name=meta]").as('meta').should(notBeChecked());
get("input[name=alt]").as('alt').should(notBeChecked());
get("input[name=cmd]").as('cmd').should(notBeChecked());
get("button").as('button').trigger("click", { shiftKey: true });
get('@shift').should(beChecked());
get("@button").trigger("click", { ctrlKey: true });
get("@shift").should(notBeChecked());
get("@ctrl").should(beChecked());
get("@button").trigger("click", { metaKey: true });
get("@ctrl").should(notBeChecked());
get("@meta").should(beChecked());
get("@cmd").should(beChecked());
get("@button").trigger("click", { altKey: true });
get("@meta").should(notBeChecked());
get("@cmd").should(notBeChecked());
get("@alt").should(beChecked());
get("@button").trigger("click", {});
get("@alt").should(notBeChecked());
get("@button").trigger("click", { ctrlKey: true, shiftKey: true, metaKey: true, altKey: true });
get("@shift").as("shift").should(beChecked());
get("@ctrl").as("ctrl").should(beChecked());
get("@meta").as("meta").should(beChecked());
get("@alt").as("alt").should(beChecked());
get("@cmd").as("cmd").should(beChecked());
}
);

test(
"handles all mouse events with modifiers",
html`
<div x-data="{ keys: {
shift: false,
ctrl: false,
meta: false,
alt: false,
cmd: false
} }">
<button type=button
@click.capture="Object.keys(keys).forEach(key => keys[key] = false)"
@contextmenu.prevent.shift="keys.shift = true"
@auxclick.ctrl="keys.ctrl = true"
@dblclick.meta="keys.meta = true"
@mouseenter.alt="keys.alt = true"
@mousemove.cmd="keys.cmd = true">
change
</button>
<template x-for="key in Object.keys(keys)" :key="key">
<input type="checkbox" :name="key" x-model="keys[key]">
</template>
</div>
`,({ get }) => {
get("input[name=shift]").as('shift').should(notBeChecked());
get("input[name=ctrl]").as('ctrl').should(notBeChecked());
get("input[name=meta]").as('meta').should(notBeChecked());
get("input[name=alt]").as('alt').should(notBeChecked());
get("input[name=cmd]").as('cmd').should(notBeChecked());
get("button").as('button').trigger("contextmenu", { shiftKey: true });
get('@shift').should(beChecked());
get("@button").trigger("click");
get("@button").trigger("auxclick", { ctrlKey: true });
get("@shift").should(notBeChecked());
get("@ctrl").should(beChecked());
get("@button").trigger("click");
get("@button").trigger("dblclick", { metaKey: true });
get("@ctrl").should(notBeChecked());
get("@meta").should(beChecked());
get("@button").trigger("click");
get("@button").trigger("mouseenter", { altKey: true });
get("@meta").should(notBeChecked());
get("@alt").should(beChecked());
get("@button").trigger("click");
get("@button").trigger("mousemove", { metaKey: true });
get("@alt").should(notBeChecked());
get("@cmd").should(beChecked());
}
);

0 comments on commit 4fa8eaf

Please sign in to comment.