zero-query 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/component.js CHANGED
@@ -678,13 +678,28 @@ class Component {
678
678
  if (el.contains(e.target)) continue;
679
679
  }
680
680
 
681
- // Key modifiers - filter keyboard events by key
681
+ // Key modifiers - filter keyboard events by key.
682
+ // Named shortcuts map common names to their e.key values.
683
+ // Any modifier not recognised as a built-in behaviour, timing,
684
+ // or system modifier is matched against e.key (case-insensitive)
685
+ // so that arbitrary keys work: .a, .f1, .+, .0, .arrowup, etc.
682
686
  const _keyMap = { enter: 'Enter', escape: 'Escape', tab: 'Tab', space: ' ', delete: 'Delete|Backspace', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' };
687
+ const _nonKeyMods = new Set(['prevent','stop','self','once','outside','capture','passive','debounce','throttle','ctrl','shift','alt','meta']);
683
688
  let keyFiltered = false;
684
- for (const mod of modifiers) {
689
+ for (let mi = 0; mi < modifiers.length; mi++) {
690
+ const mod = modifiers[mi];
685
691
  if (_keyMap[mod]) {
686
692
  const keys = _keyMap[mod].split('|');
687
693
  if (!e.key || !keys.includes(e.key)) { keyFiltered = true; break; }
694
+ } else if (_nonKeyMods.has(mod)) {
695
+ continue;
696
+ } else if (/^\d+$/.test(mod) && mi > 0 && (modifiers[mi - 1] === 'debounce' || modifiers[mi - 1] === 'throttle')) {
697
+ // Numeric value following debounce/throttle — skip (it's a ms parameter)
698
+ continue;
699
+ } else {
700
+ // Dynamic key match — compare modifier against e.key
701
+ // Case-insensitive: .a matches 'a' and 'A', .f1 matches 'F1'
702
+ if (!e.key || e.key.toLowerCase() !== mod.toLowerCase()) { keyFiltered = true; break; }
688
703
  }
689
704
  }
690
705
  if (keyFiltered) continue;
@@ -2742,6 +2742,486 @@ describe('component - combined key + system modifiers', () => {
2742
2742
  });
2743
2743
 
2744
2744
 
2745
+ // ===========================================================================
2746
+ // Dynamic key modifiers - arbitrary keys matched against e.key
2747
+ // ===========================================================================
2748
+
2749
+ describe('component - dynamic key modifier: single letter keys', () => {
2750
+ it('.a fires only on "a" key (case-insensitive)', () => {
2751
+ let count = 0;
2752
+ component('dkey-a', {
2753
+ handler() { count++; },
2754
+ render() { return '<input @keydown.a="handler">'; },
2755
+ });
2756
+ document.body.innerHTML = '<dkey-a id="dka"></dkey-a>';
2757
+ mount('#dka', 'dkey-a');
2758
+ const input = document.querySelector('#dka input');
2759
+
2760
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
2761
+ expect(count).toBe(1);
2762
+
2763
+ // Uppercase A should also match (case-insensitive)
2764
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'A', bubbles: true }));
2765
+ expect(count).toBe(2);
2766
+
2767
+ // Other keys must NOT fire
2768
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', bubbles: true }));
2769
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
2770
+ expect(count).toBe(2);
2771
+ });
2772
+
2773
+ it('.z fires only on "z" key', () => {
2774
+ let count = 0;
2775
+ component('dkey-z', {
2776
+ handler() { count++; },
2777
+ render() { return '<input @keydown.z="handler">'; },
2778
+ });
2779
+ document.body.innerHTML = '<dkey-z id="dkz"></dkey-z>';
2780
+ mount('#dkz', 'dkey-z');
2781
+ const input = document.querySelector('#dkz input');
2782
+
2783
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', bubbles: true }));
2784
+ expect(count).toBe(1);
2785
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Z', bubbles: true }));
2786
+ expect(count).toBe(2);
2787
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
2788
+ expect(count).toBe(2);
2789
+ });
2790
+ });
2791
+
2792
+ describe('component - dynamic key modifier: function keys', () => {
2793
+ it('.f1 fires only on F1', () => {
2794
+ let count = 0;
2795
+ component('dkey-f1', {
2796
+ handler() { count++; },
2797
+ render() { return '<div @keydown.f1="handler" tabindex="0">x</div>'; },
2798
+ });
2799
+ document.body.innerHTML = '<dkey-f1 id="dkf1"></dkey-f1>';
2800
+ mount('#dkf1', 'dkey-f1');
2801
+ const el = document.querySelector('#dkf1 div');
2802
+
2803
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1', bubbles: true }));
2804
+ expect(count).toBe(1);
2805
+
2806
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', bubbles: true }));
2807
+ expect(count).toBe(1);
2808
+ });
2809
+
2810
+ it('.f12 fires only on F12', () => {
2811
+ let count = 0;
2812
+ component('dkey-f12', {
2813
+ handler() { count++; },
2814
+ render() { return '<div @keydown.f12="handler" tabindex="0">x</div>'; },
2815
+ });
2816
+ document.body.innerHTML = '<dkey-f12 id="dkf12"></dkey-f12>';
2817
+ mount('#dkf12', 'dkey-f12');
2818
+ const el = document.querySelector('#dkf12 div');
2819
+
2820
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F12', bubbles: true }));
2821
+ expect(count).toBe(1);
2822
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F1', bubbles: true }));
2823
+ expect(count).toBe(1);
2824
+ });
2825
+ });
2826
+
2827
+ describe('component - dynamic key modifier: numeric keys', () => {
2828
+ it('.0 fires only on "0" key (not confused with debounce/throttle ms)', () => {
2829
+ let count = 0;
2830
+ component('dkey-zero', {
2831
+ handler() { count++; },
2832
+ render() { return '<input @keydown.0="handler">'; },
2833
+ });
2834
+ document.body.innerHTML = '<dkey-zero id="dk0"></dkey-zero>';
2835
+ mount('#dk0', 'dkey-zero');
2836
+ const input = document.querySelector('#dk0 input');
2837
+
2838
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: '0', bubbles: true }));
2839
+ expect(count).toBe(1);
2840
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true }));
2841
+ expect(count).toBe(1);
2842
+ });
2843
+ });
2844
+
2845
+ describe('component - dynamic key modifier: special/punctuation keys', () => {
2846
+ it('.+ fires on "+" key', () => {
2847
+ let count = 0;
2848
+ component('dkey-plus', {
2849
+ handler() { count++; },
2850
+ render() { return '<div @keydown.+="handler" tabindex="0">x</div>'; },
2851
+ });
2852
+ document.body.innerHTML = '<dkey-plus id="dkplus"></dkey-plus>';
2853
+ mount('#dkplus', 'dkey-plus');
2854
+ const el = document.querySelector('#dkplus div');
2855
+
2856
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true }));
2857
+ expect(count).toBe(1);
2858
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: '-', bubbles: true }));
2859
+ expect(count).toBe(1);
2860
+ });
2861
+
2862
+ it('.- fires on "-" key', () => {
2863
+ let count = 0;
2864
+ component('dkey-minus', {
2865
+ handler() { count++; },
2866
+ render() { return '<div @keydown.-="handler" tabindex="0">x</div>'; },
2867
+ });
2868
+ document.body.innerHTML = '<dkey-minus id="dkminus"></dkey-minus>';
2869
+ mount('#dkminus', 'dkey-minus');
2870
+ const el = document.querySelector('#dkminus div');
2871
+
2872
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: '-', bubbles: true }));
2873
+ expect(count).toBe(1);
2874
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: '+', bubbles: true }));
2875
+ expect(count).toBe(1);
2876
+ });
2877
+ });
2878
+
2879
+ describe('component - dynamic key modifier combined with system modifiers', () => {
2880
+ it('.ctrl.s fires only on Ctrl+S', () => {
2881
+ let count = 0;
2882
+ component('dkey-ctrl-s', {
2883
+ handler() { count++; },
2884
+ render() { return '<div @keydown.ctrl.s="handler" tabindex="0">x</div>'; },
2885
+ });
2886
+ document.body.innerHTML = '<dkey-ctrl-s id="dkcs"></dkey-ctrl-s>';
2887
+ mount('#dkcs', 'dkey-ctrl-s');
2888
+ const el = document.querySelector('#dkcs div');
2889
+
2890
+ // S without Ctrl → no fire
2891
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, ctrlKey: false }));
2892
+ expect(count).toBe(0);
2893
+
2894
+ // Ctrl without S → no fire
2895
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, ctrlKey: true }));
2896
+ expect(count).toBe(0);
2897
+
2898
+ // Ctrl+S → fire
2899
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 's', bubbles: true, ctrlKey: true }));
2900
+ expect(count).toBe(1);
2901
+
2902
+ // Ctrl+Shift+S (uppercase) → also fires (case insensitive)
2903
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'S', bubbles: true, ctrlKey: true }));
2904
+ expect(count).toBe(2);
2905
+ });
2906
+
2907
+ it('.meta.k fires only on Meta+K', () => {
2908
+ let count = 0;
2909
+ component('dkey-meta-k', {
2910
+ handler() { count++; },
2911
+ render() { return '<div @keydown.meta.k="handler" tabindex="0">x</div>'; },
2912
+ });
2913
+ document.body.innerHTML = '<dkey-meta-k id="dkmk"></dkey-meta-k>';
2914
+ mount('#dkmk', 'dkey-meta-k');
2915
+ const el = document.querySelector('#dkmk div');
2916
+
2917
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true, metaKey: false }));
2918
+ expect(count).toBe(0);
2919
+
2920
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', bubbles: true, metaKey: true }));
2921
+ expect(count).toBe(1);
2922
+ });
2923
+
2924
+ it('.alt.shift.f fires only on Alt+Shift+F', () => {
2925
+ let count = 0;
2926
+ component('dkey-alt-sf', {
2927
+ handler() { count++; },
2928
+ render() { return '<div @keydown.alt.shift.f="handler" tabindex="0">x</div>'; },
2929
+ });
2930
+ document.body.innerHTML = '<dkey-alt-sf id="dkas"></dkey-alt-sf>';
2931
+ mount('#dkas', 'dkey-alt-sf');
2932
+ const el = document.querySelector('#dkas div');
2933
+
2934
+ // Missing shift
2935
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', bubbles: true, altKey: true, shiftKey: false }));
2936
+ expect(count).toBe(0);
2937
+
2938
+ // Missing alt
2939
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', bubbles: true, altKey: false, shiftKey: true }));
2940
+ expect(count).toBe(0);
2941
+
2942
+ // Both held
2943
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', bubbles: true, altKey: true, shiftKey: true }));
2944
+ expect(count).toBe(1);
2945
+ });
2946
+ });
2947
+
2948
+ describe('component - dynamic key modifier combined with behaviour modifiers', () => {
2949
+ it('.a.prevent calls preventDefault only on "a" key', () => {
2950
+ component('dkey-a-prev', {
2951
+ handler() {},
2952
+ render() { return '<input @keydown.a.prevent="handler">'; },
2953
+ });
2954
+ document.body.innerHTML = '<dkey-a-prev id="dkap"></dkey-a-prev>';
2955
+ mount('#dkap', 'dkey-a-prev');
2956
+ const input = document.querySelector('#dkap input');
2957
+
2958
+ const aEvt = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true });
2959
+ vi.spyOn(aEvt, 'preventDefault');
2960
+ input.dispatchEvent(aEvt);
2961
+ expect(aEvt.preventDefault).toHaveBeenCalled();
2962
+
2963
+ const bEvt = new KeyboardEvent('keydown', { key: 'b', bubbles: true, cancelable: true });
2964
+ vi.spyOn(bEvt, 'preventDefault');
2965
+ input.dispatchEvent(bEvt);
2966
+ expect(bEvt.preventDefault).not.toHaveBeenCalled();
2967
+ });
2968
+
2969
+ it('.ctrl.s.prevent.stop calls both preventDefault and stopPropagation', () => {
2970
+ component('dkey-cs-ps', {
2971
+ handler() {},
2972
+ render() { return '<div @keydown.ctrl.s.prevent.stop="handler" tabindex="0">x</div>'; },
2973
+ });
2974
+ document.body.innerHTML = '<dkey-cs-ps id="dkcsps"></dkey-cs-ps>';
2975
+ mount('#dkcsps', 'dkey-cs-ps');
2976
+ const el = document.querySelector('#dkcsps div');
2977
+
2978
+ const evt = new KeyboardEvent('keydown', { key: 's', bubbles: true, cancelable: true, ctrlKey: true });
2979
+ vi.spyOn(evt, 'preventDefault');
2980
+ vi.spyOn(evt, 'stopPropagation');
2981
+ el.dispatchEvent(evt);
2982
+ expect(evt.preventDefault).toHaveBeenCalled();
2983
+ expect(evt.stopPropagation).toHaveBeenCalled();
2984
+ });
2985
+ });
2986
+
2987
+ describe('component - dynamic key modifier: named shortcuts still work', () => {
2988
+ it('named shortcuts take priority over dynamic matching', () => {
2989
+ // .space maps to ' ' (literal space char) via _keyMap, NOT "space" string
2990
+ let count = 0;
2991
+ component('dkey-space-prio', {
2992
+ handler() { count++; },
2993
+ render() { return '<button @keydown.space="handler">x</button>'; },
2994
+ });
2995
+ document.body.innerHTML = '<dkey-space-prio id="dksp"></dkey-space-prio>';
2996
+ mount('#dksp', 'dkey-space-prio');
2997
+ const btn = document.querySelector('#dksp button');
2998
+
2999
+ // ' ' is the actual e.key for Space
3000
+ btn.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
3001
+ expect(count).toBe(1);
3002
+
3003
+ // 'space' is NOT the e.key - should NOT fire because .space uses _keyMap
3004
+ btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'space', bubbles: true }));
3005
+ expect(count).toBe(1);
3006
+ });
3007
+
3008
+ it('.delete still matches both Delete and Backspace', () => {
3009
+ let count = 0;
3010
+ component('dkey-del-prio', {
3011
+ handler() { count++; },
3012
+ render() { return '<input @keydown.delete="handler">'; },
3013
+ });
3014
+ document.body.innerHTML = '<dkey-del-prio id="dkdp"></dkey-del-prio>';
3015
+ mount('#dkdp', 'dkey-del-prio');
3016
+ const input = document.querySelector('#dkdp input');
3017
+
3018
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
3019
+ expect(count).toBe(1);
3020
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }));
3021
+ expect(count).toBe(2);
3022
+ });
3023
+ });
3024
+
3025
+ describe('component - dynamic key modifier: no-key events are not filtered', () => {
3026
+ it('click events pass through dynamic key modifiers without e.key', () => {
3027
+ // A .prevent on a click should not be treated as a key filter
3028
+ let count = 0;
3029
+ component('dkey-click-ok', {
3030
+ handler() { count++; },
3031
+ render() { return '<button @click.prevent="handler">x</button>'; },
3032
+ });
3033
+ document.body.innerHTML = '<dkey-click-ok id="dkco"></dkey-click-ok>';
3034
+ mount('#dkco', 'dkey-click-ok');
3035
+ const btn = document.querySelector('#dkco button');
3036
+
3037
+ btn.click();
3038
+ expect(count).toBe(1);
3039
+ });
3040
+ });
3041
+
3042
+ describe('component - dynamic key modifier: debounce/throttle ms values not treated as keys', () => {
3043
+ it('.debounce.300 does not treat "300" as a key filter', async () => {
3044
+ let count = 0;
3045
+ component('dkey-deb-num', {
3046
+ handler() { count++; },
3047
+ render() { return '<input @input.debounce.300="handler">'; },
3048
+ });
3049
+ document.body.innerHTML = '<dkey-deb-num id="dkdn"></dkey-deb-num>';
3050
+ mount('#dkdn', 'dkey-deb-num');
3051
+ const input = document.querySelector('#dkdn input');
3052
+
3053
+ input.dispatchEvent(new Event('input', { bubbles: true }));
3054
+ // Should be debounced, not blocked by "300" key filter
3055
+ await new Promise(r => setTimeout(r, 350));
3056
+ expect(count).toBe(1);
3057
+ });
3058
+ });
3059
+
3060
+ describe('component - dynamic key modifier: multiple dynamic keys on separate bindings', () => {
3061
+ it('separate @keydown.a and @keydown.b fire independently', () => {
3062
+ let aCount = 0, bCount = 0;
3063
+ component('dkey-ab', {
3064
+ handlerA() { aCount++; },
3065
+ handlerB() { bCount++; },
3066
+ render() { return '<input @keydown.a="handlerA" @keydown.b="handlerB">'; },
3067
+ });
3068
+ document.body.innerHTML = '<dkey-ab id="dkab"></dkey-ab>';
3069
+ mount('#dkab', 'dkey-ab');
3070
+ const input = document.querySelector('#dkab input');
3071
+
3072
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
3073
+ expect(aCount).toBe(1);
3074
+ expect(bCount).toBe(0);
3075
+
3076
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', bubbles: true }));
3077
+ expect(aCount).toBe(1);
3078
+ expect(bCount).toBe(1);
3079
+
3080
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', bubbles: true }));
3081
+ expect(aCount).toBe(1);
3082
+ expect(bCount).toBe(1);
3083
+ });
3084
+ });
3085
+
3086
+ describe('component - dynamic key modifier: keyup vs keydown', () => {
3087
+ it('.a works on keyup events too', () => {
3088
+ let downCount = 0, upCount = 0;
3089
+ component('dkey-updown', {
3090
+ onDown() { downCount++; },
3091
+ onUp() { upCount++; },
3092
+ render() { return '<input @keydown.a="onDown" @keyup.a="onUp">'; },
3093
+ });
3094
+ document.body.innerHTML = '<dkey-updown id="dkud"></dkey-updown>';
3095
+ mount('#dkud', 'dkey-updown');
3096
+ const input = document.querySelector('#dkud input');
3097
+
3098
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
3099
+ expect(downCount).toBe(1);
3100
+ expect(upCount).toBe(0);
3101
+
3102
+ input.dispatchEvent(new KeyboardEvent('keyup', { key: 'a', bubbles: true }));
3103
+ expect(downCount).toBe(1);
3104
+ expect(upCount).toBe(1);
3105
+
3106
+ // Wrong key on both → neither fires
3107
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'b', bubbles: true }));
3108
+ input.dispatchEvent(new KeyboardEvent('keyup', { key: 'b', bubbles: true }));
3109
+ expect(downCount).toBe(1);
3110
+ expect(upCount).toBe(1);
3111
+ });
3112
+ });
3113
+
3114
+ describe('component - dynamic key modifier: edge cases with e.key', () => {
3115
+ it('event without e.key property does not fire dynamic key handler', () => {
3116
+ let count = 0;
3117
+ component('dkey-nokey', {
3118
+ handler() { count++; },
3119
+ render() { return '<div @keydown.a="handler" tabindex="0">x</div>'; },
3120
+ });
3121
+ document.body.innerHTML = '<dkey-nokey id="dknk"></dkey-nokey>';
3122
+ mount('#dknk', 'dkey-nokey');
3123
+ const el = document.querySelector('#dknk div');
3124
+
3125
+ // KeyboardEvent with no key specified → e.key is empty string
3126
+ el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true }));
3127
+ expect(count).toBe(0);
3128
+ });
3129
+
3130
+ it('case-insensitive match for multi-char keys like PageDown', () => {
3131
+ let count = 0;
3132
+ component('dkey-pgdn', {
3133
+ handler() { count++; },
3134
+ render() { return '<div @keydown.pagedown="handler" tabindex="0">x</div>'; },
3135
+ });
3136
+ document.body.innerHTML = '<dkey-pgdn id="dkpd"></dkey-pgdn>';
3137
+ mount('#dkpd', 'dkey-pgdn');
3138
+ const el = document.querySelector('#dkpd div');
3139
+
3140
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true }));
3141
+ expect(count).toBe(1);
3142
+
3143
+ el.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true }));
3144
+ expect(count).toBe(1);
3145
+ });
3146
+
3147
+ it('.home and .end match Home / End keys', () => {
3148
+ let homeCount = 0, endCount = 0;
3149
+ component('dkey-homeend', {
3150
+ onHome() { homeCount++; },
3151
+ onEnd() { endCount++; },
3152
+ render() { return '<input @keydown.home="onHome" @keydown.end="onEnd">'; },
3153
+ });
3154
+ document.body.innerHTML = '<dkey-homeend id="dkhe"></dkey-homeend>';
3155
+ mount('#dkhe', 'dkey-homeend');
3156
+ const input = document.querySelector('#dkhe input');
3157
+
3158
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
3159
+ expect(homeCount).toBe(1);
3160
+ expect(endCount).toBe(0);
3161
+
3162
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
3163
+ expect(homeCount).toBe(1);
3164
+ expect(endCount).toBe(1);
3165
+ });
3166
+
3167
+ it('.insert matches Insert key', () => {
3168
+ let count = 0;
3169
+ component('dkey-ins', {
3170
+ handler() { count++; },
3171
+ render() { return '<input @keydown.insert="handler">'; },
3172
+ });
3173
+ document.body.innerHTML = '<dkey-ins id="dkins"></dkey-ins>';
3174
+ mount('#dkins', 'dkey-ins');
3175
+ const input = document.querySelector('#dkins input');
3176
+
3177
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Insert', bubbles: true }));
3178
+ expect(count).toBe(1);
3179
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }));
3180
+ expect(count).toBe(1);
3181
+ });
3182
+ });
3183
+
3184
+ describe('component - dynamic key modifier: non-interfering with existing modifiers', () => {
3185
+ it('.once still works with dynamic key', () => {
3186
+ let count = 0;
3187
+ component('dkey-once-a', {
3188
+ handler() { count++; },
3189
+ render() { return '<input @keydown.a.once="handler">'; },
3190
+ });
3191
+ document.body.innerHTML = '<dkey-once-a id="dkoa"></dkey-once-a>';
3192
+ mount('#dkoa', 'dkey-once-a');
3193
+ const input = document.querySelector('#dkoa input');
3194
+
3195
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
3196
+ expect(count).toBe(1);
3197
+
3198
+ // Second press should NOT fire (.once)
3199
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
3200
+ expect(count).toBe(1);
3201
+ });
3202
+
3203
+ it('.self still works with dynamic key', () => {
3204
+ let count = 0;
3205
+ component('dkey-self-a', {
3206
+ handler() { count++; },
3207
+ render() { return '<div @keydown.a.self="handler" tabindex="0"><span>child</span></div>'; },
3208
+ });
3209
+ document.body.innerHTML = '<dkey-self-a id="dksa"></dkey-self-a>';
3210
+ mount('#dksa', 'dkey-self-a');
3211
+ const div = document.querySelector('#dksa div');
3212
+ const span = document.querySelector('#dksa span');
3213
+
3214
+ // Fire on div itself → should work
3215
+ div.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
3216
+ expect(count).toBe(1);
3217
+
3218
+ // Fire from child → should NOT fire (.self)
3219
+ span.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true }));
3220
+ expect(count).toBe(1);
3221
+ });
3222
+ });
3223
+
3224
+
2745
3225
  // ===========================================================================
