Skip to content

Commit f567d4f

Browse files
authored
Merge pull request #590 from Chris0Jeky/feature/248-board-keyboard-moves
Add board keyboard card movement and move-to action menu
2 parents 87f2d68 + 2ba3f24 commit f567d4f

File tree

9 files changed

+802
-24
lines changed

9 files changed

+802
-24
lines changed

frontend/taskdeck-web/src/components/KeyboardShortcutsHelp.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ const categories: ShortcutCategory[] = [
3737
{ keys: ['l', 'ArrowRight'], description: 'Move to next column' },
3838
]
3939
},
40+
{
41+
title: 'Card Movement',
42+
shortcuts: [
43+
{ keys: ['Alt + ArrowRight'], description: 'Move card to next column' },
44+
{ keys: ['Alt + ArrowLeft'], description: 'Move card to previous column' },
45+
{ keys: ['Alt + ArrowUp'], description: 'Move card up in column' },
46+
{ keys: ['Alt + ArrowDown'], description: 'Move card down in column' },
47+
]
48+
},
4049
{
4150
title: 'Actions',
4251
shortcuts: [

frontend/taskdeck-web/src/components/board/BoardCanvas.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ defineEmits<{
4949
:cards="cardsByColumn.get(column.id) || []"
5050
:labels="labels"
5151
:board-id="boardId"
52+
:all-columns="sortedColumns"
5253
:dragged-card="draggedCard"
5354
:selected-card-id="selectedCardId"
5455
@card-drag-start="$emit('cardDragStart', $event)"

frontend/taskdeck-web/src/components/board/CardItem.vue

Lines changed: 178 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,43 @@
11
<script setup lang="ts">
22
import { ref } from 'vue'
3-
import type { Card } from '../../types/board'
3+
import type { Card, Column } from '../../types/board'
44
55
const props = defineProps<{
66
card: Card
77
isSelected?: boolean
8+
columns?: Column[]
89
}>()
910
1011
const emit = defineEmits<{
1112
(e: 'click', card: Card): void
1213
(e: 'dragstart', card: Card): void
1314
(e: 'dragend'): void
15+
(e: 'move-to', card: Card, targetColumnId: string): void
1416
}>()
1517
1618
const isDragging = ref(false)
19+
const showMoveMenu = ref(false)
20+
21+
function toggleMoveMenu(event: Event) {
22+
event.stopPropagation()
23+
showMoveMenu.value = !showMoveMenu.value
24+
}
25+
26+
function closeMoveMenu() {
27+
showMoveMenu.value = false
28+
}
29+
30+
function handleMoveMenuKeydown(event: KeyboardEvent) {
31+
if (event.key === 'Escape') {
32+
event.stopPropagation()
33+
closeMoveMenu()
34+
}
35+
}
36+
37+
function handleMoveTo(targetColumnId: string) {
38+
showMoveMenu.value = false
39+
emit('move-to', props.card, targetColumnId)
40+
}
1741
1842
function isDragHandleTarget(target: EventTarget | null): boolean {
1943
return target instanceof Element && target.closest('[data-action="drag-card-handle"]') !== null
@@ -79,21 +103,76 @@ function isOverdue(dateString: string | null): boolean {
79103
<!-- Ember leading-edge indicator -->
80104
<span class="td-board-card__indicator" aria-hidden="true" />
81105

82-
<button
83-
type="button"
84-
data-action="drag-card-handle"
85-
draggable="true"
86-
class="td-card-drag-handle -mx-2 -mt-1 mb-2 flex min-h-10 w-[calc(100%+1rem)] items-center justify-center gap-2 rounded-md px-3 py-2 text-on-surface/60 hover:bg-surface-bright hover:text-on-surface/70 cursor-grab active:cursor-grabbing"
87-
title="Drag Card"
88-
aria-label="Drag Card"
106+
<!-- Card action bar: drag handle + move menu trigger -->
107+
<div class="td-board-card__action-bar">
108+
<button
109+
type="button"
110+
data-action="drag-card-handle"
111+
draggable="true"
112+
class="td-card-drag-handle flex min-h-10 flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-on-surface/60 hover:bg-surface-bright hover:text-on-surface/70 cursor-grab active:cursor-grabbing"
113+
title="Drag Card"
114+
aria-label="Drag Card"
115+
@click.stop
116+
@mousedown="handleDragHandleMouseDown"
117+
>
118+
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
119+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01" />
120+
</svg>
121+
<span class="td-board-card__drag-label td-board-card__drag-label--hidden">Drag card</span>
122+
</button>
123+
124+
<button
125+
v-if="columns && columns.length > 1"
126+
type="button"
127+
data-action="card-move-menu-trigger"
128+
class="td-card-move-btn flex items-center justify-center rounded-md px-2 py-2 text-on-surface/60 hover:bg-surface-bright hover:text-on-surface/70"
129+
title="Move to column..."
130+
aria-label="Move to column"
131+
aria-haspopup="true"
132+
:aria-expanded="showMoveMenu"
133+
@click="toggleMoveMenu"
134+
>
135+
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
136+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
137+
</svg>
138+
</button>
139+
</div>
140+
141+
<!-- Move-to column menu -->
142+
<div
143+
v-if="showMoveMenu && columns"
144+
class="td-card-move-menu"
145+
role="menu"
146+
aria-label="Move card to column"
89147
@click.stop
90-
@mousedown="handleDragHandleMouseDown"
148+
@keydown="handleMoveMenuKeydown"
91149
>
92-
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
93-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01" />
94-
</svg>
95-
<span class="td-board-card__drag-label td-board-card__drag-label--hidden">Drag card</span>
96-
</button>
150+
<div class="td-card-move-menu__header">Move to...</div>
151+
<button
152+
v-for="col in columns"
153+
:key="col.id"
154+
role="menuitem"
155+
:disabled="col.id === card.columnId"
156+
:class="[
157+
'td-card-move-menu__item',
158+
col.id === card.columnId ? 'td-card-move-menu__item--current' : ''
159+
]"
160+
@click="handleMoveTo(col.id)"
161+
>
162+
<span class="td-card-move-menu__dot" aria-hidden="true" />
163+
{{ col.name }}
164+
<span v-if="col.id === card.columnId" class="td-card-move-menu__badge">(current)</span>
165+
</button>
166+
</div>
167+
168+
<!-- Click-away overlay to close move menu -->
169+
<Teleport to="body">
170+
<div
171+
v-if="showMoveMenu"
172+
class="fixed inset-0 z-40"
173+
@click="closeMoveMenu"
174+
/>
175+
</Teleport>
97176

98177
<!-- Blocked Badge -->
99178
<div v-if="card.isBlocked" class="td-board-card__badge-row">
@@ -205,6 +284,91 @@ function isOverdue(dateString: string | null): boolean {
205284
opacity: 1;
206285
}
207286
287+
/* ── Action bar (drag handle + move button) ── */
288+
.td-board-card__action-bar {
289+
display: flex;
290+
align-items: center;
291+
gap: var(--td-space-1);
292+
margin: -0.5rem -0.5rem var(--td-space-2) -0.5rem;
293+
}
294+
295+
/* ── Move button ── */
296+
.td-card-move-btn {
297+
min-width: 2.5rem;
298+
min-height: 2.5rem;
299+
flex-shrink: 0;
300+
-webkit-user-select: none;
301+
user-select: none;
302+
}
303+
304+
.td-card-move-btn:focus-visible {
305+
outline: none;
306+
box-shadow: var(--td-focus-ring);
307+
}
308+
309+
/* ── Move-to menu ── */
310+
.td-card-move-menu {
311+
position: relative;
312+
z-index: 50;
313+
margin-bottom: var(--td-space-2);
314+
background: var(--td-surface-container);
315+
border: 1px solid var(--td-border-default);
316+
border-radius: var(--td-radius-lg);
317+
box-shadow: var(--td-shadow-lg);
318+
padding: var(--td-space-2) 0;
319+
max-height: 240px;
320+
overflow-y: auto;
321+
}
322+
323+
.td-card-move-menu__header {
324+
padding: var(--td-space-2) var(--td-space-4);
325+
font-size: var(--td-font-xs);
326+
font-weight: 600;
327+
text-transform: uppercase;
328+
letter-spacing: 0.15em;
329+
color: var(--td-text-tertiary);
330+
}
331+
332+
.td-card-move-menu__item {
333+
display: flex;
334+
align-items: center;
335+
gap: var(--td-space-2);
336+
width: 100%;
337+
padding: var(--td-space-2) var(--td-space-4);
338+
font-size: var(--td-font-sm);
339+
color: var(--td-text-primary);
340+
text-align: left;
341+
transition: background-color var(--td-transition-fast);
342+
}
343+
344+
.td-card-move-menu__item:hover:not(:disabled) {
345+
background: var(--td-surface-bright);
346+
}
347+
348+
.td-card-move-menu__item:focus-visible {
349+
outline: none;
350+
box-shadow: var(--td-focus-ring);
351+
}
352+
353+
.td-card-move-menu__item--current {
354+
color: var(--td-text-tertiary);
355+
cursor: default;
356+
}
357+
358+
.td-card-move-menu__dot {
359+
width: 6px;
360+
height: 6px;
361+
border-radius: 9999px;
362+
background: var(--td-color-ember-glow);
363+
flex-shrink: 0;
364+
}
365+
366+
.td-card-move-menu__badge {
367+
font-size: var(--td-font-xs);
368+
color: var(--td-text-tertiary);
369+
margin-left: auto;
370+
}
371+
208372
/* ── Drag handle — prevent text-selection interference with drag ── */
209373
.td-card-drag-handle {
210374
-webkit-user-select: none;

frontend/taskdeck-web/src/components/board/ColumnLane.vue

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { ref, computed } from 'vue'
2+
import { ref, computed, nextTick } from 'vue'
33
import { useBoardStore } from '../../store/boardStore'
44
import { useToastStore } from '../../store/toastStore'
55
import { getErrorDisplay } from '../../composables/useErrorMapper'
@@ -13,6 +13,7 @@ const props = defineProps<{
1313
cards: Card[]
1414
labels: Label[]
1515
boardId: string
16+
allColumns: Column[]
1617
draggedCard: Card | null
1718
selectedCardId?: string | null
1819
}>()
@@ -154,6 +155,28 @@ async function handleCardDrop(targetCard: Card, event: DragEvent) {
154155
}
155156
}
156157
158+
async function handleCardMoveTo(card: Card, targetColumnId: string) {
159+
if (card.columnId === targetColumnId) return
160+
try {
161+
// Move to end of target column
162+
const targetCards = boardStore.cardsByColumn.get(targetColumnId) || []
163+
const targetPosition = targetCards.length
164+
await boardStore.moveCard(props.boardId, card.id, targetColumnId, targetPosition)
165+
166+
// Restore focus on the moved card in its new column
167+
await nextTick()
168+
const el = document.querySelector(
169+
`[data-card-id="${card.id}"]`,
170+
) as HTMLElement | null
171+
if (el) {
172+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
173+
el.focus()
174+
}
175+
} catch (error) {
176+
console.error('Failed to move card:', error)
177+
}
178+
}
179+
157180
function handleCardDragOver(event: DragEvent) {
158181
event.preventDefault()
159182
event.stopPropagation()
@@ -271,9 +294,11 @@ function handleCardDragOver(event: DragEvent) {
271294
<CardItem
272295
:card="card"
273296
:is-selected="card.id === selectedCardId"
297+
:columns="allColumns"
274298
@click="handleCardClick"
275299
@dragstart="handleCardDragStart"
276300
@dragend="handleCardDragEnd"
301+
@move-to="handleCardMoveTo"
277302
/>
278303
</div>
279304

0 commit comments

Comments
 (0)