feat(list): add j/k keyboard navigation (#1040)

Partially resolves https://community.vikunja.io/t/keyboard-shortcut-next-previous-tasks-in-a-project/1971/7?u=kolaente
This commit is contained in:
kolaente 2025-06-27 13:46:48 +02:00 committed by GitHub
parent 98cc78d9b5
commit 1f8150b167
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 99 additions and 3 deletions

View File

@ -60,6 +60,24 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
},
],
},
{
title: 'keyboardShortcuts.list.title',
available: (route) => route.name === 'project.view',
shortcuts: [
{
title: 'keyboardShortcuts.list.navigateDown',
keys: ['j'],
},
{
title: 'keyboardShortcuts.list.navigateUp',
keys: ['k'],
},
{
title: 'keyboardShortcuts.list.open',
keys: ['enter'],
},
],
},
{
title: 'project.kanban.title',
available: (route) => route.name === 'project.view',

View File

@ -65,8 +65,9 @@
@start="() => drag = true"
@end="saveTaskPosition"
>
<template #item="{element: t}">
<template #item="{element: t, index}">
<SingleTaskInProject
:ref="(el) => setTaskRef(el, index)"
:show-list-color="false"
:disabled="!canDragTasks"
:can-mark-as-done="canWrite || isPseudoProject"
@ -95,7 +96,7 @@
<script setup lang="ts">
import {ref, computed, nextTick, onMounted, watch} from 'vue'
import {ref, computed, nextTick, onMounted, onBeforeUnmount, watch} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import ProjectWrapper from '@/components/project/ProjectWrapper.vue'
@ -271,6 +272,70 @@ function prepareFiltersAndLoadTasks() {
loadTasks()
}
const taskRefs = ref<(InstanceType<typeof SingleTaskInProject> | null)[]>([])
const focusedIndex = ref(-1)
function setTaskRef(el: InstanceType<typeof SingleTaskInProject> | null, index: number) {
if (el === null) {
delete taskRefs.value[index]
} else {
taskRefs.value[index] = el
}
}
function focusTask(index: number) {
if (index < 0 || index >= tasks.value.length) {
return
}
const taskRef = taskRefs.value[index]
focusedIndex.value = index
taskRef?.focus()
}
function handleListNavigation(e: KeyboardEvent) {
if (e.target instanceof HTMLElement && (e.target.closest('input, textarea, select, [contenteditable="true"]'))) {
return
}
if (e.key === 'j') {
e.preventDefault()
focusTask(Math.min(focusedIndex.value + 1, tasks.value.length - 1))
return
}
if (e.key === 'k') {
e.preventDefault()
if (focusedIndex.value === -1) {
focusTask(tasks.value.length - 1)
return
}
if (focusedIndex.value === 0) {
addTaskRef.value?.focusTaskInput()
focusedIndex.value = -1
return
}
focusTask(Math.max(focusedIndex.value - 1, 0))
return
}
if (e.key === 'Enter') {
e.preventDefault()
taskRefs.value[focusedIndex.value]?.click(e)
}
}
onMounted(() => {
document.addEventListener('keydown', handleListNavigation)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', handleListNavigation)
})
</script>
<style lang="scss" scoped>

View File

@ -1,6 +1,7 @@
<template>
<div>
<div
ref="taskRoot"
:class="{'is-loading': taskService.loading}"
class="task loader-container single-task"
tabindex="-1"
@ -347,6 +348,7 @@ async function toggleFavorite() {
emit('taskUpdated', task.value)
}
const taskRoot = ref<HTMLElement | null>(null)
const taskLinkRef = ref<HTMLElement | null>(null)
function hasTextSelected() {
@ -364,6 +366,11 @@ function openTaskDetail(event: MouseEvent | KeyboardEvent) {
taskLinkRef.value?.$el.click()
}
defineExpose({
focus: () => taskRoot.value?.focus(),
click: (e: MouseEvent | KeyboardEvent) => openTaskDetail(e),
})
</script>
<style lang="scss" scoped>
@ -381,7 +388,7 @@ function openTaskDetail(event: MouseEvent | KeyboardEvent) {
background-color: var(--grey-100);
}
&:has(*:focus-visible) {
&:has(*:focus-visible), &:focus {
box-shadow: 0 0 0 2px hsla(var(--primary-hsl), 0.5);
a.task-link {

View File

@ -1076,6 +1076,12 @@
"labels": "Navigate to labels",
"teams": "Navigate to teams",
"projects": "Navigate to projects"
},
"list": {
"title": "Task List",
"navigateDown": "Highlight next task",
"navigateUp": "Highlight previous task",
"open": "Open highlighted task"
}
},
"update": {