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'