refactor(frontend): centralize menu shortcuts (#2832)

This commit is contained in:
kergma-lw
2026-08-24 19:06:14 +00:00
committed by GitHub
parent d84b689025
commit a5d69ece80
12 changed files with 338 additions and 59 deletions
+2 -1
View File
@@ -64,7 +64,7 @@
</Modal>
<BaseButton
v-shortcut="'Shift+Slash'"
v-shortcut="SHORTCUTS.showKeyboardShortcuts"
class="keyboard-shortcuts-button d-print-none"
@click="showKeyboardShortcuts()"
>
@@ -80,6 +80,7 @@
import {watch, computed, onBeforeUnmount} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {SHORTCUTS} from '@/constants/shortcuts'
import Navigation from '@/components/home/Navigation.vue'
import QuickActions from '@/components/quick-actions/QuickActions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
+3 -1
View File
@@ -1,6 +1,6 @@
<template>
<BaseButton
v-shortcut="'Mod+KeyE'"
v-shortcut="SHORTCUTS.toggleMenu"
class="menu-show-button"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
@@ -12,6 +12,8 @@
<script setup lang="ts">
import {computed} from 'vue'
import {SHORTCUTS} from '@/constants/shortcuts'
import {useBaseStore} from '@/stores/base'
import BaseButton from '@/components/base/BaseButton.vue'
+6 -5
View File
@@ -21,7 +21,7 @@
<menu class="menu-list other-menu-items">
<li>
<RouterLink
v-shortcut="'KeyG KeyO'"
v-shortcut="SHORTCUTS.navigation.overview"
:to="{ name: 'home'}"
>
<span class="menu-item-icon icon">
@@ -32,7 +32,7 @@
</li>
<li>
<RouterLink
v-shortcut="'KeyG KeyU'"
v-shortcut="SHORTCUTS.navigation.upcoming"
:to="{ name: 'tasks.range'}"
>
<span class="menu-item-icon icon">
@@ -43,7 +43,7 @@
</li>
<li>
<RouterLink
v-shortcut="'KeyG KeyP'"
v-shortcut="SHORTCUTS.navigation.projects"
:to="{ name: 'projects.index'}"
>
<span class="menu-item-icon icon">
@@ -54,7 +54,7 @@
</li>
<li>
<RouterLink
v-shortcut="'KeyG KeyA'"
v-shortcut="SHORTCUTS.navigation.labels"
:to="{ name: 'labels.index'}"
>
<span class="menu-item-icon icon">
@@ -65,7 +65,7 @@
</li>
<li>
<RouterLink
v-shortcut="'KeyG KeyM'"
v-shortcut="SHORTCUTS.navigation.teams"
:to="{ name: 'teams.index'}"
>
<span class="menu-item-icon icon">
@@ -143,6 +143,7 @@
<script setup lang="ts">
import {computed} from 'vue'
import {SHORTCUTS} from '@/constants/shortcuts'
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import Loading from '@/components/misc/Loading.vue'
@@ -0,0 +1,40 @@
import {describe, it, expect} from 'vitest'
import {mount} from '@vue/test-utils'
import Shortcut from './Shortcut.vue'
describe('Shortcut.vue', () => {
it('renders a regular key combination with the default separator', () => {
const wrapper = mount(Shortcut, {
props: {
keys: ['ctrl', 'e'],
},
})
expect(wrapper.findAll('kbd').map(kbd => kbd.text())).toEqual(['ctrl', 'e'])
expect(wrapper.text()).toContain('ctrl+e')
})
it('renders a key sequence with a custom separator', () => {
const wrapper = mount(Shortcut, {
props: {
keys: ['g', 'o'],
combination: 'then',
},
})
expect(wrapper.findAll('kbd').map(kbd => kbd.text())).toEqual(['g', 'o'])
expect(wrapper.text()).toContain('gtheno')
})
it('renders special characters as visible shortcut keys', () => {
const wrapper = mount(Shortcut, {
props: {
keys: ['shift', '/'],
},
})
expect(wrapper.findAll('kbd').map(kbd => kbd.text())).toEqual(['shift', '/'])
expect(wrapper.text()).toContain('shift+/')
})
})
@@ -0,0 +1,50 @@
import {describe, it, expect} from 'vitest'
import {SHORTCUTS} from '@/constants/shortcuts'
import {shortcutBindingToDisplay} from '@/helpers/shortcut'
import {KEYBOARD_SHORTCUTS} from './shortcuts'
function getGroup(title: string) {
const group = KEYBOARD_SHORTCUTS.find(group => group.title === title)
expect(group).toBeDefined()
return group!
}
function getShortcut(title: string, groupTitle: string) {
const group = getGroup(groupTitle)
const shortcut = group.shortcuts.find(shortcut => shortcut.title === title)
expect(shortcut).toBeDefined()
return shortcut!
}
describe('keyboard shortcuts help data', () => {
it('uses the centralized toggle menu shortcut for the general help entry', () => {
const shortcut = getShortcut('keyboardShortcuts.toggleMenu', 'keyboardShortcuts.general')
expect(shortcut.keys).toEqual(shortcutBindingToDisplay(SHORTCUTS.toggleMenu).keys)
expect(shortcut.combination).toBeUndefined()
})
it('uses the centralized show keyboard shortcuts binding for the help dialog trigger', () => {
expect(shortcutBindingToDisplay(SHORTCUTS.showKeyboardShortcuts)).toEqual({
keys: ['shift', '/'],
})
})
it('uses the centralized overview navigation shortcut with sequence display', () => {
const shortcut = getShortcut('keyboardShortcuts.navigation.overview', 'keyboardShortcuts.navigation.title')
const expected = shortcutBindingToDisplay(SHORTCUTS.navigation.overview)
expect(shortcut.keys).toEqual(expected.keys)
expect(shortcut.combination).toEqual(expected.combination)
})
it('uses the centralized due date shortcut for the task detail help entry', () => {
const shortcut = getShortcut('keyboardShortcuts.task.dueDate', 'keyboardShortcuts.task.title')
const expected = shortcutBindingToDisplay(SHORTCUTS.taskDetail.dueDate)
expect(shortcut.keys).toEqual(expected.keys)
expect(shortcut.combination).toEqual(expected.combination)
})
})
@@ -1,10 +1,9 @@
import type {RouteLocation} from 'vue-router'
import {isAppleDevice} from '@/helpers/isAppleDevice'
import {PRIMARY_MODIFIER_KEY, SHORTCUTS} from '@/constants/shortcuts'
import {shortcutBindingToDisplay} from '@/helpers/shortcut'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
const reminderModifier = isAppleDevice() ? 'shift' : 'alt'
const deleteKey = isAppleDevice() ? 'backspace' : 'delete'
const ctrl = PRIMARY_MODIFIER_KEY
export interface Shortcut {
title: string
@@ -24,11 +23,11 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
shortcuts: [
{
title: 'keyboardShortcuts.toggleMenu',
keys: [ctrl, 'e'],
...shortcutBindingToDisplay(SHORTCUTS.toggleMenu),
},
{
title: 'keyboardShortcuts.quickSearch',
keys: [ctrl, 'k'],
...shortcutBindingToDisplay(SHORTCUTS.quickSearch),
},
],
},
@@ -37,28 +36,23 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
shortcuts: [
{
title: 'keyboardShortcuts.navigation.overview',
keys: ['g', 'o'],
combination: 'then',
...shortcutBindingToDisplay(SHORTCUTS.navigation.overview),
},
{
title: 'keyboardShortcuts.navigation.upcoming',
keys: ['g', 'u'],
combination: 'then',
...shortcutBindingToDisplay(SHORTCUTS.navigation.upcoming),
},
{
title: 'keyboardShortcuts.navigation.projects',
keys: ['g', 'p'],
combination: 'then',
...shortcutBindingToDisplay(SHORTCUTS.navigation.projects),
},
{
title: 'keyboardShortcuts.navigation.labels',
keys: ['g', 'a'],
combination: 'then',
...shortcutBindingToDisplay(SHORTCUTS.navigation.labels),
},
{
title: 'keyboardShortcuts.navigation.teams',
keys: ['g', 'm'],
combination: 'then',
...shortcutBindingToDisplay(SHORTCUTS.navigation.teams),
},
],
},
@@ -152,39 +146,39 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
shortcuts: [
{
title: 'keyboardShortcuts.task.done',
keys: ['t'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.done),
},
{
title: 'keyboardShortcuts.task.assign',
keys: ['a'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.assignees),
},
{
title: 'keyboardShortcuts.task.labels',
keys: ['l'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.labels),
},
{
title: 'keyboardShortcuts.task.dueDate',
keys: ['d'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.dueDate),
},
{
title: 'keyboardShortcuts.task.attachment',
keys: ['f'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.attachments),
},
{
title: 'keyboardShortcuts.task.related',
keys: ['r'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.relatedTasks),
},
{
title: 'keyboardShortcuts.task.move',
keys: ['m'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.moveProject),
},
{
title: 'keyboardShortcuts.task.color',
keys: ['c'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.color),
},
{
title: 'keyboardShortcuts.task.reminder',
keys: [reminderModifier, 'r'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.reminder),
},
{
title: 'keyboardShortcuts.task.description',
@@ -192,19 +186,19 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
},
{
title: 'keyboardShortcuts.task.priority',
keys: ['p'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.priority),
},
{
title: 'keyboardShortcuts.task.delete',
keys: [deleteKey],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.delete),
},
{
title: 'keyboardShortcuts.task.favorite',
keys: ['s'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.favorite),
},
{
title: 'keyboardShortcuts.task.openProject',
keys: ['u'],
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.openProject),
},
{
title: 'keyboardShortcuts.task.save',
+31
View File
@@ -0,0 +1,31 @@
import {isAppleDevice} from '@/helpers/isAppleDevice'
export const PRIMARY_MODIFIER_KEY = isAppleDevice() ? '⌘' : 'ctrl'
export const SHORTCUTS = {
toggleMenu: 'Mod+KeyE',
quickSearch: 'Mod+KeyK',
showKeyboardShortcuts: 'Shift+Slash',
navigation: {
overview: 'KeyG KeyO',
upcoming: 'KeyG KeyU',
projects: 'KeyG KeyP',
labels: 'KeyG KeyA',
teams: 'KeyG KeyM',
},
taskDetail: {
openProject: 'KeyU',
done: 'KeyT',
favorite: 'KeyS',
labels: 'KeyL',
priority: 'KeyP',
color: 'KeyC',
assignees: 'KeyA',
attachments: 'KeyF',
relatedTasks: 'KeyR',
moveProject: 'KeyM',
dueDate: 'KeyD',
reminder: isAppleDevice() ? 'Shift+KeyR' : 'Alt+KeyR',
delete: isAppleDevice() ? 'Backspace' : 'Delete',
},
} as const
+66 -1
View File
@@ -1,7 +1,7 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import * as appleDevice from '@/helpers/isAppleDevice'
import {parseKey, matchesKey, eventToShortcutString, isFormField, install, uninstall} from './shortcut'
import {parseKey, matchesKey, eventToShortcutString, isFormField, install, uninstall, shortcutBindingToDisplay} from './shortcut'
// Helper to create a partial KeyboardEvent with sensible defaults
function makeEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
@@ -290,6 +290,71 @@ describe('eventToShortcutString', () => {
})
})
describe('shortcutBindingToDisplay', () => {
it('should convert a simple key binding to display keys', () => {
expect(shortcutBindingToDisplay('KeyT')).toEqual({
keys: ['t'],
})
})
it('should convert Mod bindings to ctrl on non-Apple devices', () => {
const spy = vi.spyOn(appleDevice, 'isAppleDevice').mockReturnValue(false)
expect(shortcutBindingToDisplay('Mod+KeyE')).toEqual({
keys: ['ctrl', 'e'],
})
spy.mockRestore()
})
it('should convert Mod bindings to command on Apple devices', () => {
const spy = vi.spyOn(appleDevice, 'isAppleDevice').mockReturnValue(true)
expect(shortcutBindingToDisplay('Mod+KeyE')).toEqual({
keys: ['⌘', 'e'],
})
spy.mockRestore()
})
it('should convert Shift+Slash to display keys', () => {
expect(shortcutBindingToDisplay('Shift+Slash')).toEqual({
keys: ['shift', '/'],
})
})
it('should convert sequences to display keys with then combination', () => {
expect(shortcutBindingToDisplay('KeyG KeyO')).toEqual({
keys: ['g', 'o'],
combination: 'then',
})
})
it('should convert Alt bindings to display keys', () => {
expect(shortcutBindingToDisplay('Alt+KeyR')).toEqual({
keys: ['alt', 'r'],
})
})
it('should convert Backspace to a display key', () => {
expect(shortcutBindingToDisplay('Backspace')).toEqual({
keys: ['backspace'],
})
})
it('should convert Delete to a display key', () => {
expect(shortcutBindingToDisplay('Delete')).toEqual({
keys: ['delete'],
})
})
it('should convert Period bindings to display keys', () => {
expect(shortcutBindingToDisplay('Period')).toEqual({
keys: ['.'],
})
})
})
// --- isFormField ---
describe('isFormField', () => {
+76
View File
@@ -72,6 +72,82 @@ export function eventToShortcutString(event: KeyboardEvent): string {
return parts.join('+')
}
export interface ShortcutDisplay {
keys: string[]
combination?: 'then'
}
function modifierToDisplayKey(modifier: string): string {
switch (modifier) {
case 'Control':
return 'ctrl'
case 'Meta':
return '⌘'
case 'Alt':
return 'alt'
case 'Shift':
return 'shift'
case 'Mod':
return isAppleDevice() ? '⌘' : 'ctrl'
default:
return modifier.toLowerCase()
}
}
function codeToDisplayKey(code: string): string {
if (/^Key[A-Z]$/.test(code)) {
return code.slice(3).toLowerCase()
}
switch (code) {
case 'Slash':
return '/'
case 'Period':
return '.'
case 'Backspace':
return 'backspace'
case 'Delete':
return 'delete'
case 'Enter':
return 'enter'
case 'ArrowLeft':
return '←'
case 'ArrowRight':
return '→'
case 'ArrowUp':
return '↑'
case 'ArrowDown':
return '↓'
default:
return code.toLowerCase()
}
}
function bindingStepToDisplayKeys(step: string): string[] {
const parts = step.split('+')
const code = parts.pop() || ''
return [
...parts.map(modifierToDisplayKey),
codeToDisplayKey(code),
]
}
export function shortcutBindingToDisplay(binding: string): ShortcutDisplay {
const steps = binding.split(' ')
if (steps.length > 1) {
return {
keys: steps.flatMap(bindingStepToDisplayKeys),
combination: 'then',
}
}
return {
keys: bindingStepToDisplayKeys(binding),
}
}
// --- Form field detection ---
export function isFormField(target: EventTarget | null): boolean {
+15 -22
View File
@@ -39,14 +39,14 @@
>
<a
v-if="router.options.history.state?.back?.includes('/projects/'+p.id+'/') || false"
v-shortcut="p.id === project?.id ? 'KeyU' : ''"
v-shortcut="p.id === project?.id ? SHORTCUTS.taskDetail.openProject : ''"
@click="router.back()"
>
{{ getProjectTitle(p) }}
</a>
<RouterLink
v-else
v-shortcut="p.id === project?.id ? 'KeyU' : ''"
v-shortcut="p.id === project?.id ? SHORTCUTS.taskDetail.openProject : ''"
:to="{ name: 'project.index', params: { projectId: p.id } }"
>
{{ getProjectTitle(p) }}
@@ -445,7 +445,7 @@
>
<template v-if="canWrite">
<XButton
v-shortcut="'KeyT'"
v-shortcut="SHORTCUTS.taskDetail.done"
:class="{'is-pending': !task.done}"
class="button--mark-done"
icon="check-double"
@@ -461,7 +461,7 @@
@update:modelValue="sub => task.subscription = sub"
/>
<XButton
v-shortcut="'KeyS'"
v-shortcut="SHORTCUTS.taskDetail.favorite"
variant="secondary"
:icon="task.isFavorite ? 'star' : ['far', 'star']"
@click="toggleFavorite"
@@ -474,7 +474,7 @@
<span class="action-heading">{{ $t('task.detail.organization') }}</span>
<XButton
v-shortcut="'KeyL'"
v-shortcut="SHORTCUTS.taskDetail.labels"
variant="secondary"
icon="tags"
@click="setFieldActive('labels')"
@@ -482,7 +482,7 @@
{{ $t('task.detail.actions.label') }}
</XButton>
<XButton
v-shortcut="'KeyP'"
v-shortcut="SHORTCUTS.taskDetail.priority"
variant="secondary"
icon="exclamation-circle"
@click="setFieldActive('priority')"
@@ -497,7 +497,7 @@
{{ $t('task.detail.actions.percentDone') }}
</XButton>
<XButton
v-shortcut="'KeyC'"
v-shortcut="SHORTCUTS.taskDetail.color"
variant="secondary"
icon="fill-drip"
:icon-color="color"
@@ -509,7 +509,7 @@
<span class="action-heading">{{ $t('task.detail.management') }}</span>
<XButton
v-shortcut="'KeyA'"
v-shortcut="SHORTCUTS.taskDetail.assignees"
v-cy="'taskDetail.assign'"
variant="secondary"
icon="users"
@@ -518,7 +518,7 @@
{{ $t('task.detail.actions.assign') }}
</XButton>
<XButton
v-shortcut="'KeyF'"
v-shortcut="SHORTCUTS.taskDetail.attachments"
variant="secondary"
icon="paperclip"
@click="openAttachments()"
@@ -526,7 +526,7 @@
{{ $t('task.detail.actions.attachments') }}
</XButton>
<XButton
v-shortcut="'KeyR'"
v-shortcut="SHORTCUTS.taskDetail.relatedTasks"
variant="secondary"
icon="sitemap"
@click="setRelatedTasksActive()"
@@ -534,7 +534,7 @@
{{ $t('task.detail.actions.relatedTasks') }}
</XButton>
<XButton
v-shortcut="'KeyM'"
v-shortcut="SHORTCUTS.taskDetail.moveProject"
variant="secondary"
icon="list"
@click="setFieldActive('moveProject')"
@@ -562,7 +562,7 @@
</XButton>
<XButton
v-shortcut="'KeyD'"
v-shortcut="SHORTCUTS.taskDetail.dueDate"
variant="secondary"
icon="calendar"
@click="setFieldActive('dueDate')"
@@ -584,7 +584,7 @@
{{ $t('task.detail.actions.endDate') }}
</XButton>
<XButton
v-shortcut="reminderShortcut"
v-shortcut="SHORTCUTS.taskDetail.reminder"
variant="secondary"
:icon="['far', 'clock']"
@click="setFieldActive('reminders')"
@@ -599,7 +599,7 @@
{{ $t('task.detail.actions.repeatAfter') }}
</XButton>
<XButton
v-shortcut="deleteShortcut"
v-shortcut="SHORTCUTS.taskDetail.delete"
icon="trash-alt"
:shadow="false"
class="is-danger is-outlined has-no-border"
@@ -668,6 +668,7 @@ import type {IProject} from '@/modelTypes/IProject'
import {PRIORITIES, type Priority} from '@/constants/priorities'
import {PERMISSIONS} from '@/constants/permissions'
import {PRO_FEATURE} from '@/constants/proFeatures'
import {SHORTCUTS} from '@/constants/shortcuts'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -697,7 +698,6 @@ import Reactions from '@/components/input/Reactions.vue'
import {uploadFile} from '@/helpers/attachments'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {isAppleDevice} from '@/helpers/isAppleDevice'
import {scrollIntoView} from '@/helpers/scrollIntoView'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
@@ -773,13 +773,6 @@ const lastProject = computed(() => {
const lastProjectOrTaskProject = computed(() => lastProject.value ?? project.value)
// Use Shift+R on macOS (Alt+R produces special characters depending on keyboard layout)
// Use Alt+r on other platforms
const reminderShortcut = computed(() => isAppleDevice() ? 'Shift+KeyR' : 'Alt+KeyR')
// Match native OS conventions for "delete the selected item"
const deleteShortcut = isAppleDevice() ? 'Backspace' : 'Delete'
onBeforeRouteLeave(async () => {
if (taskNotFound.value) {
return
+11
View File
@@ -16,6 +16,17 @@ test.describe('The Menu', () => {
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
})
test('Can be toggled with keyboard shortcut on desktop', async ({authenticatedPage: page}) => {
await expect(page.locator('.menu-container')).toHaveClass(/is-active/)
await page.locator('body').click()
await page.locator('body').press('ControlOrMeta+e')
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
await page.locator('body').press('ControlOrMeta+e')
await expect(page.locator('.menu-container')).toHaveClass(/is-active/)
})
test('Is hidden by default on mobile', async ({authenticatedPage: page}) => {
await page.setViewportSize(iPhone8)
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
+15
View File
@@ -628,6 +628,21 @@ test.describe('Task', () => {
await expect(labelWrapper).not.toContainText(labels[0].title)
})
test('Can open due date with keyboard shortcut in task detail', async ({authenticatedPage: page}) => {
const tasks = await TaskFactory.create(1, {
id: 1,
done: false,
})
await page.goto(`/tasks/${tasks[0].id}`)
await page.waitForLoadState('networkidle')
const dueDateColumn = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'})
await expect(dueDateColumn).not.toBeVisible()
await page.locator('.task-view .action-buttons').click()
await page.locator('body').press('d')
await expect(dueDateColumn).toBeVisible()
})
test('Can set a due date for a task', async ({authenticatedPage: page}) => {
const tasks = await TaskFactory.create(1, {
id: 1,