Skip to content

Commit 162bba3

Browse files
Merge pull request #63653 from nextcloud/backport/63541/stable35
[stable35] fix(core): don't hijack Ctrl+F in editors or behind modals
2 parents e7c78d8 + 2d9624c commit 162bba3

6 files changed

Lines changed: 157 additions & 86 deletions

File tree

core/src/tests/components/UnifiedSearch.spec.ts

Lines changed: 130 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
4370
beforeEach(() => {
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

5084
describe('UnifiedSearch open-state model', () => {
5185
it('desktop: typing opens, clearing closes', async () => {
@@ -85,13 +119,8 @@ describe('UnifiedSearch open-state model', () => {
85119
})
86120

87121
describe('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

158218
describe('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

core/src/views/UnifiedSearch.vue

Lines changed: 21 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
<script lang="ts">
3333
import { emit, subscribe } from '@nextcloud/event-bus'
3434
import { t } from '@nextcloud/l10n'
35+
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
3536
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
3637
import { useBrowserLocation } from '@vueuse/core'
3738
import debounce from 'debounce'
@@ -124,10 +125,24 @@ export default defineComponent({
124125
},
125126
126127
mounted() {
127-
// register keyboard listener for search shortcut
128-
if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) {
129-
window.addEventListener('keydown', this.onKeyDown)
130-
}
128+
// useHotKey owns the accessibility opt-out and the guards that keep shortcuts out
129+
// of editors, inputs and open modals. The key filter runs before it calls
130+
// preventDefault, so returning false there leaves the key to the browser.
131+
this.stopHotKeys = [
132+
useHotKey(
133+
(event) => event.key.toLowerCase() === 'f'
134+
&& !this.appHandlesSearchShortcut
135+
// A second press belongs to the browser's native find.
136+
&& !this.isSearchEngaged(),
137+
() => this.focusSearch(),
138+
{ ctrl: true, prevent: true },
139+
),
140+
useHotKey(
141+
(event) => event.key.toLowerCase() === 'k',
142+
() => this.focusSearch(),
143+
{ ctrl: true, prevent: true },
144+
),
145+
]
131146
132147
// Allow external reset of the search
133148
subscribe('nextcloud:unified-search:reset', () => {
@@ -147,47 +162,12 @@ export default defineComponent({
147162
},
148163
149164
// Vue 2.7 only recognises beforeDestroy/destroyed as Options lifecycle hooks;
150-
// a beforeUnmount() option is silently ignored, so the listener must be removed here.
165+
// a beforeUnmount() option is silently ignored, so the listeners must be removed here.
151166
beforeDestroy() {
152-
// keep in mind to remove the event listener
153-
window.removeEventListener('keydown', this.onKeyDown)
167+
this.stopHotKeys.forEach((stop) => stop())
154168
},
155169
156170
methods: {
157-
/**
158-
* Handle the key down event to open search on `ctrl + F`
159-
*
160-
* @param event The keyboard event
161-
*/
162-
onKeyDown(event: KeyboardEvent) {
163-
// Match on the lowercased key so Caps Lock / Shift (event.key === 'F'/'K')
164-
// still triggers the shortcut instead of silently falling through.
165-
const key = event.key.toLowerCase()
166-
if (event.ctrlKey && key === 'f') {
167-
// Skip on pages that handle Ctrl+F themselves (e.g. a dedicated search input).
168-
if (this.appHandlesSearchShortcut) {
169-
return
170-
}
171-
// Otherwise behave like Ctrl+K: focus the input (desktop) / open the
172-
// modal (mobile). Once search is already engaged, let a second press fall
173-
// through to the browser's native find instead of claiming Ctrl+F again.
174-
if (this.isSearchEngaged()) {
175-
return
176-
}
177-
event.preventDefault()
178-
this.focusSearch()
179-
} else if ((event.metaKey || event.ctrlKey) && key === 'k') {
180-
// Global focus shortcut. Same opt-out as Ctrl+F: leave pages that own the
181-
// shortcut alone. preventDefault only when we act (Ctrl+K also focuses the
182-
// browser address bar in Firefox, so we must claim it here).
183-
if (this.appHandlesSearchShortcut) {
184-
return
185-
}
186-
event.preventDefault()
187-
this.focusSearch()
188-
}
189-
},
190-
191171
/**
192172
* Bring the user into search: focus the header input on desktop, or open the
193173
* results modal on mobile. Shared by the Ctrl+F and Ctrl+K shortcuts.

dist/core-common.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-common.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-unified-search.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/core-unified-search.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)