Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Enhances clicks with key modifiers #4209

Merged
merged 5 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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());
}
);
Loading