@@ -31,21 +31,55 @@ function factory() {
3131}
3232
3333/**
34- * Dispatch Ctrl+<key> on the window and report whether the handler claimed it.
34+ * Dispatch Ctrl+<key> from an element and report whether the handler claimed it.
35+ * The guard inspects `event.target`, so this cannot dispatch on the window.
36+ *
37+ * @param key The key to press
38+ * @param target The element the keystroke originates from
3539 */
36- function pressCtrl ( key = 'k' ) {
40+ function pressCtrl ( key = 'k' , target : EventTarget = document . body ) {
3741 const event = new KeyboardEvent ( 'keydown' , { key, ctrlKey : true , bubbles : true , cancelable : true } )
3842 const prevented = vi . spyOn ( event , 'preventDefault' )
39- window . dispatchEvent ( event )
43+ target . dispatchEvent ( event )
4044 return prevented
4145}
4246
47+ /**
48+ * jsdom has no layout, so the mask's visibility has to be faked.
49+ *
50+ * @param parent Element to append the mask to
51+ */
52+ function addVisibleModalMask ( parent : Element = document . body ) {
53+ const mask = document . createElement ( 'div' )
54+ mask . classList . add ( 'modal-mask' )
55+ parent . appendChild ( mask )
56+ Element . prototype . checkVisibility = ( ) => true
57+ return mask
58+ }
59+
60+ /**
61+ * jsdom does not derive isContentEditable from the attribute.
62+ */
63+ function addContentEditable ( ) {
64+ const editor = document . createElement ( 'div' )
65+ Object . defineProperty ( editor , 'isContentEditable' , { value : true } )
66+ document . body . appendChild ( editor )
67+ return editor
68+ }
69+
4370beforeEach ( ( ) => {
4471 mobile . value = false
4572 location . value = { pathname : '/' }
46- window . OCP = { Accessibility : { disableKeyboardShortcuts : ( ) => true } }
73+ // useHotKey reads the accessibility opt-out once, when its module is first imported,
74+ // so it cannot be toggled per test. Opting out is the library's behaviour, not ours.
75+ window . OCP = { Accessibility : { disableKeyboardShortcuts : ( ) => false } }
76+ } )
77+ afterEach ( ( ) => {
78+ vi . clearAllMocks ( )
79+ document . body . replaceChildren ( )
80+ // @ts -expect-error Restore the jsdom default (absent).
81+ delete Element . prototype . checkVisibility
4782} )
48- afterEach ( ( ) => vi . clearAllMocks ( ) )
4983
5084describe ( 'UnifiedSearch open-state model' , ( ) => {
5185 it ( 'desktop: typing opens, clearing closes' , async ( ) => {
@@ -85,13 +119,8 @@ describe('UnifiedSearch open-state model', () => {
85119} )
86120
87121describe ( 'UnifiedSearch focus shortcut (Ctrl/Cmd+K)' , ( ) => {
88- function mountWithShortcuts ( ) {
89- window . OCP = { Accessibility : { disableKeyboardShortcuts : ( ) => false } }
90- return factory ( )
91- }
92-
93122 it ( 'desktop: focuses the header input and claims the key' , ( ) => {
94- const wrapper = mountWithShortcuts ( )
123+ const wrapper = factory ( )
95124 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
96125
97126 const prevented = pressCtrl ( )
@@ -103,67 +132,93 @@ describe('UnifiedSearch focus shortcut (Ctrl/Cmd+K)', () => {
103132
104133 it ( 'mobile: opens the modal instead (no header input to focus)' , ( ) => {
105134 mobile . value = true
106- const wrapper = mountWithShortcuts ( )
135+ const wrapper = factory ( )
107136
108137 pressCtrl ( )
109138
110139 expect ( wrapper . vm . showUnifiedSearch ) . toBe ( true )
111140 wrapper . destroy ( )
112141 } )
113142
114- it ( 'is not bound when the user disabled keyboard shortcuts' , ( ) => {
115- const wrapper = factory ( ) // beforeEach leaves shortcuts disabled
143+ // The allowlist means the page owns Ctrl+F, not Ctrl+K. Nothing on those pages binds
144+ // Ctrl+K, so bailing out would hand it to the browser (Firefox opens its search bar).
145+ it ( 'still claims the key on pages that own Ctrl+F' , ( ) => {
146+ location . value = { pathname : '/settings/users' }
147+ const wrapper = factory ( )
148+ const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
149+
150+ const prevented = pressCtrl ( )
151+
152+ expect ( focusInput ) . toHaveBeenCalled ( )
153+ expect ( prevented ) . toHaveBeenCalled ( )
154+ wrapper . destroy ( )
155+ } )
156+
157+ it ( 'unbinds the shortcut when the component is torn down' , ( ) => {
158+ const wrapper = factory ( )
116159 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
117160
161+ wrapper . destroy ( )
118162 pressCtrl ( )
119163
120164 expect ( focusInput ) . not . toHaveBeenCalled ( )
165+ } )
166+
167+ // Under Caps Lock / Shift, event.key is 'K'. The shortcut must still fire.
168+ it ( 'fires regardless of key case' , ( ) => {
169+ const wrapper = factory ( )
170+ const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
171+
172+ pressCtrl ( 'K' )
173+
174+ expect ( focusInput ) . toHaveBeenCalled ( )
121175 wrapper . destroy ( )
122176 } )
123177
124- it ( 'stays out of the way on pages that own the search shortcut' , ( ) => {
125- location . value = { pathname : '/settings/users' }
126- const wrapper = mountWithShortcuts ( )
178+ it ( 'leaves the key to the editor the user is typing in' , ( ) => {
179+ const wrapper = factory ( )
127180 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
128181
129- const prevented = pressCtrl ( )
182+ const prevented = pressCtrl ( 'k' , addContentEditable ( ) )
130183
131184 expect ( focusInput ) . not . toHaveBeenCalled ( )
132185 expect ( prevented ) . not . toHaveBeenCalled ( )
133186 wrapper . destroy ( )
134187 } )
135188
136- it ( 'unbinds the shortcut when the component is torn down ' , ( ) => {
137- const wrapper = mountWithShortcuts ( )
189+ it ( 'stays quiet behind an open modal from another app ' , ( ) => {
190+ const wrapper = factory ( )
138191 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
192+ addVisibleModalMask ( )
139193
140- wrapper . destroy ( )
141- pressCtrl ( )
194+ const prevented = pressCtrl ( 'k' )
142195
143196 expect ( focusInput ) . not . toHaveBeenCalled ( )
197+ expect ( prevented ) . not . toHaveBeenCalled ( )
198+ wrapper . destroy ( )
144199 } )
145200
146- // Under Caps Lock / Shift, event.key is 'K'. The shortcut must still fire.
147- it ( 'fires regardless of key case' , ( ) => {
148- const wrapper = mountWithShortcuts ( )
201+ // useHotKey has no way to exempt a component's own scrim, so the open results panel
202+ // silences Ctrl+K. Re-focusing an already-focused input is a no-op, so this is only
203+ // a papercut: the browser gets the key instead.
204+ it ( 'gives up the key behind its own results scrim' , ( ) => {
205+ const wrapper = factory ( )
149206 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
207+ document . body . appendChild ( wrapper . vm . $el )
208+ addVisibleModalMask ( wrapper . vm . $el )
150209
151- pressCtrl ( 'K ' )
210+ const prevented = pressCtrl ( 'k ' )
152211
153- expect ( focusInput ) . toHaveBeenCalled ( )
212+ expect ( focusInput ) . not . toHaveBeenCalled ( )
213+ expect ( prevented ) . not . toHaveBeenCalled ( )
154214 wrapper . destroy ( )
155215 } )
156216} )
157217
158218describe ( 'UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K' , ( ) => {
159- function mountWithShortcuts ( ) {
160- window . OCP = { Accessibility : { disableKeyboardShortcuts : ( ) => false } }
161- return factory ( )
162- }
163-
164219 // Ctrl+F used to open the modal on an empty query; it now mirrors Ctrl+K.
165220 it ( 'desktop: focuses the input instead of opening an empty modal' , ( ) => {
166- const wrapper = mountWithShortcuts ( )
221+ const wrapper = factory ( )
167222 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
168223
169224 const prevented = pressCtrl ( 'f' )
@@ -176,7 +231,7 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
176231
177232 it ( 'mobile: opens the modal (no header input to focus)' , ( ) => {
178233 mobile . value = true
179- const wrapper = mountWithShortcuts ( )
234+ const wrapper = factory ( )
180235
181236 pressCtrl ( 'f' )
182237
@@ -186,7 +241,7 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
186241
187242 it ( 'stays out of the way on pages that own the search shortcut' , ( ) => {
188243 location . value = { pathname : '/settings/users' }
189- const wrapper = mountWithShortcuts ( )
244+ const wrapper = factory ( )
190245 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
191246
192247 const prevented = pressCtrl ( 'f' )
@@ -200,7 +255,7 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
200255 // must not steal the key there. This replaces the local search bar we used to render.
201256 it ( 'leaves Ctrl+F to Deck, which filters in its own board input' , ( ) => {
202257 location . value = { pathname : '/apps/deck' }
203- const wrapper = mountWithShortcuts ( )
258+ const wrapper = factory ( )
204259 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
205260
206261 const prevented = pressCtrl ( 'f' )
@@ -214,7 +269,7 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
214269 // Once search is engaged, Ctrl+F belongs to the browser again: a second press must
215270 // reach the native find bar instead of being swallowed to re-focus what is already focused.
216271 it ( 'falls through to the browser once the results are open' , ( ) => {
217- const wrapper = mountWithShortcuts ( )
272+ const wrapper = factory ( )
218273 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
219274 wrapper . vm . showUnifiedSearch = true
220275
@@ -226,7 +281,7 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
226281 } )
227282
228283 it ( 'falls through to the browser while the header input already holds focus' , ( ) => {
229- const wrapper = mountWithShortcuts ( )
284+ const wrapper = factory ( )
230285 const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
231286 // The engaged check tests the real focused element against the input's DOM subtree,
232287 // so it needs a focusable node that is actually in the document.
@@ -245,10 +300,46 @@ describe('UnifiedSearch find shortcut (Ctrl+F) aligns with Ctrl+K', () => {
245300 wrapper . destroy ( )
246301 } )
247302
303+ it ( 'leaves the key to the editor the user is typing in' , ( ) => {
304+ const wrapper = factory ( )
305+ const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
306+
307+ const prevented = pressCtrl ( 'f' , addContentEditable ( ) )
308+
309+ expect ( focusInput ) . not . toHaveBeenCalled ( )
310+ expect ( prevented ) . not . toHaveBeenCalled ( )
311+ wrapper . destroy ( )
312+ } )
313+
314+ it ( 'leaves the key to an input the user is typing in' , ( ) => {
315+ const wrapper = factory ( )
316+ const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
317+ const field = document . createElement ( 'input' )
318+ document . body . appendChild ( field )
319+
320+ const prevented = pressCtrl ( 'f' , field )
321+
322+ expect ( focusInput ) . not . toHaveBeenCalled ( )
323+ expect ( prevented ) . not . toHaveBeenCalled ( )
324+ wrapper . destroy ( )
325+ } )
326+
327+ it ( 'stays quiet behind an open modal from another app' , ( ) => {
328+ const wrapper = factory ( )
329+ const focusInput = vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
330+ addVisibleModalMask ( )
331+
332+ const prevented = pressCtrl ( 'f' )
333+
334+ expect ( focusInput ) . not . toHaveBeenCalled ( )
335+ expect ( prevented ) . not . toHaveBeenCalled ( )
336+ wrapper . destroy ( )
337+ } )
338+
248339 // Only Ctrl+F defers to the browser. Ctrl+K has no native meaning worth preserving
249340 // (in Firefox it focuses the address bar), so it stays claimed even when engaged.
250341 it ( 'does not make Ctrl+K fall through as well' , ( ) => {
251- const wrapper = mountWithShortcuts ( )
342+ const wrapper = factory ( )
252343 vi . spyOn ( wrapper . vm , 'focusInput' ) . mockImplementation ( ( ) => { } )
253344 wrapper . vm . showUnifiedSearch = true
254345
0 commit comments