Maintenance: Desktop view - Escape key closes both quick search and flyout/dialog
This commit is contained in:
@@ -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) ||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user