Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save colmtuite/032f0bc7977bf97636e292c2b0069f0f to your computer and use it in GitHub Desktop.

Select an option

Save colmtuite/032f0bc7977bf97636e292c2b0069f0f to your computer and use it in GitHub Desktop.
'use client';
import * as React from 'react';
import clsx from 'clsx';
import {
GripVertical,
Lock,
LockOpen,
MoreHorizontal,
Pin,
PinOff,
Plus,
RotateCcw,
Shuffle,
Trash2,
X,
} from 'lucide-react';
import { Menu } from '@base-ui/react/menu';
import { Popover } from '@base-ui/react/popover';
import { Toolbar } from '@base-ui/react/toolbar';
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import { useStableCallback } from '@base-ui/utils/useStableCallback';
import { useTimeout } from '@base-ui/utils/useTimeout';
import { useValueAsRef } from '@base-ui/utils/useValueAsRef';
import {
monitorForElements,
useDraggable,
useDragSource,
useDropTarget,
useIsDragging,
useRegisterAutoScroller,
} from '@base-ui/plus/drag-engine';
import type { DropTargetRecord, ElementDragPayload } from '@base-ui/plus/drag-engine';
import {
boardReducer,
computeCardEdge,
computeColumnEdge,
createSeedBoard,
isCardDrag,
isCardDropTarget,
isColumnDrag,
isColumnDropTarget,
KANBAN_CARD_KIND,
KANBAN_CARD_TARGET_KIND,
KANBAN_COLUMN_KIND,
KANBAN_COLUMN_TARGET_KIND,
KANBAN_DRAG_KINDS,
resolveCardInsertion,
resolveColumnInsertion,
} from './kanbanLogic';
import type {
Board,
BoardAction,
Card,
CardDragData,
CardDropData,
CardId,
Column,
ColumnDragData,
ColumnDropData,
ColumnId,
DropIndicatorState,
} from './kanbanLogic';
import { Switch } from '../_components/Switch';
import { SettingsMetadata, useExperimentSettings } from '../_components/SettingsPanel';
import styles from './kanban.module.css';
interface ActivityEvent {
id: number;
at: number;
phase: 'start' | 'enter' | 'leave' | 'drop' | 'cancel';
sourceKind: 'card' | 'column';
sourceLabel: string;
targetLabel: string;
selectionSize: number;
}
interface KanbanSettings {
showActivityLog: boolean;
handleDragForAllCards: boolean;
handleDragForAllColumns: boolean;
}
// -----------------------------------------------------------------------------
// Context
// -----------------------------------------------------------------------------
// Split into two contexts so that consumers reading only board/selection data
// don't re-render on drag-UI transients (hover, hints, active ids), and vice
// versa. Drag UI state changes on every mousemove during a drag; keeping it
// out of the data context matters for boards with many cards.
interface KanbanDataContextValue {
board: Board;
boardRef: React.RefObject<Board>;
dispatch: React.Dispatch<BoardAction>;
selection: Set<CardId>;
selectionRef: React.RefObject<Set<CardId>>;
toggleSelection: (cardId: CardId, additive: boolean) => void;
toggleSelectionRange: (cardId: CardId, columnId: ColumnId) => void;
selectOnly: (cardId: CardId) => void;
clearSelection: () => void;
deleteSelection: () => void;
settings: KanbanSettings;
}
interface KanbanDragUIContextValue {
dropIndicator: DropIndicatorState;
setDropIndicator: (next: DropIndicatorState) => void;
dropIndicatorRef: React.RefObject<DropIndicatorState>;
columnHover: Record<ColumnId, boolean>;
setColumnHover: React.Dispatch<React.SetStateAction<Record<ColumnId, boolean>>>;
}
const KanbanDataContext = React.createContext<KanbanDataContextValue | null>(null);
const KanbanDragUIContext = React.createContext<KanbanDragUIContextValue | null>(null);
function useKanbanData(): KanbanDataContextValue {
const ctx = React.useContext(KanbanDataContext);
if (!ctx) {
throw new Error('Kanban experiment components must be rendered inside KanbanExperiment.');
}
return ctx;
}
function useKanbanDragUI(): KanbanDragUIContextValue {
const ctx = React.useContext(KanbanDragUIContext);
if (!ctx) {
throw new Error('Kanban experiment components must be rendered inside KanbanExperiment.');
}
return ctx;
}
// -----------------------------------------------------------------------------
// Settings metadata
// -----------------------------------------------------------------------------
export const settingsMetadata: SettingsMetadata<KanbanSettings> = {
showActivityLog: {
type: 'boolean',
label: 'Show activity log',
default: true,
},
handleDragForAllCards: {
type: 'boolean',
label: 'Require card grip to drag',
default: false,
},
handleDragForAllColumns: {
type: 'boolean',
label: 'Require column grip to drag',
default: false,
},
};
// -----------------------------------------------------------------------------
// Activity log
// -----------------------------------------------------------------------------
const ACTIVITY_LIMIT = 50;
function formatRelativeMs(at: number, now: number): string {
const delta = Math.max(0, now - at);
if (delta < 1000) {
return `${delta}ms`;
}
return `${(delta / 1000).toFixed(1)}s`;
}
// -----------------------------------------------------------------------------
// Main component
// -----------------------------------------------------------------------------
export default function KanbanExperiment() {
const { settings } = useExperimentSettings<KanbanSettings>();
const [board, dispatch] = React.useReducer(boardReducer, undefined, createSeedBoard);
const [selection, setSelection] = React.useState<Set<CardId>>(() => new Set());
const [dropIndicator, setDropIndicatorState] = React.useState<DropIndicatorState>(null);
const [columnHover, setColumnHover] = React.useState<Record<ColumnId, boolean>>({});
const [chaosEnabled, setChaosEnabled] = React.useState(false);
const boardRef = useValueAsRef(board);
const selectionRef = useValueAsRef(selection);
const dropIndicatorRef = useValueAsRef(dropIndicator);
const isDragging = useIsDragging();
const isDraggingRef = useValueAsRef(isDragging);
// Anchor card for range-select. Updated whenever selection changes through
// a direct user action (click, range click, toggle-add).
const lastSelectedIdRef = React.useRef<CardId | null>(null);
const setDropIndicator = useStableCallback((next: DropIndicatorState) => {
dropIndicatorRef.current = next;
setDropIndicatorState(next);
});
const toggleSelection = useStableCallback((cardId: CardId, additive: boolean) => {
setSelection((prev) => {
const next = new Set(prev);
if (additive) {
if (next.has(cardId)) {
next.delete(cardId);
} else {
next.add(cardId);
}
} else if (next.has(cardId) && next.size === 1) {
next.delete(cardId);
} else {
next.clear();
next.add(cardId);
}
return next;
});
lastSelectedIdRef.current = cardId;
});
const selectOnly = useStableCallback((cardId: CardId) => {
setSelection((prev) => {
if (prev.size === 1 && prev.has(cardId)) {
return prev;
}
return new Set([cardId]);
});
lastSelectedIdRef.current = cardId;
});
const clearSelection = useStableCallback(() => {
setSelection((prev) => (prev.size === 0 ? prev : new Set()));
lastSelectedIdRef.current = null;
});
// Range-select: pick every card between the anchor and `cardId` within the
// column that contains them both. Falls back to an additive toggle when the
// anchor is missing or lives in another column — a cross-column range is
// ambiguous in a kanban (which column's order wins?), so we don't guess.
const toggleSelectionRange = useStableCallback((cardId: CardId, columnId: ColumnId) => {
const anchor = lastSelectedIdRef.current;
if (anchor == null) {
selectOnly(cardId);
return;
}
const col = boardRef.current.columns[columnId];
const anchorIdx = col ? col.cardIds.indexOf(anchor) : -1;
const targetIdx = col ? col.cardIds.indexOf(cardId) : -1;
if (!col || anchorIdx === -1 || targetIdx === -1) {
toggleSelection(cardId, true);
return;
}
const [a, b] = anchorIdx <= targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];
const rangeIds = col.cardIds.slice(a, b + 1);
setSelection((prev) => {
const next = new Set(prev);
for (const id of rangeIds) {
next.add(id);
}
return next;
});
lastSelectedIdRef.current = cardId;
});
const deleteSelection = useStableCallback(() => {
const sel = selectionRef.current;
if (sel.size === 0) {
return;
}
dispatch({ type: 'DELETE_CARDS', cardIds: Array.from(sel) });
// Selection pruning happens in the board-sync effect; still clear the
// anchor here so range-select starts fresh.
lastSelectedIdRef.current = null;
});
React.useEffect(() => {
return monitorForElements<CardDragData | ColumnDragData>({
acceptKinds: KANBAN_DRAG_KINDS,
onDrop: () => {
setDropIndicator(null);
setColumnHover({});
},
});
}, [setDropIndicator]);
// Prune selection when cards disappear (single delete, column delete, reset).
// Without this, the "N selected" badge overcounts and shift-range falls back
// to additive toggle because the anchor points at a gone card.
React.useEffect(() => {
setSelection((prev) => {
let changed = false;
const next = new Set<CardId>();
for (const id of prev) {
if (board.cards[id] != null) {
next.add(id);
} else {
changed = true;
}
}
return changed ? next : prev;
});
const anchor = lastSelectedIdRef.current;
if (anchor != null && board.cards[anchor] == null) {
lastSelectedIdRef.current = null;
}
}, [board.cards]);
// Chaos mode: shuffle a column's cards during an active drag, every ~500ms.
const chaosFrame = useAnimationFrame();
const lastChaosTsRef = React.useRef(0);
React.useEffect(() => {
if (!chaosEnabled) {
return undefined;
}
const tick = (ts: number) => {
if (isDraggingRef.current && ts - lastChaosTsRef.current >= 500) {
lastChaosTsRef.current = ts;
dispatch({ type: 'CHAOS_SHUFFLE' });
}
chaosFrame.request(tick);
};
chaosFrame.request(tick);
return () => chaosFrame.cancel();
}, [chaosEnabled, chaosFrame, isDraggingRef]);
// Global Delete/Backspace: remove the current card selection. Ignored while
// typing in an input/textarea/contenteditable so editing card titles doesn't
// wipe the board.
React.useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Delete' && event.key !== 'Backspace') {
return;
}
if (selectionRef.current.size === 0) {
return;
}
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) {
return;
}
}
event.preventDefault();
deleteSelection();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [deleteSelection, selectionRef]);
const dataValue = React.useMemo<KanbanDataContextValue>(
() => ({
board,
boardRef,
dispatch,
selection,
selectionRef,
toggleSelection,
toggleSelectionRange,
selectOnly,
clearSelection,
deleteSelection,
settings,
}),
[
board,
boardRef,
selection,
selectionRef,
toggleSelection,
toggleSelectionRange,
selectOnly,
clearSelection,
deleteSelection,
settings,
],
);
const dragUIValue = React.useMemo<KanbanDragUIContextValue>(
() => ({
dropIndicator,
setDropIndicator,
dropIndicatorRef,
columnHover,
setColumnHover,
}),
[dropIndicator, setDropIndicator, dropIndicatorRef, columnHover],
);
const handleReset = useStableCallback(() => {
setSelection(new Set());
dispatch({ type: 'RESET', board: createSeedBoard() });
});
const handleAddColumn = useStableCallback(
(payload: { title: string; wipLimit: number | null; pinned: boolean; handleDrag: boolean }) => {
dispatch({
type: 'ADD_COLUMN',
title: payload.title,
wipLimit: payload.wipLimit,
pinned: payload.pinned,
handleDrag: payload.handleDrag,
});
},
);
return (
<KanbanDataContext.Provider value={dataValue}>
<KanbanDragUIContext.Provider value={dragUIValue}>
<div className={styles.root}>
<KanbanToolbar
chaosEnabled={chaosEnabled}
onToggleChaos={setChaosEnabled}
onReset={handleReset}
onAddColumn={handleAddColumn}
selectionSize={selection.size}
onClearSelection={clearSelection}
/>
<div className={styles.main}>
<KanbanBoard />
{settings.showActivityLog && <ActivityLogPanel />}
</div>
<CancelToast />
</div>
</KanbanDragUIContext.Provider>
</KanbanDataContext.Provider>
);
}
// -----------------------------------------------------------------------------
// Toolbar
// -----------------------------------------------------------------------------
interface KanbanToolbarProps {
chaosEnabled: boolean;
onToggleChaos: (next: boolean) => void;
onReset: () => void;
onAddColumn: (payload: {
title: string;
wipLimit: number | null;
pinned: boolean;
handleDrag: boolean;
}) => void;
selectionSize: number;
onClearSelection: () => void;
}
function KanbanToolbar(props: KanbanToolbarProps) {
return (
<Toolbar.Root className={styles.toolbar}>
<span className={styles.toolbarTitle}>Kanban</span>
<AddColumnPopover onSubmit={props.onAddColumn} />
<Toolbar.Button
render={
<button type="button" className={styles.iconButton} onClick={props.onReset}>
<RotateCcw size={14} aria-hidden />
Reset
</button>
}
/>
<Toolbar.Button
render={
<button
type="button"
className={styles.iconButton}
data-destructive={props.chaosEnabled ? '' : undefined}
onClick={() => props.onToggleChaos(!props.chaosEnabled)}
aria-pressed={props.chaosEnabled}
>
<Shuffle size={14} aria-hidden />
{props.chaosEnabled ? 'Chaos: ON' : 'Chaos: off'}
</button>
}
/>
<span className={styles.toolbarHint}>
Shift-click for range · Cmd/Ctrl-click to toggle · Del/Backspace to delete · Esc to cancel a
drag
</span>
<div className={styles.toolbarSpacer} />
{props.selectionSize > 0 && (
<React.Fragment>
<span className={styles.selectionBadge} aria-live="polite">
{props.selectionSize} selected
</span>
<Toolbar.Button
render={
<button type="button" className={styles.iconButton} onClick={props.onClearSelection}>
<X size={14} aria-hidden />
Clear
</button>
}
/>
</React.Fragment>
)}
</Toolbar.Root>
);
}
function AddColumnPopover(props: {
onSubmit: (payload: {
title: string;
wipLimit: number | null;
pinned: boolean;
handleDrag: boolean;
}) => void;
}) {
const [open, setOpen] = React.useState(false);
const [title, setTitle] = React.useState('');
const [wipLimitStr, setWipLimitStr] = React.useState('');
const [pinned, setPinned] = React.useState(false);
const [handleDrag, setHandleDrag] = React.useState(false);
const reset = () => {
setTitle('');
setWipLimitStr('');
setPinned(false);
setHandleDrag(false);
};
const submit = () => {
const trimmed = title.trim();
if (!trimmed) {
return;
}
const rawLimit = wipLimitStr.trim() === '' ? NaN : Number.parseInt(wipLimitStr, 10);
const wipLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : null;
props.onSubmit({
title: trimmed,
wipLimit,
pinned,
handleDrag,
});
setOpen(false);
reset();
};
return (
<Popover.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
reset();
}
}}
>
<Popover.Trigger
render={
<button type="button" className={styles.iconButton}>
<Plus size={14} aria-hidden />
Add column
</button>
}
/>
<Popover.Portal>
<Popover.Positioner sideOffset={8} align="start">
<Popover.Popup className={styles.popup}>
<div className={styles.popupTitle}>New column</div>
<label className={styles.popupField}>
<span className={styles.popupLabel}>Title</span>
<input
className={styles.popupInput}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
}}
autoFocus
/>
</label>
<label className={styles.popupField}>
<span className={styles.popupLabel}>WIP limit (optional)</span>
<input
className={styles.popupInput}
type="number"
min={1}
value={wipLimitStr}
onChange={(event) => setWipLimitStr(event.currentTarget.value)}
/>
</label>
<Switch label="Pinned (no auto-scroll)" checked={pinned} onCheckedChange={setPinned} />
<Switch
label="Require header grip to drag"
checked={handleDrag}
onCheckedChange={setHandleDrag}
/>
<div className={styles.popupActions}>
<button
type="button"
className={styles.iconButton}
onClick={() => {
setOpen(false);
reset();
}}
>
Cancel
</button>
<button type="button" className={styles.iconButton} onClick={submit}>
Add column
</button>
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
// -----------------------------------------------------------------------------
// Board
// -----------------------------------------------------------------------------
function KanbanBoard() {
const { board, clearSelection } = useKanbanData();
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const autoScrollRef = useRegisterAutoScroller({ allowedAxis: 'horizontal' });
const boardScrollRef = useStableCallback((node: HTMLDivElement | null) => {
scrollRef.current = node;
autoScrollRef(node);
});
// Pointer-down on the bare scroll container (not on a column/card) clears
// the selection. Wired via addEventListener so the div stays non-interactive
// from an a11y lint perspective.
React.useEffect(() => {
const el = scrollRef.current;
if (!el) {
return undefined;
}
const handler = (event: PointerEvent) => {
if (event.target === el) {
clearSelection();
}
};
el.addEventListener('pointerdown', handler);
return () => el.removeEventListener('pointerdown', handler);
}, [clearSelection]);
return (
<div className={styles.boardScroll} ref={boardScrollRef}>
{board.columnOrder.map((id) => (
<ColumnView key={id} columnId={id} />
))}
</div>
);
}
// -----------------------------------------------------------------------------
// Column
// -----------------------------------------------------------------------------
const ColumnView = React.memo(function ColumnView(props: { columnId: ColumnId }) {
const { columnId } = props;
const { board, boardRef, dispatch, settings } = useKanbanData();
const { setDropIndicator, dropIndicator, dropIndicatorRef, columnHover, setColumnHover } =
useKanbanDragUI();
const dragging = useIsDragging({ id: `column:${columnId}` });
const column = board.columns[columnId];
const cardListRef = useRegisterAutoScroller({
allowedAxis: 'vertical',
canScroll: () => {
const col = boardRef.current.columns[columnIdRef.current];
return col ? !col.pinned : true;
},
});
const gripRef = React.useRef<HTMLSpanElement | null>(null);
const columnHeaderRef = React.useRef<HTMLElement | null>(null);
const columnIdRef = useValueAsRef(columnId);
const forceHandleColumns = settings.handleDragForAllColumns;
const showGrip = (column?.handleDrag ?? false) || forceHandleColumns;
// The column header is the drag handle: drags initiate from the title row
// only, so the card list and footer remain interactive without triggering
// a column reorder.
const { ref: columnDraggableRef, dragPreview: columnDragPreview } = useDraggable<
ColumnDragData,
HTMLDivElement
>({
id: `column:${columnId}`,
kind: KANBAN_COLUMN_KIND,
getDragHandle: () => columnHeaderRef.current,
canDrag: () => {
const col = boardRef.current.columns[columnIdRef.current];
return col != null;
},
getInitialData: () => ({
id: columnIdRef.current,
}),
renderDragPreview: () => {
const col = boardRef.current.columns[columnIdRef.current];
if (!col) {
return null;
}
return <ColumnDragPreviewInner title={col.title} count={col.cardIds.length} />;
},
dragPreviewOffset: { x: 16, y: 12 },
});
// Make the column a drop target (accepts card drags AND column reorder drags).
// `acceptKinds` declares both kinds once; the engine filters before any
// callback fires so `source.data` is `CardDragData | ColumnDragData` here.
const columnDropTargetRef = useDropTarget<
CardDragData | ColumnDragData,
ColumnDropData,
HTMLDivElement
>({
kind: KANBAN_COLUMN_TARGET_KIND,
acceptKinds: KANBAN_DRAG_KINDS,
getData: () => ({
columnId: columnIdRef.current,
}),
canDrop: ({ source }) => {
if (isCardDrag(source)) {
const col = boardRef.current.columns[columnIdRef.current];
if (!col) {
return false;
}
if (col.wipLimit != null) {
// Post-move size: column minus any already-in-selection cards, plus
// the full selection. Collapses to `col.cardIds.length` when every
// selected card is already here, so same-column drags never fail.
const selectedSet = new Set(source.data.selectedIds);
const alreadyInTarget = col.cardIds.reduce(
(n: number, id: CardId) => (selectedSet.has(id) ? n + 1 : n),
0,
);
const postMoveSize =
col.cardIds.length - alreadyInTarget + source.data.selectedIds.length;
if (postMoveSize > col.wipLimit) {
return false;
}
}
return true;
}
// source must be a column drag here per `acceptKinds`.
return (source.data as ColumnDragData).id !== columnIdRef.current;
},
onDragEnter: ({ source }) => {
if (isColumnDrag(source)) {
return;
}
setColumnHover((prev) =>
prev[columnIdRef.current] ? prev : { ...prev, [columnIdRef.current]: true },
);
const col = boardRef.current.columns[columnIdRef.current];
if (col && col.cardIds.length === 0) {
setDropIndicator({ kind: 'column-empty', columnId: columnIdRef.current });
}
},
onDrag: ({ source, location, self }) => {
if (isColumnDrag(source)) {
const edge = computeColumnEdge(self.element as HTMLElement, location.current.input.clientX);
const current = dropIndicatorRef.current;
if (
!current ||
current.kind !== 'column-reorder' ||
current.columnId !== columnIdRef.current ||
current.edge !== edge
) {
setDropIndicator({ kind: 'column-reorder', columnId: columnIdRef.current, edge });
}
}
},
onDragLeave: ({ source }) => {
if (isColumnDrag(source)) {
const current = dropIndicatorRef.current;
if (
current &&
current.kind === 'column-reorder' &&
current.columnId === columnIdRef.current
) {
setDropIndicator(null);
}
return;
}
setColumnHover((prev) => {
if (!prev[columnIdRef.current]) {
return prev;
}
const next = { ...prev };
delete next[columnIdRef.current];
return next;
});
const current = dropIndicatorRef.current;
if (current && current.kind === 'column-empty' && current.columnId === columnIdRef.current) {
setDropIndicator(null);
}
},
onDrop: ({ source, self }) => {
// Engine fires `onDrop` only on the innermost target, so `self` is the
// innermost — no element-equality guard needed.
if (isCardDrag(source)) {
const target = resolveCardInsertion(
boardRef.current,
source.data,
self,
dropIndicatorRef.current,
);
if (target != null) {
dispatch({
type: 'MOVE_CARDS',
cardIds: source.data.selectedIds.filter(
(id: CardId) => boardRef.current.cards[id] != null,
),
toColumnId: target.toColumnId,
toIndex: target.toIndex,
});
}
return;
}
// source.data must be ColumnDragData here per `acceptSource`.
const toIndex = resolveColumnInsertion(
boardRef.current,
source.data,
self,
dropIndicatorRef.current,
);
if (toIndex != null) {
dispatch({ type: 'MOVE_COLUMN', columnId: source.data.id, toIndex });
}
},
});
if (!column) {
return null;
}
const isFull = column.wipLimit != null && column.cardIds.length >= column.wipLimit;
const columnReorderEdge =
dropIndicator?.kind === 'column-reorder' && dropIndicator.columnId === column.id
? dropIndicator.edge
: null;
return (
<div ref={columnDropTargetRef} className={styles.columnWrapper}>
{columnReorderEdge === 'before' && (
<span className={clsx(styles.columnDropLine, styles.columnDropLineBefore)} aria-hidden />
)}
<section
className={styles.column}
ref={columnDraggableRef}
data-hover={columnHover[column.id] ? 'true' : undefined}
data-dragging={dragging ? 'true' : undefined}
aria-label={column.title}
>
<ColumnHeader
column={column}
headerRef={columnHeaderRef}
gripRef={gripRef}
showGrip={showGrip}
/>
<div
className={styles.cardList}
ref={cardListRef}
data-empty-drop={
column.cardIds.length === 0 &&
dropIndicator?.kind === 'column-empty' &&
dropIndicator.columnId === column.id
? 'true'
: undefined
}
>
{column.cardIds.length === 0 ? (
<div className={styles.cardListEmpty}>Drop cards here</div>
) : (
column.cardIds.map((cardId) => (
<CardView key={cardId} cardId={cardId} columnId={column.id} />
))
)}
</div>
<div className={styles.columnFooter}>
<AddCardPopover columnId={column.id} disabled={isFull} />
{isFull && <span className={styles.hint}>WIP limit reached</span>}
</div>
{columnDragPreview}
</section>
{columnReorderEdge === 'after' && (
<span className={clsx(styles.columnDropLine, styles.columnDropLineAfter)} aria-hidden />
)}
</div>
);
});
// -----------------------------------------------------------------------------
// Column header (with menu, edit title, drag grip)
// -----------------------------------------------------------------------------
function ColumnHeader(props: {
column: Column;
headerRef: React.RefObject<HTMLElement | null>;
gripRef: React.RefObject<HTMLSpanElement | null>;
showGrip: boolean;
}) {
const { column, headerRef, gripRef, showGrip } = props;
const { dispatch } = useKanbanData();
return (
<header ref={headerRef} className={styles.columnHeader}>
<span
ref={gripRef}
className={styles.columnGrip}
aria-hidden={!showGrip}
style={showGrip ? undefined : { display: 'none' }}
>
<GripVertical size={14} />
</span>
<EditTitlePopover
title={column.title}
onRename={(next) =>
dispatch({ type: 'UPDATE_COLUMN', columnId: column.id, patch: { title: next } })
}
trigger={
<button type="button" className={styles.columnTitle} aria-label={`Edit ${column.title}`}>
{column.title}
</button>
}
/>
<span className={styles.columnMeta}>
<span
className={clsx(
styles.badge,
column.cardIds.length >= (column.wipLimit ?? Infinity) && styles.badgeFull,
)}
>
{column.cardIds.length}
{column.wipLimit != null ? ` / ${column.wipLimit}` : ''}
</span>
{column.pinned && (
<span className={clsx(styles.badge, styles.badgePinned)} title="Auto-scroll disabled">
<Pin size={10} /> pinned
</span>
)}
</span>
<ColumnMenu column={column} />
</header>
);
}
function ColumnMenu(props: { column: Column }) {
const { column } = props;
const { dispatch } = useKanbanData();
const [open, setOpen] = React.useState(false);
return (
<Menu.Root open={open} onOpenChange={setOpen}>
<Menu.Trigger
className={styles.columnMenu}
aria-label={`Menu for ${column.title}`}
data-open={open ? 'true' : undefined}
>
<MoreHorizontal size={14} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner className={styles.menuPositioner} sideOffset={6} align="end">
<Menu.Popup className={styles.menuPopup}>
<Menu.Item
className={styles.menuItem}
onClick={() =>
dispatch({
type: 'UPDATE_COLUMN',
columnId: column.id,
patch: { pinned: !column.pinned },
})
}
>
{column.pinned ? <PinOff size={14} /> : <Pin size={14} />}
{column.pinned ? 'Unpin column' : 'Pin column'}
</Menu.Item>
<Menu.Item
className={styles.menuItem}
onClick={() =>
dispatch({
type: 'UPDATE_COLUMN',
columnId: column.id,
patch: { handleDrag: !column.handleDrag },
})
}
>
<GripVertical size={14} />
{column.handleDrag ? 'Drag anywhere on header' : 'Require grip to drag'}
</Menu.Item>
<Menu.Separator className={styles.menuSeparator} />
<Menu.Item
className={styles.menuItem}
data-destructive="true"
onClick={() => dispatch({ type: 'DELETE_COLUMN', columnId: column.id })}
>
<Trash2 size={14} />
Delete column
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
// -----------------------------------------------------------------------------
// Card
// -----------------------------------------------------------------------------
const CardView = React.memo(function CardView(props: { cardId: CardId; columnId: ColumnId }) {
const { cardId, columnId } = props;
const {
board,
boardRef,
dispatch,
selection,
selectionRef,
toggleSelection,
toggleSelectionRange,
selectOnly,
settings,
} = useKanbanData();
const { setDropIndicator, dropIndicator, dropIndicatorRef } = useKanbanDragUI();
// Derive "this card is dragging" from the active drag source. A card drag
// can carry several selected ids, and they all visually fade together —
// covers the multi-select case in one read instead of mirroring a Set.
const dragSource = useDragSource<CardDragData | ColumnDragData>();
const dragging =
dragSource != null && isCardDrag(dragSource) && dragSource.data.selectedIds.includes(cardId);
const card = board.cards[cardId];
const gripRef = React.useRef<HTMLSpanElement | null>(null);
const column = board.columns[columnId];
const cardIdRef = useValueAsRef(cardId);
const columnIdRef = useValueAsRef(columnId);
const lockedRef = useValueAsRef(card?.locked ?? false);
const forceHandleCards = settings.handleDragForAllCards;
const showGrip = (column?.handleDrag ?? false) || forceHandleCards;
// Register the card as draggable. `getDragHandle` is re-invoked at drag
// time so toggling `showGrip` (which conditionally mounts the grip span)
// takes effect without re-registration.
const { ref: cardDraggableRef, dragPreview: cardDragPreview } = useDraggable<
CardDragData,
HTMLDivElement
>({
id: `card:${cardId}`,
kind: KANBAN_CARD_KIND,
getDragHandle: () => (showGrip ? gripRef.current : null),
canDrag: () => !lockedRef.current,
getInitialData: () => {
const sel = selectionRef.current;
const cards = boardRef.current.cards;
const base =
sel.has(cardIdRef.current) && sel.size > 1 ? Array.from(sel) : [cardIdRef.current];
// Drop locked cards from the drag payload so the reducer doesn't try
// to reorder them and downstream UI (preview count, activity log)
// reflects what will actually move. The dragged card is unlocked per
// `canDrag`, so the result is never empty.
const selected = base.filter((id: CardId) => {
const c = cards[id];
return c != null && !c.locked;
});
return {
id: cardIdRef.current,
selectedIds: selected,
};
},
renderDragPreview: ({ source }) => {
const latestCard = boardRef.current.cards[cardIdRef.current];
if (!latestCard) {
return null;
}
return (
<CardDragPreviewInner title={latestCard.title} count={source.data.selectedIds.length} />
);
},
dragPreviewOffset: { x: 16, y: 16 },
});
// Register the card as a drop target (for card-over-card drops).
// `acceptKinds` restricts this target to card drags — column drags fall
// through to the column drop target underneath, which is what we want.
const cardDropTargetRef = useDropTarget<
CardDragData,
CardDropData | ColumnDropData,
HTMLDivElement
>({
kind: KANBAN_CARD_TARGET_KIND,
acceptKinds: [KANBAN_CARD_KIND],
getData: (): CardDropData => ({
cardId: cardIdRef.current,
columnId: columnIdRef.current,
}),
canDrop: ({ source }) => !source.data.selectedIds.includes(cardIdRef.current),
onDrag: ({ location, self }) => {
const edge = computeCardEdge(self.element as HTMLElement, location.current.input.clientY);
const current = dropIndicatorRef.current;
if (
!current ||
current.kind !== 'card' ||
current.cardId !== cardIdRef.current ||
current.edge !== edge
) {
setDropIndicator({ kind: 'card', cardId: cardIdRef.current, edge });
}
},
onDragLeave: () => {
const current = dropIndicatorRef.current;
if (current && current.kind === 'card' && current.cardId === cardIdRef.current) {
setDropIndicator(null);
}
},
onDrop: ({ source, self }) => {
// Engine fires `onDrop` only on the innermost target, so `self` is the
// innermost — no element-equality guard needed.
const target = resolveCardInsertion(
boardRef.current,
source.data,
self,
dropIndicatorRef.current,
);
if (!target) {
return;
}
const valid = source.data.selectedIds.filter(
(id: CardId) => boardRef.current.cards[id] != null,
);
if (valid.length === 0) {
return;
}
dispatch({
type: 'MOVE_CARDS',
cardIds: valid,
toColumnId: target.toColumnId,
toIndex: target.toIndex,
});
},
});
const cardRef = useStableCallback((node: HTMLDivElement | null) => {
cardDraggableRef.current = node;
});
if (!card) {
return null;
}
const selected = selection.has(card.id);
const isIndicatorTarget =
dropIndicator?.kind === 'card' && dropIndicator.cardId === card.id ? dropIndicator.edge : null;
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.shiftKey) {
event.preventDefault();
toggleSelectionRange(card.id, columnId);
return;
}
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
toggleSelection(card.id, true);
return;
}
selectOnly(card.id);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (event.shiftKey) {
toggleSelectionRange(card.id, columnId);
} else if (event.metaKey || event.ctrlKey) {
toggleSelection(card.id, true);
} else {
selectOnly(card.id);
}
}
};
return (
<div ref={cardDropTargetRef} className={styles.cardWrapper}>
{isIndicatorTarget === 'before' && (
<span className={clsx(styles.dropLine, styles.dropLineBefore)} aria-hidden />
)}
<div
ref={cardRef}
className={styles.card}
data-selected={selected ? 'true' : undefined}
data-locked={card.locked ? 'true' : undefined}
data-dragging={dragging ? 'true' : undefined}
role="button"
tabIndex={0}
aria-label={`${card.title}${card.locked ? ', locked' : ''}${selected ? ', selected' : ''}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{showGrip && (
<span ref={gripRef} className={styles.cardGrip} aria-hidden>
<GripVertical size={14} />
</span>
)}
<div className={styles.cardBody}>
<div className={styles.cardTitle}>{card.title}</div>
{card.description && <div className={styles.cardDescription}>{card.description}</div>}
{card.locked && (
<div className={styles.cardTags}>
<span className={styles.badge}>
<Lock size={10} /> locked
</span>
</div>
)}
</div>
<CardMenu card={card} />
</div>
{isIndicatorTarget === 'after' && (
<span className={clsx(styles.dropLine, styles.dropLineAfter)} aria-hidden />
)}
{cardDragPreview}
</div>
);
});
function CardMenu(props: { card: Card }) {
const { card } = props;
const { dispatch } = useKanbanData();
const [open, setOpen] = React.useState(false);
const stop = (event: React.SyntheticEvent) => {
event.stopPropagation();
};
return (
<Menu.Root open={open} onOpenChange={setOpen}>
<Menu.Trigger
className={styles.cardMenu}
aria-label={`Menu for ${card.title}`}
data-open={open ? 'true' : undefined}
onClick={stop}
onPointerDown={stop}
onDragStart={(event) => event.preventDefault()}
>
<MoreHorizontal size={14} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner className={styles.menuPositioner} sideOffset={6} align="end">
<Menu.Popup className={styles.menuPopup}>
<Menu.Item
className={styles.menuItem}
onClick={() =>
dispatch({
type: 'UPDATE_CARD',
cardId: card.id,
patch: { locked: !card.locked },
})
}
>
{card.locked ? <LockOpen size={14} /> : <Lock size={14} />}
{card.locked ? 'Unlock card' : 'Lock card'}
</Menu.Item>
<Menu.Separator className={styles.menuSeparator} />
<Menu.Item
className={styles.menuItem}
data-destructive="true"
onClick={() => dispatch({ type: 'DELETE_CARD', cardId: card.id })}
>
<Trash2 size={14} />
Delete card
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
// -----------------------------------------------------------------------------
// Add-card and edit-title popovers
// -----------------------------------------------------------------------------
function AddCardPopover(props: { columnId: ColumnId; disabled?: boolean }) {
const { dispatch } = useKanbanData();
const [open, setOpen] = React.useState(false);
const [title, setTitle] = React.useState('');
const submit = () => {
const trimmed = title.trim();
if (!trimmed) {
return;
}
dispatch({ type: 'ADD_CARD', columnId: props.columnId, title: trimmed });
setTitle('');
setOpen(false);
};
return (
<Popover.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setTitle('');
}
}}
>
<Popover.Trigger
render={
<button
type="button"
className={styles.addCardButton}
disabled={props.disabled}
aria-label="Add card"
>
<Plus size={12} aria-hidden />
Add card
</button>
}
/>
<Popover.Portal>
<Popover.Positioner sideOffset={6} align="start">
<Popover.Popup className={styles.popup}>
<div className={styles.popupTitle}>New card</div>
<input
className={styles.popupInput}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
}
}}
autoFocus
aria-label="Card title"
/>
<div className={styles.popupActions}>
<button
type="button"
className={styles.iconButton}
onClick={() => {
setOpen(false);
setTitle('');
}}
>
Cancel
</button>
<button type="button" className={styles.iconButton} onClick={submit}>
Add
</button>
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
function EditTitlePopover(props: {
title: string;
onRename: (next: string) => void;
trigger: React.ReactElement<Record<string, unknown>>;
}) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(props.title);
React.useEffect(() => {
if (open) {
setValue(props.title);
}
}, [open, props.title]);
const submit = () => {
const trimmed = value.trim();
if (trimmed && trimmed !== props.title) {
props.onRename(trimmed);
}
setOpen(false);
};
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger render={props.trigger} />
<Popover.Portal>
<Popover.Positioner sideOffset={6} align="start">
<Popover.Popup className={styles.popup}>
<div className={styles.popupTitle}>Rename</div>
<input
className={styles.popupInput}
value={value}
onChange={(event) => setValue(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
submit();
} else if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
}
}}
autoFocus
/>
<div className={styles.popupActions}>
<button type="button" className={styles.iconButton} onClick={() => setOpen(false)}>
Cancel
</button>
<button type="button" className={styles.iconButton} onClick={submit}>
Save
</button>
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
// -----------------------------------------------------------------------------
// Drag previews
// -----------------------------------------------------------------------------
function CardDragPreviewInner(props: { title: string; count: number }) {
return (
<div className={styles.dragPreview}>
<div>{props.title}</div>
{props.count > 1 && <span className={styles.dragPreviewBadge}>{props.count}</span>}
</div>
);
}
function ColumnDragPreviewInner(props: { title: string; count: number }) {
return (
<div className={styles.columnDragPreview}>
{props.title}
<span className={styles.badge} style={{ marginLeft: 8 }}>
{props.count}
</span>
</div>
);
}
// -----------------------------------------------------------------------------
// Activity log + cancel toast
// -----------------------------------------------------------------------------
function ActivityLogPanel() {
const { boardRef } = useKanbanData();
const listRef = React.useRef<HTMLDivElement | null>(null);
const nextIdRef = React.useRef(0);
const [entries, setEntries] = React.useState<ActivityEvent[]>([]);
const [now, setNow] = React.useState(() => Date.now());
const tick = useTimeout();
const describeSource = useStableCallback(
(
source: ElementDragPayload<CardDragData | ColumnDragData>,
): {
kind: 'card' | 'column';
label: string;
selectionSize: number;
} => {
if (isCardDrag(source)) {
const card = boardRef.current.cards[source.data.id];
const title = card ? card.title : source.data.id;
return {
kind: 'card',
label:
source.data.selectedIds.length > 1
? `${title} (+${source.data.selectedIds.length - 1})`
: title,
selectionSize: source.data.selectedIds.length,
};
}
if (isColumnDrag(source)) {
const col = boardRef.current.columns[source.data.id];
return {
kind: 'column',
label: col ? col.title : source.data.id,
selectionSize: 1,
};
}
return { kind: 'card', label: 'unknown', selectionSize: 0 };
},
);
const describeTarget = useStableCallback((record: DropTargetRecord | undefined): string => {
if (!record) {
return '—';
}
const target = record as unknown as DropTargetRecord<CardDropData | ColumnDropData>;
if (isCardDropTarget(target)) {
const card = boardRef.current.cards[target.data.cardId];
return card ? `card "${card.title}"` : `card ${target.data.cardId}`;
}
if (isColumnDropTarget(target)) {
const col = boardRef.current.columns[target.data.columnId];
return col ? `column "${col.title}"` : `column ${target.data.columnId}`;
}
return 'unknown';
});
const push = useStableCallback((event: Omit<ActivityEvent, 'id' | 'at'>) => {
nextIdRef.current += 1;
const next: ActivityEvent = { ...event, id: nextIdRef.current, at: Date.now() };
setEntries((prev) => {
const tail =
prev.length >= ACTIVITY_LIMIT ? prev.slice(prev.length - ACTIVITY_LIMIT + 1) : prev;
return [...tail, next];
});
});
React.useEffect(() => {
// Dedupe "enter" events within a single drag, keyed by element identity so
// two columns with identical titles don't collide. Re-initialized on each
// drag start so a later drag re-entering the same element still logs.
let enteredByDrag = new WeakSet<Element>();
return monitorForElements<CardDragData | ColumnDragData>({
acceptKinds: KANBAN_DRAG_KINDS,
onDragStart: ({ source }) => {
enteredByDrag = new WeakSet<Element>();
const desc = describeSource(source);
push({
phase: 'start',
sourceKind: desc.kind,
sourceLabel: desc.label,
targetLabel: '—',
selectionSize: desc.selectionSize,
});
},
onDropTargetChange: ({ source, location }) => {
const prev = new Set(location.previous.dropTargets.map((t) => t.element));
const curr = new Set(location.current.dropTargets.map((t) => t.element));
const desc = describeSource(source);
// Resolve each unique record's label once per event; this hook fires
// on every drag-over tick, and describeTarget walks the board.
const targetLabels = new Map<Element, string>();
const labelFor = (record: DropTargetRecord) => {
let label = targetLabels.get(record.element);
if (label == null) {
label = describeTarget(record);
targetLabels.set(record.element, label);
}
return label;
};
for (const record of location.current.dropTargets) {
if (!prev.has(record.element) && !enteredByDrag.has(record.element)) {
enteredByDrag.add(record.element);
push({
phase: 'enter',
sourceKind: desc.kind,
sourceLabel: desc.label,
targetLabel: labelFor(record),
selectionSize: desc.selectionSize,
});
}
}
for (const record of location.previous.dropTargets) {
if (!curr.has(record.element)) {
push({
phase: 'leave',
sourceKind: desc.kind,
sourceLabel: desc.label,
targetLabel: labelFor(record),
selectionSize: desc.selectionSize,
});
}
}
},
onDrop: ({ source, location }) => {
const desc = describeSource(source);
const cancelled = location.current.dropTargets.length === 0;
const innermost = location.current.dropTargets[0];
push({
phase: cancelled ? 'cancel' : 'drop',
sourceKind: desc.kind,
sourceLabel: desc.label,
targetLabel: cancelled ? '—' : describeTarget(innermost),
selectionSize: desc.selectionSize,
});
},
});
}, [describeSource, describeTarget, push]);
React.useEffect(() => {
const schedule = () => {
tick.start(1000, () => {
setNow(Date.now());
schedule();
});
};
schedule();
return () => tick.clear();
}, [tick]);
useIsoLayoutEffect(() => {
const el = listRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [entries.length]);
return (
<aside className={styles.activityLog} aria-label="Drag activity log">
<div className={styles.activityHeader}>
<span>Monitor</span>
<span className={styles.activityHeaderMuted}>
{entries.length}/{ACTIVITY_LIMIT}
</span>
</div>
<div className={styles.activityList} ref={listRef}>
{entries.length === 0 && (
<span className={styles.activityHeaderMuted}>No activity yet. Start dragging.</span>
)}
{entries.map((entry) => (
<div key={entry.id} className={styles.activityEntry} data-phase={entry.phase}>
<span className={styles.activityTime}>{formatRelativeMs(entry.at, now)}</span>
<span className={styles.activityPhase}>{entry.phase}</span>
<span className={styles.activityBody}>
{entry.sourceKind} {entry.sourceLabel}
{entry.phase === 'enter' || entry.phase === 'leave' ? ` → ${entry.targetLabel}` : ''}
{entry.phase === 'drop' ? ` → ${entry.targetLabel}` : ''}
</span>
</div>
))}
</div>
</aside>
);
}
function CancelToast() {
// Owns its own `cancelAt` so the 2.5s auto-clear timer isn't reset every
// time the parent re-renders (which happens on every drag tick).
const [cancelAt, setCancelAt] = React.useState<number | null>(null);
const timeout = useTimeout();
React.useEffect(() => {
return monitorForElements<CardDragData | ColumnDragData>({
acceptKinds: KANBAN_DRAG_KINDS,
onDrop: ({ location }) => {
if (location.current.dropTargets.length === 0) {
setCancelAt(Date.now());
}
},
});
}, []);
React.useEffect(() => {
if (cancelAt == null) {
return;
}
timeout.start(2500, () => setCancelAt(null));
}, [cancelAt, timeout]);
if (cancelAt == null) {
return null;
}
return (
<div className={styles.toastStack} aria-live="polite">
<div className={styles.toast}>Drag cancelled</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment