فهرست منبع

:sparkles: Enhances clicks with key modifiers (#4209)

* :white_check_mark: Adds comprehensive Modifier test

* :test_tube: Adds failing test for mouse modifiers

* :sparkles: Allows modifier keys on click events

* :memo: Updates Documentation

* :bug: Allows all mouse events
Eric Kwoka 1 سال پیش
والد
کامیت
4fa8eafa06
3فایلهای تغییر یافته به همراه233 افزوده شده و 28 حذف شده
  1. 17 8
      packages/alpinejs/src/utils/on.js
  2. 59 19
      packages/docs/src/en/directives/on.md
  3. 157 1
      tests/cypress/integration/directives/x-on.spec.js

+ 17 - 8
packages/alpinejs/src/utils/on.js

@@ -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)
 
@@ -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')) {
@@ -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
         }
     }

+ 59 - 19
packages/docs/src/en/directives/on.md

@@ -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
@@ -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
@@ -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)
-

+ 157 - 1
tests/cypress/integration/directives/x-on.spec.js

@@ -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`
@@ -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());
+    }
+);