From d0adb1ad71980239855f233e101e4f722c52171a Mon Sep 17 00:00:00 2001 From: Jay Porta <15250836+jayporta@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:18:54 -0700 Subject: [PATCH 1/2] [task-board] add column reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Columns can be dragged by a grip in their header, or moved with the arrow keys while the grip has focus. Order is layout only — tasks join their column on status, so search and every task listing read the same either way. Co-Authored-By: Claude Opus 5 --- src/components/BoardColumn.tsx | 168 +++++++++++++++++++++------------ src/hooks/useColumnReorder.ts | 65 +++++++++++++ src/lib/board.test.ts | 39 +++++++- src/lib/board.ts | 32 +++++++ src/lib/dnd.ts | 8 ++ 5 files changed, 252 insertions(+), 60 deletions(-) create mode 100644 src/hooks/useColumnReorder.ts diff --git a/src/components/BoardColumn.tsx b/src/components/BoardColumn.tsx index 328ee9b..e6cc68d 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,24 @@ 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) + // 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 index = board.columns.findIndex((candidate) => candidate.id === column.id) + const target = board.columns[index + 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 +54,109 @@ 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..eb5d331 --- /dev/null +++ b/src/hooks/useColumnReorder.ts @@ -0,0 +1,65 @@ +import { 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. + */ +export function useColumnReorder(column: Column) { + const { dispatch } = useBoardContext() + const [armed, setArmed] = useState(false) + const [dragging, setDragging] = useState(false) + const [isOver, setIsOver] = useState(false) + + const columnProps = { + // Armed by the grip rather than always on, so a drag cannot start from a + // card, a button, or a selection inside the column. + draggable: armed, + onDragStart: (event: React.DragEvent) => { + // A card starting its own drag inside this column bubbles through here. + // Without the guard it would pick up a column payload too, and dropping + // that card would move its task and reorder the board in one go. + if (!armed) return + event.stopPropagation() + event.dataTransfer.setData(COLUMN_DRAG_MIME, column.id) + event.dataTransfer.effectAllowed = 'move' + setDragging(true) + }, + onDragEnd: () => { + setDragging(false) + setArmed(false) + }, + 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: () => 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 = { + onMouseDown: () => setArmed(true), + onMouseUp: () => setArmed(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' From dc1cc49313dd03cbf84215ce7bcbfb4c6ec9fc5e Mon Sep 17 00:00:00 2001 From: Jay Porta <15250836+jayporta@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:02:23 -0700 Subject: [PATCH 2/2] [task-board] drag columns from the grip itself The column root was armed as draggable by its grip, but a press that released off the grip left it armed. An armed root caught a card's own dragstart as it bubbled and stapled a column payload onto it, so dropping that card moved the task and reordered the board at once. Making the grip the drag source removes the latch, the bubbling guard it needed, and the stale state behind both. setDragImage keeps the column, not the grip icon, under the cursor. Also ignore dragleave between a column's own children, and put the column's position in the grip's label so an arrow-key move is announced. Co-Authored-By: Claude Opus 5 --- src/components/BoardColumn.tsx | 9 ++++-- src/hooks/useColumnReorder.ts | 56 +++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/components/BoardColumn.tsx b/src/components/BoardColumn.tsx index e6cc68d..401411e 100644 --- a/src/components/BoardColumn.tsx +++ b/src/components/BoardColumn.tsx @@ -26,11 +26,12 @@ export function BoardColumn({ column }: { column: 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 index = board.columns.findIndex((candidate) => candidate.id === column.id) - const target = board.columns[index + offset] + const target = board.columns[position + offset] if (target) dispatch({ type: 'move_column', id: column.id, targetId: target.id }) } @@ -92,7 +93,9 @@ export function BoardColumn({ column }: { column: Column }) { (null) const [dragging, setDragging] = useState(false) const [isOver, setIsOver] = useState(false) const columnProps = { - // Armed by the grip rather than always on, so a drag cannot start from a - // card, a button, or a selection inside the column. - draggable: armed, - onDragStart: (event: React.DragEvent) => { - // A card starting its own drag inside this column bubbles through here. - // Without the guard it would pick up a column payload too, and dropping - // that card would move its task and reorder the board in one go. - if (!armed) return - event.stopPropagation() - event.dataTransfer.setData(COLUMN_DRAG_MIME, column.id) - event.dataTransfer.effectAllowed = 'move' - setDragging(true) - }, - onDragEnd: () => { - setDragging(false) - setArmed(false) - }, + 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. @@ -43,7 +34,12 @@ export function useColumnReorder(column: Column) { event.dataTransfer.dropEffect = 'move' setIsOver(true) }, - onDragLeave: () => setIsOver(false), + 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) @@ -55,8 +51,24 @@ export function useColumnReorder(column: Column) { } const handleProps = { - onMouseDown: () => setArmed(true), - onMouseUp: () => setArmed(false), + 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