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:
parent
98cc78d9b5
commit
1f8150b167
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue