Skip to content

Commit 4fa8eaf

Browse files
authored
✨ Enhances clicks with key modifiers (#4209)
* ✅ Adds comprehensive Modifier test * 🧪 Adds failing test for mouse modifiers * ✨ Allows modifier keys on click events * 📝 Updates Documentation * 🐛 Allows all mouse events
1 parent 6dcfefc commit 4fa8eaf

File tree

3 files changed

+233
-28
lines changed

3 files changed

+233
-28
lines changed

packages/alpinejs/src/utils/on.js

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,16 @@ export default function on (el, event, modifiers, callback) {
6767
if (modifiers.includes('self')) handler = wrapHandler(handler, (next, e) => { e.target === el && next(e) })
6868

6969
// Handle :keydown and :keyup listeners.
70-
handler = wrapHandler(handler, (next, e) => {
71-
if (isKeyEvent(event)) {
70+
// Handle :click and :auxclick listeners.
71+
if (isKeyEvent(event) || isClickEvent(event)) {
72+
handler = wrapHandler(handler, (next, e) => {
7273
if (isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers)) {
7374
return
7475
}
75-
}
76-
77-
next(e)
78-
})
76+
77+
next(e)
78+
})
79+
}
7980

8081
listenerTarget.addEventListener(event, handler, options)
8182

@@ -106,9 +107,13 @@ function isKeyEvent(event) {
106107
return ['keydown', 'keyup'].includes(event)
107108
}
108109

110+
function isClickEvent(event) {
111+
return ['contextmenu','click','mouse'].some(i => event.includes(i))
112+
}
113+
109114
function isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers) {
110115
let keyModifiers = modifiers.filter(i => {
111-
return ! ['window', 'document', 'prevent', 'stop', 'once', 'capture'].includes(i)
116+
return ! ['window', 'document', 'prevent', 'stop', 'once', 'capture', 'self', 'away', 'outside', 'passive'].includes(i)
112117
})
113118

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

144149
// If all the modifiers selected are pressed, ...
145150
if (activelyPressedKeyModifiers.length === selectedSystemKeyModifiers.length) {
146-
// AND the remaining key is pressed as well. It's a press.
151+
152+
// AND the event is a click. It's a pass.
153+
if (isClickEvent(e.type)) return false
154+
155+
// OR the remaining key is pressed as well. It's a press.
147156
if (keyToModifiers(e.key).includes(keyModifiers[0])) return false
148157
}
149158
}

packages/docs/src/en/directives/on.md

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Here's an example of simple button that shows an alert when clicked.
1313
<button x-on:click="alert('Hello World!')">Say Hi</button>
1414
```
1515

16-
> `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).
16+
> `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).
1717
1818
<a name="shorthand-syntax"></a>
1919
## Shorthand syntax
@@ -74,23 +74,64 @@ You can directly use any valid key names exposed via [`KeyboardEvent.key`](https
7474

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

77-
| Modifier | Keyboard Key |
78-
| -------------------------- | --------------------------- |
79-
| `.shift` | Shift |
80-
| `.enter` | Enter |
81-
| `.space` | Space |
82-
| `.ctrl` | Ctrl |
83-
| `.cmd` | Cmd |
84-
| `.meta` | Cmd on Mac, Windows key on Windows |
85-
| `.alt` | Alt |
86-
| `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows |
87-
| `.escape` | Escape |
88-
| `.tab` | Tab |
89-
| `.caps-lock` | Caps Lock |
90-
| `.equal` | Equal, `=` |
91-
| `.period` | Period, `.` |
92-
| `.comma` | Comma, `,` |
93-
| `.slash` | Forward Slash, `/` |
77+
| Modifier | Keyboard Key |
78+
| ------------------------------ | ---------------------------------- |
79+
| `.shift` | Shift |
80+
| `.enter` | Enter |
81+
| `.space` | Space |
82+
| `.ctrl` | Ctrl |
83+
| `.cmd` | Cmd |
84+
| `.meta` | Cmd on Mac, Windows key on Windows |
85+
| `.alt` | Alt |
86+
| `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows |
87+
| `.escape` | Escape |
88+
| `.tab` | Tab |
89+
| `.caps-lock` | Caps Lock |
90+
| `.equal` | Equal, `=` |
91+
| `.period` | Period, `.` |
92+
| `.comma` | Comma, `,` |
93+
| `.slash` | Forward Slash, `/` |
94+
95+
<a name="mouse-events"></a>
96+
## Mouse events
97+
98+
Like the above Keyboard Events, Alpine allows the use of some key modifiers for handling `click` events.
99+
100+
| Modifier | Event Key |
101+
| -------- | --------- |
102+
| `.shift` | shiftKey |
103+
| `.ctrl` | ctrlKey |
104+
| `.cmd` | metaKey |
105+
| `.meta` | metaKey |
106+
| `.alt` | altKey |
107+
108+
These work on `click`, `auxclick`, `context` and `dblclick` events, and even `mouseover`, `mousemove`, `mouseenter`, `mouseleave`, `mouseout`, `mouseup` and `mousedown`.
109+
110+
Here's an example of a button that changes behaviour when the `Shift` key is held down.
111+
112+
```alpine
113+
<button type="button"
114+
@click="message = 'selected'"
115+
@click.shift="message = 'added to selection'">
116+
@mousemove.shift="message = 'add to selection'"
117+
@mouseout="message = 'select'"
118+
x-text="message"></button>
119+
```
120+
121+
<!-- START_VERBATIM -->
122+
<div class="demo">
123+
<div x-data="{ message: '' }">
124+
<button type="button"
125+
@click="message = 'selected'"
126+
@click.shift="message = 'added to selection'"
127+
@mousemove.shift="message = 'add to selection'"
128+
@mouseout="message = 'select'"
129+
x-text="message"></button>
130+
</div>
131+
</div>
132+
<!-- END_VERBATIM -->
133+
134+
> 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.
94135
95136
<a name="custom-events"></a>
96137
## Custom events
@@ -311,4 +352,3 @@ Add this modifier if you want to execute this listener in the event's capturing
311352
```
312353

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