2746
3226
  // .outside modifier - fire when event target is outside the element
2747
3227
  // ===========================================================================
@@ -8,7 +8,7 @@ function minifyCSS(css) {
8
8
  }
9
9
 
10
10
  const tests = [
11
- // Pseudo-classes with descendant combinator (the bug we fixed)
11
+ // Pseudo-classes with descendant combinator (the bug was that the space before :not() was being removed, which is incorrect)
12
12
  ['.docs-section :not(pre) > code { color: red }', 'space before :not()'],
13
13
  ['.foo :has(.bar) { color: red }', 'space before :has()'],
14
14
  ['.foo :is(.a, .b) { color: red }', 'space before :is()'],
@@ -18,12 +18,12 @@ const tests = [
18
18
  ['.foo::before { content: "x" }', '::before'],
19
19
  ['.foo ::after { content: "x" }', 'space before ::after'],
20
20
 
21
- // Pseudo-classes directly on element (no space should stay compact)
21
+ // Pseudo-classes directly on element (no space - should stay compact)
22
22
  ['a:hover { color: red }', ':hover no space'],
23
23
  ['li:nth-child(2n + 1) { color: red }', ':nth-child()'],
24
24
  ['input:focus-visible { outline: 1px }', ':focus-visible'],
25
25
 
26
- // calc() spaces around operators are significant!
26
+ // calc() - spaces around operators are significant!
27
27
  ['.foo { width: calc(100% - 20px) }', 'calc() spaces'],
28
28
  ['.foo { width: calc(50vw + 2rem) }', 'calc() addition'],
29
29
  ['.foo { font-size: clamp(1rem, 2vw, 3rem) }', 'clamp()'],
@@ -145,7 +145,7 @@ assert('.bar :not(.a):not(.b) { color: red }',
145
145
  // Edge case: content with double spaces inside quotes
146
146
  const dblSpace = minifyCSS('.foo::before { content: "a b" }');
147
147
  if (dblSpace.includes('content:"a b"')) {
148
- console.log('WARN content double space "a b" collapsed to "a b" (cosmetic, not functional)');
148
+ console.log('WARN content double space - "a b" collapsed to "a b" (cosmetic, not functional)');
149
149
  } else {
150
150
  console.log('PASS content double space preserved');
151
151
  }
package/types/misc.d.ts CHANGED
@@ -165,15 +165,37 @@ export function safeEval(expr: string, scope: object[]): any;
165
165
  /**
166
166
  * Supported event modifier strings for `@event` and `z-on:event` bindings.
167
167
  * Modifiers are appended to the event name with dots, e.g. `@click.prevent.stop`.
168
+ *
169
+ * **Key modifiers** — named shortcuts (`.enter`, `.escape`, `.tab`, `.space`,
170
+ * `.delete`, `.up`, `.down`, `.left`, `.right`) plus any arbitrary key matched
171
+ * case-insensitively against `KeyboardEvent.key` (e.g. `.a`, `.f1`, `.+`).
172
+ *
173
+ * **System modifiers** — `.ctrl`, `.shift`, `.alt`, `.meta` require the
174
+ * corresponding modifier key to be held.
168
175
  */
169
176
  export type EventModifier =
170
177
  | 'prevent'
171
178
  | 'stop'
172
179
  | 'self'
173
180
  | 'once'
181
+ | 'outside'
174
182
  | 'capture'
175
183
  | 'passive'
176
184
  | `debounce`
177
185
  | `debounce.${number}`
178
186
  | `throttle`
179
- | `throttle.${number}`;
187
+ | `throttle.${number}`
188
+ | 'enter'
189
+ | 'escape'
190
+ | 'tab'
191
+ | 'space'
192
+ | 'delete'
193
+ | 'up'
194
+ | 'down'
195
+ | 'left'
196
+ | 'right'
197
+ | 'ctrl'
198
+ | 'shift'
199
+ | 'alt'
200
+ | 'meta'
201
+ | (string & {});