zero-query 1.0.1 → 1.0.5

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.
@@ -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
  // ===========================================================================
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { createRouter, getRouter } from '../src/router.js';
2
+ import { createRouter, getRouter, matchRoute } from '../src/router.js';
3
3
  import { component } from '../src/component.js';
4
4
 
5
5
  // Register stub components used in route definitions so mount() doesn't throw
@@ -809,6 +809,70 @@ describe('Router - navigation chaining', () => {
809
809
  });
810
810
 
811
811
 
812
+ // ---------------------------------------------------------------------------
813
+ // matchRoute - standalone route matcher (DOM-free)
814
+ // ---------------------------------------------------------------------------
815
+
816
+ describe('matchRoute', () => {
817
+ const routes = [
818
+ { path: '/', component: 'home-page' },
819
+ { path: '/blog', component: 'blog-list' },
820
+ { path: '/blog/:slug', component: 'blog-post' },
821
+ { path: '/user/:id', component: 'user-page' },
822
+ { path: '/files/*', component: 'file-browser' },
823
+ ];
824
+
825
+ it('matches a static route', () => {
826
+ expect(matchRoute(routes, '/')).toEqual({ component: 'home-page', params: {} });
827
+ expect(matchRoute(routes, '/blog')).toEqual({ component: 'blog-list', params: {} });
828
+ });
829
+
830
+ it('matches a parameterized route', () => {
831
+ expect(matchRoute(routes, '/blog/hello-world')).toEqual({
832
+ component: 'blog-post',
833
+ params: { slug: 'hello-world' },
834
+ });
835
+ });
836
+
837
+ it('matches multiple params', () => {
838
+ const r = [{ path: '/user/:id/post/:pid', component: 'user-post' }];
839
+ expect(matchRoute(r, '/user/42/post/7')).toEqual({
840
+ component: 'user-post',
841
+ params: { id: '42', pid: '7' },
842
+ });
843
+ });
844
+
845
+ it('matches wildcard routes', () => {
846
+ const result = matchRoute(routes, '/files/docs/readme.md');
847
+ expect(result.component).toBe('file-browser');
848
+ });
849
+
850
+ it('returns fallback when nothing matches', () => {
851
+ expect(matchRoute(routes, '/nope')).toEqual({ component: 'not-found', params: {} });
852
+ });
853
+
854
+ it('accepts a custom fallback component name', () => {
855
+ expect(matchRoute(routes, '/nope', '404-page')).toEqual({ component: '404-page', params: {} });
856
+ });
857
+
858
+ it('matches first route when multiple could match', () => {
859
+ const r = [
860
+ { path: '/a', component: 'first' },
861
+ { path: '/a', component: 'second' },
862
+ ];
863
+ expect(matchRoute(r, '/a').component).toBe('first');
864
+ });
865
+
866
+ it('handles per-route fallback aliases', () => {
867
+ const r = [
868
+ { path: '/docs/:section', component: 'docs-page', fallback: '/docs' },
869
+ ];
870
+ expect(matchRoute(r, '/docs/intro')).toEqual({ component: 'docs-page', params: { section: 'intro' } });
871
+ expect(matchRoute(r, '/docs')).toEqual({ component: 'docs-page', params: {} });
872
+ });
873
+ });
874
+
875
+
812
876
  // ---------------------------------------------------------------------------
813
877
  // Hash mode path parsing
814
878
  // ---------------------------------------------------------------------------