diff --git a/src/components/BoardColumn.tsx b/src/components/BoardColumn.tsx index 328ee9b..401411e 100644 --- a/src/components/BoardColumn.tsx +++ b/src/components/BoardColumn.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import AddIcon from '@mui/icons-material/Add' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined' +import DragIndicatorIcon from '@mui/icons-material/DragIndicator' +import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Chip from '@mui/material/Chip' import IconButton from '@mui/material/IconButton' @@ -9,6 +11,7 @@ import Stack from '@mui/material/Stack' import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' import { useBoardContext } from '../context/boardContext' +import { useColumnReorder } from '../hooks/useColumnReorder' import { useTaskDropTarget } from '../hooks/useTaskDropTarget' import { CREATE_STATUS, FALLBACK_STATUS, isCoreColumn, visibleTasks } from '../lib/board' import { ConfirmDialog } from './ConfirmDialog' @@ -19,9 +22,25 @@ import type { Column } from '../types' export function BoardColumn({ column }: { column: Column }) { const { board, createTask, dispatch } = useBoardContext() const { isOver, dropProps } = useTaskDropTarget(column.status) + const { dragging, isOver: reordering, columnProps, handleProps } = useColumnReorder(column) const [confirmingDelete, setConfirmingDelete] = useState(false) const tasks = visibleTasks(board.tasks, column.status) + const position = board.columns.findIndex((candidate) => candidate.id === column.id) + + // Keyboard reordering, since a drag reaches neither a keyboard nor a touch + // screen. Swapping with a neighbour is the same move as dropping onto it. + const shiftBy = (offset: number) => { + const target = board.columns[position + offset] + if (target) dispatch({ type: 'move_column', id: column.id, targetId: target.id }) + } + + const handleGripKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return + event.preventDefault() + shiftBy(event.key === 'ArrowLeft' ? -1 : 1) + } + // Named rather than hardcoded, so the confirmation cannot promise the wrong // destination if FALLBACK_STATUS ever changes. const fallbackLabel = @@ -36,77 +55,111 @@ export function BoardColumn({ column }: { column: Column }) { ) return ( - - - - - {column.label} - - - + + + {/* The only place a column drag can start, and the keyboard route + to the same move. */} + + + + + + + {column.label} + + + - {addButton} + {addButton} - {/* Only user-added columns can be deleted; the three core statuses are fixed. */} - {!isCoreColumn(column) && ( - - setConfirmingDelete(true)} - sx={{ '&:hover': { color: 'error.main' } }} - > - - - + {/* Only user-added columns can be deleted; the three core statuses are fixed. */} + {!isCoreColumn(column) && ( + + setConfirmingDelete(true)} + sx={{ '&:hover': { color: 'error.main' } }} + > + + + + )} + + + {tasks.length === 0 ? ( + + ) : ( + + {tasks.map((task) => ( + + ))} + )} - - {tasks.length === 0 ? ( - dispatch({ type: 'delete_column', id: column.id })} + onClose={() => setConfirmingDelete(false)} /> - ) : ( - - {tasks.map((task) => ( - - ))} - - )} - - dispatch({ type: 'delete_column', id: column.id })} - onClose={() => setConfirmingDelete(false)} - /> - + + ) } diff --git a/src/hooks/useColumnReorder.ts b/src/hooks/useColumnReorder.ts new file mode 100644 index 0000000..66f2337 --- /dev/null +++ b/src/hooks/useColumnReorder.ts @@ -0,0 +1,77 @@ +import { useRef, useState } from 'react' +import { useBoardContext } from '../context/boardContext' +import { COLUMN_DRAG_MIME } from '../lib/dnd' +import type { Column } from '../types' + +/** + * Reordering by drag makes every column both a source and a target, so one hook + * owns both halves rather than splitting them the way the task hooks do. + * + * Spread `columnProps` on the column root and `handleProps` on its grip. The + * root sits outside the task drop area: a card dragged over a column is ignored + * here and handled there, and neither payload can trigger the other's move. + * + * The grip is the draggable element, not the root. `useTaskDrag` instead arms a + * draggable root from its handle, which is fine for a card but not here: the + * root is an ancestor of every card, so an armed root would catch a card's own + * dragstart as it bubbles and staple a column payload onto it. Dragging from + * the grip means that event never reaches this hook and there is no latch to + * leave set. + */ +export function useColumnReorder(column: Column) { + const { dispatch } = useBoardContext() + const rootRef = useRef(null) + const [dragging, setDragging] = useState(false) + const [isOver, setIsOver] = useState(false) + + const columnProps = { + ref: rootRef, + onDragOver: (event: React.DragEvent) => { + // Only `types` is readable mid-drag. Anything else — a card, a file — is + // left alone to bubble down to whichever target does want it. + if (!event.dataTransfer.types.includes(COLUMN_DRAG_MIME)) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setIsOver(true) + }, + onDragLeave: (event: React.DragEvent) => { + // dragleave bubbles, so crossing between a column's own children fires it + // too. Leaving for somewhere still inside this column is not leaving. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return + setIsOver(false) + }, + onDrop: (event: React.DragEvent) => { + // Empty for a card drop, which the task target has already handled. + const id = event.dataTransfer.getData(COLUMN_DRAG_MIME) + if (!id) return + event.preventDefault() + setIsOver(false) + dispatch({ type: 'move_column', id, targetId: column.id }) + }, + } + + const handleProps = { + draggable: true, + onDragStart: (event: React.DragEvent) => { + event.dataTransfer.setData(COLUMN_DRAG_MIME, column.id) + event.dataTransfer.effectAllowed = 'move' + // Drag the column, not the grip icon that started it, held at the point + // it was grabbed so the ghost stays under the cursor. + const root = rootRef.current + if (root) { + const bounds = root.getBoundingClientRect() + event.dataTransfer.setDragImage( + root, + event.clientX - bounds.left, + event.clientY - bounds.top, + ) + } + setDragging(true) + }, + onDragEnd: () => setDragging(false), + } + + // The dragged column sits under the cursor the whole way, and dropping it on + // itself does nothing — highlighting it would promise a move that never lands. + return { dragging, isOver: isOver && !dragging, columnProps, handleProps } +} diff --git a/src/lib/board.test.ts b/src/lib/board.test.ts index cf64919..e952565 100644 --- a/src/lib/board.test.ts +++ b/src/lib/board.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import type { BoardState, Task } from '../types' -import { boardReducer, createEmptyBoard, displayTitle, searchTasks, UNTITLED_LABEL } from './board' +import { + boardReducer, + createEmptyBoard, + displayTitle, + searchTasks, + UNTITLED_LABEL, + visibleTasks, +} from './board' function boardWith(tasks: Task[]): BoardState { return { ...createEmptyBoard(), tasks } @@ -57,6 +64,36 @@ describe('boardReducer', () => { expect(boardReducer(next, { type: 'delete_column', id: next.columns[0].id })).toBe(next) }) + + it('reorders any column, core ones included, and ignores a move that lands nowhere', () => { + const state = createEmptyBoard() + const [todo, inProgress, done] = state.columns + + // Dragged onto Done, Todo takes its place and the others close up. + const next = boardReducer(state, { type: 'move_column', id: todo.id, targetId: done.id }) + expect(next.columns.map((column) => column.status)).toEqual(['in_progress', 'done', 'todo']) + + // Same object back, so a drop on itself or on nothing costs no re-render. + expect(boardReducer(next, { type: 'move_column', id: todo.id, targetId: todo.id })).toBe(next) + expect(boardReducer(next, { type: 'move_column', id: todo.id, targetId: 'ghost' })).toBe(next) + expect(boardReducer(next, { type: 'move_column', id: 'ghost', targetId: inProgress.id })).toBe( + next, + ) + }) + + it('leaves every task untouched when columns move, so search and lists are unaffected', () => { + // Order is layout. Anything reading tasks joins on `status`, which a move + // never rewrites — a reordered board answers exactly as it did before. + const tasks = [task({ id: 'a' }), task({ id: 'b', title: 'Ship it', status: 'done' })] + const state = boardWith(tasks) + const [todo, , done] = state.columns + + const next = boardReducer(state, { type: 'move_column', id: done.id, targetId: todo.id }) + + expect(next.tasks).toBe(state.tasks) + expect(searchTasks(next.tasks, 'ship').map((t) => t.id)).toEqual(['b']) + expect(visibleTasks(next.tasks, 'todo').map((t) => t.id)).toEqual(['a']) + }) }) describe('searchTasks', () => { diff --git a/src/lib/board.ts b/src/lib/board.ts index 00f6229..a8581d4 100644 --- a/src/lib/board.ts +++ b/src/lib/board.ts @@ -128,6 +128,27 @@ export function createColumn(label: string): Column { } } +/** + * Reorders columns by dropping one onto another: the dragged column takes the + * target's place and the rest close up around it. + * + * Column order is presentation only. Tasks join their column on `status`, never + * on position, so nothing outside the board's layout reads this order. + * + * Returns the original array for a move that changes nothing, which is what + * lets the reducer hand back the same state and skip the re-render. + */ +export function moveColumn(columns: Column[], id: string, targetId: string): Column[] { + const from = columns.findIndex((column) => column.id === id) + const to = columns.findIndex((column) => column.id === targetId) + if (from === -1 || to === -1 || from === to) return columns + + const next = [...columns] + const [moved] = next.splice(from, 1) + next.splice(to, 0, moved) + return next +} + const newestFirst = (a: Task, b: Task) => b.created_at.localeCompare(a.created_at) /** Tasks in one column, newest first. */ @@ -167,6 +188,8 @@ export type BoardAction = | { type: 'delete_task'; id: string } | { type: 'add_column'; label: string } | { type: 'delete_column'; id: string } + /** Reorder: `id` takes `targetId`'s place. Layout only — no task is touched. */ + | { type: 'move_column'; id: string; targetId: string } const hasStatus = (state: BoardState, status: Status) => state.columns.some((column) => column.status === status) @@ -243,5 +266,14 @@ export function boardReducer(state: BoardState, action: BoardAction): BoardState ), } } + + /** + * Every column moves, core ones included: deletion is restricted because it + * would strand tasks, but position carries no meaning to strand. + */ + case 'move_column': { + const columns = moveColumn(state.columns, action.id, action.targetId) + return columns === state.columns ? state : { ...state, columns } + } } } diff --git a/src/lib/dnd.ts b/src/lib/dnd.ts index 54eeb48..075f189 100644 --- a/src/lib/dnd.ts +++ b/src/lib/dnd.ts @@ -5,3 +5,11 @@ * simply never dropping. */ export const DRAG_MIME = 'application/x-task-board-task' + +/** + * The payload type for a column being reordered. Distinct from `DRAG_MIME` so a + * column and a card can share a drop area: each side ignores the other's type, + * and a card dragged over a column still lands in the column's task drop target + * rather than reordering the board. + */ +export const COLUMN_DRAG_MIME = 'application/x-task-board-column'