tests/cypress/integration/directives/x-on.spec.js

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beChecked, notBeChecked, haveAttribute, haveData, haveText, test, beVisible, notBeVisible, html } from '../../utils'
1+
import { beChecked, contain, notBeChecked, haveAttribute, haveData, haveText, test, beVisible, notBeVisible, html } from '../../utils'
22

33
test('data modified in event listener updates affected attribute bindings',
44
html`
@@ -671,3 +671,159 @@ test('handles await in handlers with invalid right hand expressions',
671671
get('span').should(haveText('new string'))
672672
}
673673
)
674+
675+
test(
676+
"handles system modifier keys on key events",
677+
html`
678+
<div x-data="{ keys: {
679+
shift: false,
680+
ctrl: false,
681+
meta: false,
682+
alt: false,
683+
cmd: false
684+
} }">
685+
<input type="text"
686+
@keydown.capture="Object.keys(keys).forEach(key => keys[key] = false)"
687+
@keydown.meta.space="keys.meta = true"
688+
@keydown.ctrl.space="keys.ctrl = true"
689+
@keydown.shift.space="keys.shift = true"
690+
@keydown.alt.space="keys.alt = true"
691+
@keydown.cmd.space="keys.cmd = true"
692+
/>
693+
<template x-for="key in Object.keys(keys)" :key="key">
694+
<input type="checkbox" :name="key" x-model="keys[key]">
695+
</template>
696+
</div>
697+
`,({ get }) => {
698+
get("input[name=shift]").as('shift').should(notBeChecked());
699+
get("input[name=ctrl]").as('ctrl').should(notBeChecked());
700+
get("input[name=meta]").as('meta').should(notBeChecked());
701+
get("input[name=alt]").as('alt').should(notBeChecked());
702+
get("input[name=cmd]").as('cmd').should(notBeChecked());
703+
get("input[type=text]").as('input').trigger("keydown", { key: 'space', shiftKey: true });
704+
get('@shift').should(beChecked());
705+
get("@input").trigger("keydown", { key: 'space', ctrlKey: true });
706+
get("@shift").should(notBeChecked());
707+
get("@ctrl").should(beChecked());
708+
get("@input").trigger("keydown", { key: 'space', metaKey: true });
709+
get("@ctrl").should(notBeChecked());
710+
get("@meta").should(beChecked());
711+
get("@cmd").should(beChecked());
712+
get("@input").trigger("keydown", { key: 'space', altKey: true });
713+
get("@meta").should(notBeChecked());
714+
get("@cmd").should(notBeChecked());
715+
get("@alt").should(beChecked());
716+
get("@input").trigger("keydown", { key: 'space' });
717+
get("@alt").should(notBeChecked());
718+
get("@input").trigger("keydown", { key: 'space',
719+
ctrlKey: true, shiftKey: true, metaKey: true, altKey: true });
720+
get("input[name=shift]").as("shift").should(beChecked());
721+
get("input[name=ctrl]").as("ctrl").should(beChecked());
722+
get("input[name=meta]").as("meta").should(beChecked());
723+
get("input[name=alt]").as("alt").should(beChecked());
724+
get("input[name=cmd]").as("cmd").should(beChecked());
725+
}
726+
);
727+
728+
test(
729+
"handles system modifier keys on mouse events",
730+
html`
731+
<div x-data="{ keys: {
732+
shift: false,
733+
ctrl: false,
734+
meta: false,
735+
alt: false,
736+
cmd: false
737+
} }">
738+
<button type=button
739+
@click.capture="Object.keys(keys).forEach(key => keys[key] = false)"
740+
@click.shift="keys.shift = true"
741+
@click.ctrl="keys.ctrl = true"
742+
@click.meta="keys.meta = true"
743+
@click.alt="keys.alt = true"
744+
@click.cmd="keys.cmd = true">
745+
change
746+
</button>
747+
<template x-for="key in Object.keys(keys)" :key="key">
748+
<input type="checkbox" :name="key" x-model="keys[key]">
749+
</template>
750+
</div>
751+
`,({ get }) => {
752+
get("input[name=shift]").as('shift').should(notBeChecked());
753+
get("input[name=ctrl]").as('ctrl').should(notBeChecked());
754+
get("input[name=meta]").as('meta').should(notBeChecked());
755+
get("input[name=alt]").as('alt').should(notBeChecked());
756+
get("input[name=cmd]").as('cmd').should(notBeChecked());
757+
get("button").as('button').trigger("click", { shiftKey: true });
758+
get('@shift').should(beChecked());
759+
get("@button").trigger("click", { ctrlKey: true });
760+
get("@shift").should(notBeChecked());
761+
get("@ctrl").should(beChecked());
762+
get("@button").trigger("click", { metaKey: true });
763+
get("@ctrl").should(notBeChecked());
764+
get("@meta").should(beChecked());
765+
get("@cmd").should(beChecked());
766+
get("@button").trigger("click", { altKey: true });
767+
get("@meta").should(notBeChecked());
768+
get("@cmd").should(notBeChecked());
769+
get("@alt").should(beChecked());
770+
get("@button").trigger("click", {});
771+
get("@alt").should(notBeChecked());
772+
get("@button").trigger("click", { ctrlKey: true, shiftKey: true, metaKey: true, altKey: true });
773+
get("@shift").as("shift").should(beChecked());
774+
get("@ctrl").as("ctrl").should(beChecked());
775+
get("@meta").as("meta").should(beChecked());
776+
get("@alt").as("alt").should(beChecked());
777+
get("@cmd").as("cmd").should(beChecked());
778+
}
779+
);
780+
781+
test(
782+
"handles all mouse events with modifiers",
783+
html`
784+
<div x-data="{ keys: {
785+
shift: false,
786+
ctrl: false,
787+
meta: false,
788+
alt: false,
789+
cmd: false
790+
} }">
791+
<button type=button
792+
@click.capture="Object.keys(keys).forEach(key => keys[key] = false)"
793+
@contextmenu.prevent.shift="keys.shift = true"
794+
@auxclick.ctrl="keys.ctrl = true"
795+
@dblclick.meta="keys.meta = true"
796+
@mouseenter.alt="keys.alt = true"
797+
@mousemove.cmd="keys.cmd = true">
798+
change
799+
</button>
800+
<template x-for="key in Object.keys(keys)" :key="key">
801+
<input type="checkbox" :name="key" x-model="keys[key]">
802+
</template>
803+
</div>
804+
`,({ get }) => {
805+
get("input[name=shift]").as('shift').should(notBeChecked());
806+
get("input[name=ctrl]").as('ctrl').should(notBeChecked());
807+
get("input[name=meta]").as('meta').should(notBeChecked());
808+
get("input[name=alt]").as('alt').should(notBeChecked());
809+
get("input[name=cmd]").as('cmd').should(notBeChecked());
810+
get("button").as('button').trigger("contextmenu", { shiftKey: true });
811+
get('@shift').should(beChecked());
812+
get("@button").trigger("click");
813+
get("@button").trigger("auxclick", { ctrlKey: true });
814+
get("@shift").should(notBeChecked());
815+
get("@ctrl").should(beChecked());
816+
get("@button").trigger("click");
817+
get("@button").trigger("dblclick", { metaKey: true });
818+
get("@ctrl").should(notBeChecked());
819+
get("@meta").should(beChecked());
820+
get("@button").trigger("click");
821+
get("@button").trigger("mouseenter", { altKey: true });
822+
get("@meta").should(notBeChecked());
823+
get("@alt").should(beChecked());
824+
get("@button").trigger("click");
825+
get("@button").trigger("mousemove", { metaKey: true });
826+
get("@alt").should(notBeChecked());
827+
get("@cmd").should(beChecked());
828+
}
829+
);

0 commit comments

Comments
 (0)