Created
May 11, 2026 22:25
-
-
Save colmtuite/032f0bc7977bf97636e292c2b0069f0f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| '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