Maintenance: Desktop view - Escape key closes both quick search and flyout/dialog

This commit is contained in:
Benjamin Scharf
2025-05-06 14:20:40 +02:00
parent c8413c5ed8
commit 6f6319586c
7 changed files with 313 additions and 32 deletions

View File

@@ -1,16 +1,19 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { onKeyUp } from '@vueuse/core'
import { useTemplateRef, nextTick, onMounted, computed } from 'vue'
import { computed, nextTick, onMounted, useTemplateRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTrapTab } from '#shared/composables/useTrapTab.ts'
import stopEvent from '#shared/utils/events.ts'
import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonOverlayContainer from '#desktop/components/CommonOverlayContainer/CommonOverlayContainer.vue'
import {
KeyboardKey,
type OrderKeyHandlerConfig,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
import { getRouteIdentifier } from '#desktop/composables/useOverlayContainer.ts'
import CommonDialogActionFooter, {
@@ -35,6 +38,7 @@ export interface Props {
noAutofocus?: boolean
fullscreen?: boolean
global?: boolean
escapeConfig?: Pick<OrderKeyHandlerConfig, 'beforeHandlerRuns' | 'handler'>
}
const props = withDefaults(defineProps<Props>(), {
@@ -70,18 +74,28 @@ const close = async (cancel?: boolean) => {
const dialogId = `dialog-${props.name}`
onKeyUp('Escape', (e) => {
stopEvent(e)
close()
})
const escapeConfig: OrderKeyHandlerConfig = {
handler: close,
key: dialogId,
beforeHandlerRuns: props.escapeConfig?.beforeHandlerRuns,
}
const { unsubscribeEvent, subscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
escapeConfig,
)
watch(isActive, (isActive) =>
isActive ? subscribeEvent(escapeConfig) : unsubscribeEvent(escapeConfig),
)
useTrapTab(dialogElement)
onMounted(() => {
if (props.noAutofocus) return
// Will try to find focusable element inside dialog main and footer content.
// If it won't find it, will try to find inside the header most likely will find "Close" button.
// Will try to find a focusable element inside dialog main and footer content.
// If it doesn't find it, will try to find inside the header most likely will find "Close" button.
const firstFocusable =
getFirstFocusableElement(contentElement.value) ||
getFirstFocusableElement(footerElement.value) ||

View File

@@ -6,7 +6,6 @@ import {
useLocalStorage,
useScroll,
useActiveElement,
onKeyDown,
useCurrentElement,
type MaybeElementRef,
type ComputedRefWithControl,
@@ -30,13 +29,17 @@ import type { FormRef } from '#shared/components/Form/types.ts'
import { useForm } from '#shared/components/Form/useForm.ts'
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
import { useTrapTab } from '#shared/composables/useTrapTab.ts'
import stopEvent from '#shared/utils/events.ts'
import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonOverlayContainer from '#desktop/components/CommonOverlayContainer/CommonOverlayContainer.vue'
import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
import {
type OrderKeyHandlerConfig,
KeyboardKey,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
import { getRouteIdentifier } from '#desktop/composables/useOverlayContainer.ts'
import CommonFlyoutActionFooter from './CommonFlyoutActionFooter.vue'
@@ -69,7 +72,7 @@ export interface Props {
noCloseOnAction?: boolean
/**
* Don't focus the first element inside a Flyout after being mounted
* if nothing is focusable, will focus "Close" button when dismissible is active.
* if nothing is focusable, will focus the "Close" button when dismissible is active.
*/
noAutofocus?: boolean
fullscreen?: boolean
@@ -77,6 +80,9 @@ export interface Props {
* If true, no page context will be added to the name, e.g. for confirmation dialogs.
*/
global?: boolean
/**
*/
escapeConfig?: Pick<OrderKeyHandlerConfig, 'beforeHandlerRuns' | 'handler'>
}
const props = withDefaults(defineProps<Props>(), {
@@ -244,11 +250,28 @@ onMounted(async () => {
})
})
onKeyDown('Escape', (e) => {
const escapeHandler = () => {
if (props.noCloseOnEscape) return
stopEvent(e)
close()
})
}
const escapeConfig: OrderKeyHandlerConfig = {
handler: () => {
if (props.escapeConfig?.handler) props.escapeConfig.handler()
escapeHandler()
},
key: flyoutId,
beforeHandlerRuns: props.escapeConfig?.beforeHandlerRuns,
}
const { subscribeEvent, unsubscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
escapeConfig,
)
watch(isActive, (isActive) =>
isActive ? subscribeEvent(escapeConfig) : unsubscribeEvent(escapeConfig),
)
// Style
const contentElement = useTemplateRef('content')

View File

@@ -1,13 +1,18 @@
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { onBeforeUnmount, useTemplateRef, watch } from 'vue'
import { useTemplateRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import emitter from '#shared/utils/emitter.ts'
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
import {
KeyboardKey,
type OrderKeyHandlerConfig,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
const searchValue = defineModel<string>()
@@ -26,24 +31,26 @@ const resetInput = () => {
inputSearchInstance.value?.blur()
}
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.code === 'Escape') resetInput()
}
const goToSearchView = () => {
router.push({ name: 'search', params: { searchTerm: searchValue.value } })
resetInput()
}
watch(isSearchActive, (isActive) =>
isActive
? window.addEventListener('keydown', handleEscapeKey)
: window.removeEventListener('keydown', handleEscapeKey),
const keyHandlerConfig: OrderKeyHandlerConfig = {
handler: resetInput,
key: 'quick-search-input',
}
const { subscribeEvent, unsubscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
keyHandlerConfig,
)
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleEscapeKey)
})
watch(isSearchActive, (isActive) =>
isActive
? subscribeEvent(keyHandlerConfig)
: unsubscribeEvent(keyHandlerConfig),
)
emitter.on('focus-quick-search-field', () => inputSearchInstance.value?.focus())
emitter.on('reset-quick-search-field', () => resetInput())

View File

@@ -0,0 +1,97 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import renderComponent from '#tests/support/components/renderComponent.ts'
import { KeyboardKey } from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useKeyboardEventBus } from '#desktop/composables/useOrderedKeyboardEvents/useKeyboardEventBus.ts'
const mountComponent = (setup: () => object | void) =>
renderComponent({
template: `
<KeepAlive>
<div/>
</KeepAlive>`,
setup,
})
describe('useKeyboardEventBus', () => {
it('allows combined keyboard events to be subscribed', async () => {
const event = {
handler: vi.fn(),
key: 'test-4',
beforeHandlerRuns: vi.fn(),
}
const wrapper = mountComponent(() => {
useKeyboardEventBus([KeyboardKey.Control, 'd'], event)
})
await wrapper.events.keyboard('{Control>}{d}{/Control}')
expect(event.handler).toHaveBeenCalled()
})
it('should subscribe to a keyboard event and run always the last one added', async () => {
const firstEvent = {
handler: vi.fn(),
key: 'test',
beforeHandlerRuns: vi.fn(),
}
const secondEvent = {
handler: vi.fn(),
key: 'test-2',
beforeHandlerRuns: vi.fn(() => false),
}
const wrapper = mountComponent(() => {
const { subscribeEvent } = useKeyboardEventBus(
KeyboardKey.Escape,
firstEvent,
)
subscribeEvent(secondEvent)
})
await wrapper.events.keyboard('{Escape}')
expect(firstEvent.handler).not.toHaveBeenCalled()
expect(secondEvent.handler).toHaveBeenCalled()
})
it('prevents handler execution if beforeHandlerRuns returns true', async () => {
const event = {
handler: vi.fn(),
key: 'test',
beforeHandlerRuns: vi.fn(() => true),
}
const wrapper = mountComponent(() => {
useKeyboardEventBus(KeyboardKey.Escape, event)
})
await wrapper.events.keyboard('{Escape}')
expect(event.handler).not.toHaveBeenCalled()
wrapper.unmount()
})
it('prevents handler execution if async beforeHandlerRuns returns true', async () => {
const event = {
handler: vi.fn(),
key: 'test',
beforeHandlerRuns: vi.fn(async () => Promise.resolve(true)),
}
const wrapper = mountComponent(() => {
useKeyboardEventBus(KeyboardKey.Escape, event)
})
await wrapper.events.keyboard('{Escape}')
expect(event.handler).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@@ -0,0 +1,18 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
*/
export enum KeyboardKey {
Escape = 'escape',
Shift = 'shift',
Enter = 'enter',
Control = 'control',
Alt = 'alt',
}
export interface OrderKeyHandlerConfig {
handler: () => void
key: string
beforeHandlerRuns?: () => boolean | Promise<boolean> | void
}

View File

@@ -0,0 +1,122 @@
// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
import { useMagicKeys } from '@vueuse/core'
import { isArray } from 'lodash-es'
import {
effectScope,
onBeforeUnmount,
onDeactivated,
shallowRef,
watch,
} from 'vue'
import {
type OrderKeyHandlerConfig,
KeyboardKey,
} from '#desktop/composables/useOrderedKeyboardEvents/types.ts'
import { useReactivate } from '#desktop/composables/useReactivate.ts'
const subscribedHandlers = shallowRef<Record<string, OrderKeyHandlerConfig[]>>(
{},
)
const activeKeyboardKeys = shallowRef(new Set<string | KeyboardKey>(new Set()))
let isScopeActive = false
const scope = effectScope()
export const useKeyboardEventBus = (
keyName: KeyboardKey | string[],
config: OrderKeyHandlerConfig,
) => {
const keyboardKey = isArray(keyName) ? keyName.join(',') : keyName
const hasHandlerConfig = (handlerConfig = config) =>
subscribedHandlers.value[keyboardKey]?.some(
(c) => c.key === handlerConfig.key,
)
const subscribeEvent = (handlerConfig: OrderKeyHandlerConfig) => {
if (subscribedHandlers.value[keyboardKey] === undefined) {
subscribedHandlers.value[keyboardKey] = []
}
if (hasHandlerConfig(handlerConfig)) return
subscribedHandlers.value[keyboardKey].push(handlerConfig)
if (keyboardKey === ',' && import.meta.env.DEV)
return console.error('keyboardKey is a comma can not be used as a key')
activeKeyboardKeys.value.add(keyboardKey)
}
if (config) subscribeEvent(config)
if (!isScopeActive) {
isScopeActive = true
// gets activated with the first instance
// should not be needed to be clean up due to shortcuts later
scope.run(() => {
const handleKeyPress = async (handlers: OrderKeyHandlerConfig[]) => {
if (!handlers?.at(-1)) return
const { handler, beforeHandlerRuns } = handlers.at(
-1,
) as OrderKeyHandlerConfig
if (beforeHandlerRuns && (await beforeHandlerRuns())) return
handler()
}
const keyboardKeys = useMagicKeys()
watch(keyboardKeys.current, (event: Set<string>) => {
if (event.size === 0) return
const eventKeys = Array.from(event)
activeKeyboardKeys.value.forEach((keyOrKeys) => {
const keys = keyOrKeys.split(',')
const isKeyCombinationPressed =
keys.length > 2
? keys.every((key, index) => eventKeys[index] === key)
: event.has(keys[0])
if (isKeyCombinationPressed)
handleKeyPress(
subscribedHandlers.value[
keys.length > 1 ? eventKeys.join(',') : keys[0]
],
)
})
})
})
}
const unsubscribeEvent = (handlerConfig: OrderKeyHandlerConfig) => {
subscribedHandlers.value[keyboardKey] = subscribedHandlers.value[
keyboardKey
].filter((config) => config.key !== handlerConfig.key)
}
const cleanup = () => {
subscribedHandlers.value = {}
activeKeyboardKeys.value = new Set()
isScopeActive = false
}
onDeactivated(() => unsubscribeEvent(config))
useReactivate(() => !hasHandlerConfig(config) && subscribeEvent(config))
onBeforeUnmount(() => {
unsubscribeEvent(config)
})
return { subscribeEvent, unsubscribeEvent, cleanup }
}

View File

@@ -3137,11 +3137,11 @@ msgstr ""
msgid "Close current tab"
msgstr ""
#: app/frontend/apps/desktop/components/CommonDialog/CommonDialog.vue:149
#: app/frontend/apps/desktop/components/CommonDialog/CommonDialog.vue:163
msgid "Close dialog"
msgstr ""
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue:320
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue:343
msgid "Close side panel"
msgstr ""
@@ -12673,7 +12673,7 @@ msgstr ""
msgid "Reset Password failed!"
msgstr ""
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearchInput/QuickSearchInput.vue:63
#: app/frontend/apps/desktop/components/Search/QuickSearch/QuickSearchInput/QuickSearchInput.vue:70
msgid "Reset Search"
msgstr ""
@@ -12725,7 +12725,7 @@ msgstr ""
msgid "Resize frame"
msgstr ""
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue:352
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue:375
msgid "Resize side panel"
msgstr ""
@@ -14032,7 +14032,7 @@ msgstr ""
msgid "Shown when the chat is closed."
msgstr ""
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue:333
#: app/frontend/apps/desktop/components/CommonFlyout/CommonFlyout.vue:356
msgid "Side panel footer"
msgstr ""