Data Board

A drag-and-drop board with bounded height, generic grouping into bands, per-column pagination and hideable columns, all driven by your typed data model.

npx shadcn@latest add https://shuip.plvo.dev/r/data-board.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/data-board.json
bun x shadcn@latest add https://shuip.plvo.dev/r/data-board.json
'use client';
import {
closestCorners,
DndContext,
type DragEndEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ChevronDown, CircleDashed, Eye, EyeOff, GripVertical, type LucideIcon, Plus, Search } from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
export type DataBoardColumn = {
id: string;
label: string;
icon?: LucideIcon;
accentClassName?: string;
};
export type DataBoardGroup = {
id: string;
label: string;
};
export type DataBoardGroupBy<T> = {
field: keyof T;
label?: (value: unknown) => string;
description?: (value: unknown) => React.ReactNode;
groups?: DataBoardGroup[];
defaultCollapsed?: (groupId: string) => boolean;
};
export type DataBoardBand<T> = {
id: string;
label: string;
value: unknown;
items: T[];
};
export type DataBoardField<T> = {
key: keyof T;
label?: string;
render?: (value: T[keyof T], item: T) => React.ReactNode;
};
export const UNGROUPED_ID = '__none__';
export function groupIdOf(value: unknown): string {
return value == null ? UNGROUPED_ID : String(value);
}
function compareGroupIds(a: string, b: string): number {
if (a === UNGROUPED_ID) return 1;
if (b === UNGROUPED_ID) return -1;
const na = Number(a);
const nb = Number(b);
if (a !== '' && b !== '' && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
return a.localeCompare(b);
}
export function buildGroups<T extends object>(
items: T[],
groupBy: DataBoardGroupBy<T>,
ungroupedLabel: string,
): DataBoardBand<T>[] {
const explicit = new Map(groupBy.groups?.map((group) => [group.id, group.label]));
const buckets = new Map<string, { value: unknown; items: T[] }>();
for (const group of groupBy.groups ?? []) {
buckets.set(group.id, { value: group.id, items: [] });
}
for (const item of items) {
const value = item[groupBy.field];
const id = groupIdOf(value);
const bucket = buckets.get(id);
if (bucket) {
bucket.items.push(item);
// An explicitly declared group starts with its id as a placeholder value;
// the first real item replaces it so drops write back the original type.
if (bucket.items.length === 1) bucket.value = value;
} else {
buckets.set(id, { value, items: [item] });
}
}
const ids = [...buckets.keys()];
if (groupBy.groups) {
const rank = new Map(groupBy.groups.map((group, index) => [group.id, index]));
ids.sort((a, b) => {
const ra = rank.get(a);
const rb = rank.get(b);
if (ra != null && rb != null) return ra - rb;
if (ra != null) return -1;
if (rb != null) return 1;
return compareGroupIds(a, b);
});
} else {
ids.sort(compareGroupIds);
}
return ids.map((id) => {
const bucket = buckets.get(id) as { value: unknown; items: T[] };
const value = id === UNGROUPED_ID ? null : bucket.value;
return {
id,
label: explicit.get(id) ?? (id === UNGROUPED_ID ? ungroupedLabel : (groupBy.label?.(value) ?? String(value))),
value,
items: bucket.items,
};
});
}
export function paginate<T>(items: T[], limit: number): { shown: T[]; hidden: number } {
if (!Number.isFinite(limit) || limit >= items.length) return { shown: items, hidden: 0 };
const shown = items.slice(0, Math.max(0, limit));
return { shown, hidden: items.length - shown.length };
}
export function limitFor(pageSize: number | false, revealed: number): number {
if (pageSize === false || pageSize <= 0) return Number.POSITIVE_INFINITY;
return pageSize * (revealed + 1);
}
export function visibleColumns(columns: DataBoardColumn[], hidden: string[]): DataBoardColumn[] {
if (hidden.length === 0) return columns;
const hiddenSet = new Set(hidden);
return columns.filter((column) => !hiddenSet.has(column.id));
}
const DROP_SEPARATOR = '::';
export const columnKey = (columnId: string) => `col:${columnId}`;
// Pagination applies per cell (column x band) but the key is the band's:
// one "show more" raises every column in the band at once.
export const bandKey = (groupId: string) => `band:${groupId}`;
export function dropZoneId(columnId: string, groupId?: string): string {
return groupId == null ? columnId : `${groupId}${DROP_SEPARATOR}${columnId}`;
}
export function parseDropZoneId(id: string): { columnId: string; groupId?: string } {
const at = id.indexOf(DROP_SEPARATOR);
if (at === -1) return { columnId: id };
return { groupId: id.slice(0, at), columnId: id.slice(at + DROP_SEPARATOR.length) };
}
// The hovered zone when there is one, otherwise the cell of the hovered card.
// `groupId` stays absent outside grouped mode.
export function resolveDropTarget({
overId,
zones,
overItem,
}: {
overId: string;
zones: Set<string>;
overItem: { columnId: string; groupId?: string } | null;
}): { columnId: string; groupId?: string } | null {
if (zones.has(overId)) return parseDropZoneId(overId);
if (overItem) return { ...overItem };
return null;
}
// A move that changes neither column, rank nor band has nothing to report.
// `fromGroup` and `toGroup` are both undefined outside grouped mode, which
// neutralises their comparison.
export function isSamePosition(move: {
fromColumn: string;
toColumn: string;
fromIndex: number | null;
toIndex: number;
fromGroup?: string;
toGroup?: string;
}): boolean {
return move.fromColumn === move.toColumn && move.fromIndex === move.toIndex && move.fromGroup === move.toGroup;
}
// A card dropped outside the paginated slice must stay mounted: unmounting it
// mid-drag would make it vanish until the next "show more" and would deprive
// dnd-kit of its active node. `shown` is a prefix of `all`, so appending the
// card preserves order.
export function withActiveCard<T>(
all: T[],
shown: T[],
hidden: number,
activeId: string | null,
getId: (item: T) => string,
): { rendered: T[]; remaining: number } {
if (activeId == null || shown.some((item) => getId(item) === activeId)) return { rendered: shown, remaining: hidden };
const active = all.find((item) => getId(item) === activeId);
if (!active) return { rendered: shown, remaining: hidden };
return { rendered: [...shown, active], remaining: hidden - 1 };
}
// A band emptied mid-drag would disappear under the cursor: the layout would
// jump, dnd-kit's measurements would go stale, and the card could no longer
// return to its original band. Bands present at drag start stay rendered —
// empty, in place — until the drag ends. A band born during the drag is added.
export function withPinnedBands<T>(bands: DataBoardBand<T>[], pinned: DataBoardBand<T>[] | null): DataBoardBand<T>[] {
if (!pinned) return bands;
const live = new Map(bands.map((band) => [band.id, band]));
const pinnedIds = new Set(pinned.map((band) => band.id));
const kept = pinned.map((band) => live.get(band.id) ?? { ...band, items: [] });
return [...kept, ...bands.filter((band) => !pinnedIds.has(band.id))];
}
// How many steps to open so the card at `index` enters the paginated window.
// Zero when it is already inside — the common case must not inflate pagination.
export function revealStepsFor(pageSize: number | false, revealed: number, index: number): number {
if (pageSize === false || pageSize <= 0 || index < 0) return 0;
if (index < limitFor(pageSize, revealed)) return 0;
return Math.floor(index / pageSize) - revealed;
}
export type DataBoardLabels = {
empty: string;
ungrouped: string;
allColumnsHidden: string;
searchPlaceholder: string;
showMore: (remaining: number) => string;
pageSizeOption: (size: number) => string;
pageSizeLabel: string;
addToColumn: (label: string) => string;
hideColumn: (label: string) => string;
showColumn: (label: string) => string;
};
export const DEFAULT_LABELS: DataBoardLabels = {
empty: 'No items',
ungrouped: 'Ungrouped',
allColumnsHidden: 'All columns are hidden',
searchPlaceholder: 'Search...',
showMore: (remaining) => `Show ${remaining} more`,
pageSizeOption: (size) => `${size} per column`,
pageSizeLabel: 'Cards per column',
addToColumn: (label) => `Add to ${label}`,
hideColumn: (label) => `Hide ${label}`,
showColumn: (label) => `Show ${label}`,
};
export const STORAGE_PREFIX = 'shuip-data-board:';
export type StoredView = {
hidden: string[];
pageSize: number | false;
collapsed: Record<string, boolean>;
};
export function parseStoredView(raw: string | null, knownColumnIds: string[]): Partial<StoredView> {
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (typeof parsed !== 'object' || parsed === null) return {};
const source = parsed as Partial<StoredView>;
const out: Partial<StoredView> = {};
if (Array.isArray(source.hidden)) {
const known = new Set(knownColumnIds);
out.hidden = source.hidden.filter((id): id is string => typeof id === 'string' && known.has(id));
}
if (source.pageSize === false || (typeof source.pageSize === 'number' && source.pageSize > 0)) {
out.pageSize = source.pageSize;
}
if (source.collapsed && typeof source.collapsed === 'object' && !Array.isArray(source.collapsed)) {
out.collapsed = source.collapsed;
}
return out;
}
function writeStoredView(persistKey: string | undefined, view: StoredView): void {
if (!persistKey || typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_PREFIX + persistKey, JSON.stringify(view));
} catch {
// Storage full or unavailable: the view stays in memory for this session.
}
}
export type DataBoardView = {
hidden: string[];
toggleHidden: (columnId: string) => void;
pageSize: number | false;
setPageSize: (next: number | false) => void;
revealedFor: (key: string) => number;
revealMore: (key: string) => void;
isCollapsed: (groupId: string, fallback: boolean) => boolean;
toggleCollapsed: (groupId: string, fallback: boolean) => void;
};
export function useDataBoardView({
persistKey,
columnIds,
defaultPageSize,
hiddenColumns,
onHiddenColumnsChange,
}: {
persistKey?: string;
columnIds: string[];
defaultPageSize: number | false;
hiddenColumns?: string[];
onHiddenColumnsChange?: (ids: string[]) => void;
}): DataBoardView {
const columnIdsRef = React.useRef(columnIds);
const [ownHidden, setOwnHidden] = React.useState<string[]>([]);
const [pageSize, setPageSizeState] = React.useState<number | false>(defaultPageSize);
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
const [revealed, setRevealed] = React.useState<Record<string, number>>({});
const hydrated = React.useRef(false);
const controlled = hiddenColumns != null;
const hidden = controlled ? hiddenColumns : ownHidden;
// Always-current reflection of what belongs in storage. Unlike the state
// values, which only change on the next render, this ref is also rewritten
// by each mutator: two mutations batched into one render must both reach
// storage.
const storedRef = React.useRef<StoredView>({ hidden, pageSize, collapsed });
// Resynchronised on commit, never during render: React can replay or discard
// a render, and a ref written in a discarded render would leak state that was
// never displayed.
React.useEffect(() => {
columnIdsRef.current = columnIds;
storedRef.current = { hidden, pageSize, collapsed };
});
// Read after mount only: reading during render would make the server HTML
// diverge from the first client render. `columnIds` is deliberately not a
// dependency — adding a column must not re-read storage and clobber the
// current view.
React.useEffect(() => {
hydrated.current = true;
if (!persistKey || typeof window === 'undefined') return;
let raw: string | null = null;
try {
raw = window.localStorage.getItem(STORAGE_PREFIX + persistKey);
} catch {
return;
}
const stored = parseStoredView(raw, columnIdsRef.current);
if (stored.hidden) setOwnHidden(stored.hidden);
if (stored.pageSize !== undefined) setPageSizeState(stored.pageSize);
if (stored.collapsed) setCollapsed(stored.collapsed);
}, [persistKey]);
const persist = (patch: Partial<StoredView>) => {
const next = { ...storedRef.current, ...patch };
storedRef.current = next;
if (!hydrated.current) return;
writeStoredView(persistKey, next);
};
const toggleHidden = (columnId: string) => {
const base = storedRef.current.hidden;
const next = base.includes(columnId) ? base.filter((id) => id !== columnId) : [...base, columnId];
if (controlled) {
onHiddenColumnsChange?.(next);
return;
}
setOwnHidden(next);
persist({ hidden: next });
};
const setPageSize = (next: number | false) => {
setPageSizeState(next);
setRevealed({});
persist({ pageSize: next });
};
const revealedFor = (key: string) => revealed[key] ?? 0;
const revealMore = (key: string) => {
setRevealed((prev) => ({ ...prev, [key]: (prev[key] ?? 0) + 1 }));
};
const isCollapsed = (groupId: string, fallback: boolean) => collapsed[groupId] ?? fallback;
const toggleCollapsed = (groupId: string, fallback: boolean) => {
const base = storedRef.current.collapsed;
const next = { ...base, [groupId]: !(base[groupId] ?? fallback) };
setCollapsed(next);
persist({ collapsed: next });
};
return {
hidden,
toggleHidden,
pageSize,
setPageSize,
revealedFor,
revealMore,
isCollapsed,
toggleCollapsed,
};
}
export function DataBoardCardFace({
title,
body,
onClick,
handle,
className,
}: {
title?: React.ReactNode;
body?: React.ReactNode;
onClick?: () => void;
handle?: React.ReactNode;
className?: string;
}) {
return (
<Card className={cn('gap-0 py-0', onClick && 'cursor-pointer', className)} onClick={onClick}>
<div className='flex items-start gap-2 p-3'>
{handle}
<div className='min-w-0 flex-1 space-y-1'>
{title != null ? <div className='text-sm font-medium leading-snug'>{title}</div> : null}
{body}
</div>
</div>
</Card>
);
}
export function DataBoardCard({
id,
columnId,
groupId,
draggable,
title,
body,
onClick,
className,
}: {
id: string;
columnId: string;
groupId?: string;
draggable: boolean;
title?: React.ReactNode;
body?: React.ReactNode;
onClick?: () => void;
className?: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
data: { columnId, groupId },
disabled: !draggable,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50')}>
<DataBoardCardFace
title={title}
body={body}
onClick={onClick}
className={className}
handle={
draggable ? (
<button
type='button'
className='mt-0.5 cursor-grab touch-none text-muted-foreground active:cursor-grabbing'
onClick={(event) => event.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical className='size-4' />
</button>
) : null
}
/>
</div>
);
}
export function DataBoardColumnHeader({
column,
count,
summary,
labels,
onAdd,
onHide,
}: {
column: DataBoardColumn;
count: number;
summary?: React.ReactNode;
labels: DataBoardLabels;
onAdd?: () => void;
onHide?: () => void;
}) {
const Icon = column.icon ?? CircleDashed;
return (
<div data-slot='data-board-column-header' className='group/col flex w-72 shrink-0 items-center gap-1.5 px-1.5 py-1'>
<Icon className={cn('size-4 shrink-0 text-muted-foreground', column.accentClassName)} />
<span className='min-w-0 truncate text-sm font-medium'>{column.label}</span>
<span className='shrink-0 text-xs tabular-nums text-muted-foreground'>{count}</span>
{summary != null ? (
<span className='ml-auto shrink-0 text-xs tabular-nums text-muted-foreground'>{summary}</span>
) : null}
<div className={cn('flex shrink-0 items-center', summary == null && 'ml-auto')}>
{onAdd ? (
<Button
variant='ghost'
size='icon'
className='size-6'
onClick={onAdd}
aria-label={labels.addToColumn(column.label)}
>
<Plus className='size-4' />
</Button>
) : null}
{onHide ? (
<Button
variant='ghost'
size='icon'
className='size-6 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/col:opacity-100'
onClick={onHide}
aria-label={labels.hideColumn(column.label)}
>
<EyeOff className='size-4' />
</Button>
) : null}
</div>
</div>
);
}
export function DataBoardColumnBody({
zoneId,
scrollable,
children,
}: {
zoneId: string;
scrollable?: boolean;
children: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id: zoneId });
return (
<div
ref={setNodeRef}
data-slot='data-board-column-body'
className={cn(
// `p-1.5` leaves room for a badge a consumer positions at
// `-top-1.5 -right-1.5` on a card; without it the scrollport clips it.
'flex w-72 shrink-0 flex-col gap-1.5 rounded-lg border border-transparent p-1.5 transition-colors',
scrollable && 'min-h-0 overflow-x-hidden overflow-y-auto',
isOver && 'border-border bg-muted/50',
)}
>
{children}
</div>
);
}
export function DataBoardBandRow({
label,
count,
description,
collapsed,
onToggle,
footer,
children,
}: {
label: string;
count: number;
description?: React.ReactNode;
collapsed: boolean;
onToggle: () => void;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
const bodyId = React.useId();
return (
<section className='border-b last:border-b-0'>
<button
type='button'
onClick={onToggle}
aria-expanded={!collapsed}
// The body is unmounted when collapsed, so `aria-controls` must not
// reference an id that is absent from the DOM.
aria-controls={collapsed ? undefined : bodyId}
// Sticky within the band scroller, so a band header stays visible for
// as long as you are reading its cards.
className='sticky top-0 z-10 flex w-full items-center gap-2 bg-background py-2 text-left'
>
<ChevronDown
className={cn('size-4 shrink-0 text-muted-foreground transition-transform', collapsed && '-rotate-90')}
/>
<span data-slot='data-board-band-label' className='shrink-0 text-sm font-medium'>
{label}
</span>
<span className='shrink-0 text-xs tabular-nums text-muted-foreground'>{count}</span>
{description != null ? (
<span
data-slot='data-board-band-description'
className='hidden min-w-0 flex-1 truncate text-xs text-muted-foreground sm:block'
>
{description}
</span>
) : null}
</button>
{collapsed ? null : (
<>
<div id={bodyId} data-slot='data-board-band-body' className='pb-3'>
<div className='flex gap-2'>{children}</div>
</div>
{footer ? <div className='flex pb-3'>{footer}</div> : null}
</>
)}
</section>
);
}
export function DataBoardHiddenRail({
columns,
counts,
labels,
onShow,
}: {
columns: DataBoardColumn[];
counts: Record<string, number>;
labels: DataBoardLabels;
onShow: (columnId: string) => void;
}) {
if (columns.length === 0) return null;
return (
<div className='flex w-12 shrink-0 flex-col items-center gap-2 border-l bg-muted/40 py-3'>
{columns.map((column) => (
<Tooltip key={column.id}>
<TooltipTrigger asChild>
<Button
variant='outline'
className='h-auto w-8 flex-col gap-2 px-1 py-2.5 has-[>svg]:px-1'
onClick={() => onShow(column.id)}
aria-label={labels.showColumn(column.label)}
>
<span className='max-h-40 truncate text-xs font-medium [writing-mode:vertical-rl]'>{column.label}</span>
<Eye className={cn('size-3.5 text-muted-foreground', column.accentClassName)} />
</Button>
</TooltipTrigger>
<TooltipContent side='left'>
{labels.showColumn(column.label)} · {counts[column.id] ?? 0}
</TooltipContent>
</Tooltip>
))}
</div>
);
}
export function DataBoardToolbar({
query,
onQueryChange,
searchable,
pageSize,
pageSizeOptions,
onPageSizeChange,
labels,
}: {
query: string;
onQueryChange: (next: string) => void;
searchable: boolean;
pageSize: number | false;
pageSizeOptions: number[];
onPageSizeChange: (next: number) => void;
labels: DataBoardLabels;
}) {
const showPageSize = pageSizeOptions.length > 1 && pageSize !== false;
// `pageSize` and `pageSizeOptions` default independently, so a consumer who
// overrides only one leaves the select holding a value that matches no item,
// which renders as an empty trigger. Fold the active size in.
const options = React.useMemo(() => {
if (pageSize === false || pageSizeOptions.includes(pageSize)) return pageSizeOptions;
return [...pageSizeOptions, pageSize].sort((a, b) => a - b);
}, [pageSizeOptions, pageSize]);
if (!searchable && !showPageSize) return null;
return (
<div className='flex shrink-0 items-center gap-2'>
{searchable ? (
<div className='relative w-full max-w-xs'>
<Search className='absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={labels.searchPlaceholder}
aria-label={labels.searchPlaceholder}
className='pl-8'
/>
</div>
) : null}
{showPageSize ? (
<Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>
<SelectTrigger size='sm' className='ml-auto w-36' aria-label={labels.pageSizeLabel}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={String(option)}>
{labels.pageSizeOption(option)}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
);
}
export type DataBoardMoveEvent<T> = {
item: T;
fromColumn: string;
toColumn: string;
toIndex: number;
fromGroup?: string;
toGroup?: string;
};
/**
* The board's live card list and the drag state machine that mutates it.
*
* Hovering reorders optimistically; `onCardMove` fires on drop and only when
* the position actually changed. Every decision that does not need the DOM
* lives in the pure helpers above and is tested separately.
*/
function useDataBoardDrag<T extends object>({
data,
defaultData,
columnField,
groupBy,
ungroupedLabel,
getId,
getColumn,
getGroup,
shownColumns,
view,
onDataChange,
onCardMove,
}: {
data?: T[];
defaultData?: T[];
columnField: keyof T;
groupBy?: DataBoardGroupBy<T>;
ungroupedLabel: string;
getId: (item: T) => string;
getColumn: (item: T) => string;
getGroup: (item: T) => string | undefined;
shownColumns: DataBoardColumn[];
view: DataBoardView;
onDataChange?: (items: T[]) => void;
onCardMove?: (event: DataBoardMoveEvent<T>) => void;
}) {
const [items, setItems] = React.useState<T[]>(() => data ?? defaultData ?? []);
const draggingRef = React.useRef(false);
const fromColumnRef = React.useRef<string | null>(null);
const fromIndexRef = React.useRef<number | null>(null);
const fromGroupRef = React.useRef<string | undefined>(undefined);
const startItemsRef = React.useRef<T[] | null>(null);
const pendingDataRef = React.useRef<T[] | null>(null);
// A `data` update that lands mid-drag must not be dropped: the guard is a
// ref, so this effect will not re-run when the drag ends. Hold the value and
// drain it on the paths where the drag changed nothing.
React.useEffect(() => {
if (!data) return;
if (draggingRef.current) {
pendingDataRef.current = data;
return;
}
setItems(data);
}, [data]);
const [activeId, setActiveId] = React.useState<string | null>(null);
const [pinnedBands, setPinnedBands] = React.useState<DataBoardBand<T>[] | null>(null);
const bands = React.useMemo(
() => (groupBy ? withPinnedBands(buildGroups(items, groupBy, ungroupedLabel), pinnedBands) : null),
[items, groupBy, ungroupedLabel, pinnedBands],
);
const zoneIds = React.useMemo(() => {
const ids = new Set<string>();
for (const column of shownColumns) {
if (bands) for (const band of bands) ids.add(dropZoneId(column.id, band.id));
else ids.add(dropZoneId(column.id));
}
return ids;
}, [shownColumns, bands]);
// With no `groupId`, the index spans the whole column (flat mode); with one,
// it spans only the column x band cell, which is the paginated unit.
const cellIndexOf = React.useCallback(
(list: T[], columnId: string, groupId: string | undefined, cardId: string) =>
list
.filter((item) => getColumn(item) === columnId && (groupId == null || getGroup(item) === groupId))
.findIndex((item) => getId(item) === cardId),
[getColumn, getGroup, getId],
);
// Applied when the drag produced no change. On the success path the board
// emits `onDataChange` instead, which makes the parent the source of truth
// and leaves any value held here stale.
const drainPendingData = (apply: boolean) => {
const pending = pendingDataRef.current;
pendingDataRef.current = null;
if (apply && pending) setItems(pending);
};
function handleDragStart(event: DragStartEvent) {
draggingRef.current = true;
const id = String(event.active.id);
const startItem = items.find((item) => getId(item) === id);
const startColumn = startItem ? getColumn(startItem) : null;
fromColumnRef.current = startColumn;
fromIndexRef.current = startColumn == null ? null : cellIndexOf(items, startColumn, undefined, id);
fromGroupRef.current = startItem ? getGroup(startItem) : undefined;
startItemsRef.current = items;
setPinnedBands(bands);
setActiveId(id);
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeCardId = String(active.id);
const overId = String(over.id);
if (activeCardId === overId) return;
setItems((prev) => {
const activeIndex = prev.findIndex((item) => getId(item) === activeCardId);
if (activeIndex < 0) return prev;
const overIsZone = zoneIds.has(overId);
const overItem = overIsZone ? undefined : prev.find((item) => getId(item) === overId);
const target = resolveDropTarget({
overId,
zones: zoneIds,
overItem: overItem ? { columnId: getColumn(overItem), groupId: getGroup(overItem) } : null,
});
if (!target) return prev;
const current = prev[activeIndex];
const columnChanged = getColumn(current) !== target.columnId;
const groupChanged = target.groupId != null && getGroup(current) !== target.groupId;
// Write the band's value, not its id: a board grouped by a numeric field
// expects the number back, not its string form.
const bandValue = bands?.find((band) => band.id === target.groupId)?.value ?? null;
const updated =
columnChanged || groupChanged
? prev.map((item, index) => {
if (index !== activeIndex) return item;
const next = { ...item } as Record<string, unknown>;
if (columnChanged) next[columnField as string] = target.columnId;
if (groupChanged && groupBy) next[groupBy.field as string] = bandValue;
return next as T;
})
: prev;
const activeIndexNow =
columnChanged || groupChanged ? updated.findIndex((item) => getId(item) === activeCardId) : activeIndex;
const inTargetCell = (item: T) =>
getColumn(item) === target.columnId && (target.groupId == null || getGroup(item) === target.groupId);
let overIndex = activeIndexNow;
if (overIsZone) {
for (let index = 0; index < updated.length; index++) {
if (getId(updated[index]) !== activeCardId && inTargetCell(updated[index])) overIndex = index;
}
} else {
overIndex = updated.findIndex((item) => getId(item) === overId);
}
if (overIndex < 0 || (!columnChanged && !groupChanged && overIndex === activeIndexNow)) return updated;
return arrayMove(updated, activeIndexNow, overIndex);
});
}
function handleDragEnd(event: DragEndEvent) {
draggingRef.current = false;
setActiveId(null);
setPinnedBands(null);
const fromColumn = fromColumnRef.current;
const fromIndex = fromIndexRef.current;
const fromGroup = fromGroupRef.current;
fromColumnRef.current = null;
fromIndexRef.current = null;
fromGroupRef.current = undefined;
if (fromColumn == null) {
drainPendingData(true);
return;
}
const activeCardId = String(event.active.id);
const movedItem = items.find((item) => getId(item) === activeCardId);
if (!movedItem) {
drainPendingData(true);
return;
}
const toColumn = getColumn(movedItem);
const toIndex = cellIndexOf(items, toColumn, undefined, activeCardId);
const toGroup = getGroup(movedItem);
startItemsRef.current = null;
if (isSamePosition({ fromColumn, toColumn, fromIndex, toIndex, fromGroup, toGroup })) {
drainPendingData(true);
return;
}
// A card dropped past its target cell's paginated window would be invisible
// until the next "show more": open just enough steps to reveal it.
const key = toGroup == null ? columnKey(toColumn) : bandKey(toGroup);
const indexInCell = toGroup == null ? toIndex : cellIndexOf(items, toColumn, toGroup, activeCardId);
const steps = revealStepsFor(view.pageSize, view.revealedFor(key), indexInCell);
for (let step = 0; step < steps; step++) view.revealMore(key);
drainPendingData(false);
onDataChange?.(items);
onCardMove?.({ item: movedItem, fromColumn, toColumn, toIndex, fromGroup, toGroup });
}
function handleDragCancel() {
draggingRef.current = false;
const snapshot = startItemsRef.current;
startItemsRef.current = null;
fromColumnRef.current = null;
fromIndexRef.current = null;
fromGroupRef.current = undefined;
setActiveId(null);
setPinnedBands(null);
if (snapshot) setItems(snapshot);
drainPendingData(true);
}
return {
items,
activeId,
bands,
handlers: {
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
onDragCancel: handleDragCancel,
},
};
}
export type DataBoardProps<T extends object> = {
columns: DataBoardColumn[];
data?: T[];
defaultData?: T[];
onDataChange?: (next: T[]) => void;
idField?: keyof T;
columnField: keyof T;
title?: (item: T) => React.ReactNode;
fields?: DataBoardField<T>[];
cardContent?: (item: T) => React.ReactNode;
cardClassName?: (item: T) => string | undefined;
wrapCard?: (item: T, card: React.ReactNode) => React.ReactNode;
renderColumnSummary?: (items: T[], column: DataBoardColumn) => React.ReactNode;
onCardAdd?: (columnId: string) => void;
onCardClick?: (item: T) => void;
onCardMove?: (event: DataBoardMoveEvent<T>) => void;
searchableFields?: (keyof T)[];
groupBy?: DataBoardGroupBy<T>;
pageSize?: number | false;
pageSizeOptions?: number[];
hiddenColumns?: string[];
onHiddenColumnsChange?: (ids: string[]) => void;
persistKey?: string;
height?: number | string;
labels?: Partial<DataBoardLabels>;
className?: string;
};
export function DataBoard<T extends object>({
columns,
data,
defaultData,
onDataChange,
idField = 'id' as keyof T,
columnField,
title,
fields,
cardContent,
cardClassName,
wrapCard,
renderColumnSummary,
onCardAdd,
onCardClick,
onCardMove,
searchableFields,
groupBy,
pageSize = 20,
pageSizeOptions = [10, 20, 50],
hiddenColumns,
onHiddenColumnsChange,
persistKey,
height,
labels: labelOverrides,
className,
}: DataBoardProps<T>) {
const dndId = React.useId();
const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides]);
const getId = React.useCallback((item: T) => String(item[idField]), [idField]);
const getColumn = React.useCallback((item: T) => String(item[columnField]), [columnField]);
const getGroup = React.useCallback(
(item: T): string | undefined => (groupBy ? groupIdOf(item[groupBy.field]) : undefined),
[groupBy],
);
// A drag with no handler cannot accomplish anything: it would mutate local
// state without persisting, and the consumer's next refetch would undo it.
// Either handler is enough — a read-only board passes neither.
const draggable = onCardMove != null || onDataChange != null;
const view = useDataBoardView({
persistKey,
columnIds: columns.map((column) => column.id),
defaultPageSize: pageSize,
hiddenColumns,
onHiddenColumnsChange,
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const shownColumns = React.useMemo(() => visibleColumns(columns, view.hidden), [columns, view.hidden]);
const hiddenColumnList = React.useMemo(() => {
const hiddenSet = new Set(view.hidden);
return columns.filter((column) => hiddenSet.has(column.id));
}, [columns, view.hidden]);
const { items, activeId, bands, handlers } = useDataBoardDrag<T>({
data,
defaultData,
columnField,
groupBy,
ungroupedLabel: labels.ungrouped,
getId,
getColumn,
getGroup,
shownColumns,
view,
onDataChange,
onCardMove,
});
const [query, setQuery] = React.useState('');
const deferredQuery = React.useDeferredValue(query);
// Search is purely visual: it narrows what is rendered and never mutates the
// card list the drag machine owns.
const searchable = Boolean(searchableFields?.length);
const visibleItems = React.useMemo(() => {
const needle = deferredQuery.trim().toLowerCase();
if (!needle || !searchableFields?.length) return items;
return items.filter((item) =>
searchableFields.some((field) =>
String(item[field] ?? '')
.toLowerCase()
.includes(needle),
),
);
}, [items, deferredQuery, searchableFields]);
// Bands are built from the unfiltered list so a band never disappears
// mid-search; this set is what narrows each cell.
const visibleIds = React.useMemo(() => new Set(visibleItems.map(getId)), [visibleItems, getId]);
const shownColumnIds = React.useMemo(() => new Set(shownColumns.map((column) => column.id)), [shownColumns]);
const itemsByColumn = React.useMemo(() => {
const grouped = new Map<string, T[]>(columns.map((column) => [column.id, []]));
for (const item of visibleItems) grouped.get(getColumn(item))?.push(item);
return grouped;
}, [columns, visibleItems, getColumn]);
const counts = React.useMemo(() => {
const out: Record<string, number> = {};
for (const column of columns) out[column.id] = (itemsByColumn.get(column.id) ?? []).length;
return out;
}, [columns, itemsByColumn]);
const activeItem = activeId ? (items.find((item) => getId(item) === activeId) ?? null) : null;
const renderTitle = React.useCallback((item: T): React.ReactNode => (title ? title(item) : null), [title]);
const renderBody = React.useCallback(
(item: T): React.ReactNode => {
if (cardContent) return cardContent(item);
if (!fields?.length) return null;
return (
<div className='flex flex-wrap gap-x-2 gap-y-1 text-xs text-muted-foreground'>
{fields.map((field) => {
const value = item[field.key];
return (
<span key={String(field.key)} className='whitespace-nowrap'>
{field.label ? <span className='font-medium'>{field.label}: </span> : null}
{field.render ? field.render(value, item) : String(value ?? '')}
</span>
);
})}
</div>
);
},
[cardContent, fields],
);
// The rendered slice of a cell. `key` carries the pagination: a column in
// flat mode, a whole band in grouped mode.
const sliceOf = (all: T[], key: string) => {
const { shown, hidden } = paginate(all, limitFor(view.pageSize, view.revealedFor(key)));
return withActiveCard(all, shown, hidden, activeId, getId);
};
const renderCards = (list: T[], column: DataBoardColumn, groupId?: string) =>
list.map((item) => {
const card = (
<DataBoardCard
id={getId(item)}
columnId={column.id}
groupId={groupId}
draggable={draggable}
title={renderTitle(item)}
body={renderBody(item)}
className={cardClassName?.(item)}
onClick={onCardClick ? () => onCardClick(item) : undefined}
/>
);
return <React.Fragment key={getId(item)}>{wrapCard ? wrapCard(item, card) : card}</React.Fragment>;
});
const renderMoreButton = (key: string, remaining: number, buttonClassName: string) => (
<Button
variant='outline'
size='sm'
className={cn(buttonClassName, 'text-muted-foreground')}
onClick={() => view.revealMore(key)}
>
{labels.showMore(remaining)}
</Button>
);
// A flat-mode column scrolls on its own and carries its own "show more";
// a grouped-mode cell delegates both to its band.
const renderColumnBody = (
column: DataBoardColumn,
rendered: T[],
remaining: number,
key: string,
groupId?: string,
) => (
<DataBoardColumnBody key={column.id} zoneId={dropZoneId(column.id, groupId)} scrollable={groupId == null}>
<SortableContext items={rendered.map(getId)} strategy={verticalListSortingStrategy}>
{rendered.length === 0 ? (
<p className='rounded-md border border-dashed p-3 text-center text-xs text-muted-foreground'>
{labels.empty}
</p>
) : (
renderCards(rendered, column, groupId)
)}
</SortableContext>
{groupId == null && remaining > 0 ? renderMoreButton(key, remaining, 'w-full') : null}
</DataBoardColumnBody>
);
const rootStyle: React.CSSProperties =
height == null ? {} : { height: typeof height === 'number' ? `${height}px` : height };
return (
<div data-slot='data-board' style={rootStyle} className={cn('flex min-h-0 flex-col gap-3', className)}>
<DataBoardToolbar
query={query}
onQueryChange={setQuery}
searchable={searchable}
pageSize={view.pageSize}
pageSizeOptions={pageSizeOptions}
onPageSizeChange={view.setPageSize}
labels={labels}
/>
<DndContext id={dndId} sensors={sensors} collisionDetection={closestCorners} {...handlers}>
<div className='flex min-h-0 flex-1'>
<div className='min-h-0 flex-1 overflow-x-auto overflow-y-hidden'>
{shownColumns.length === 0 ? (
<p className='grid h-full place-items-center text-sm text-muted-foreground'>{labels.allColumnsHidden}</p>
) : (
<div className='flex h-full w-max min-w-full flex-col'>
<div className='flex shrink-0 gap-2 border-b pb-2'>
{shownColumns.map((column) => (
<DataBoardColumnHeader
key={column.id}
column={column}
count={counts[column.id] ?? 0}
summary={renderColumnSummary?.(itemsByColumn.get(column.id) ?? [], column)}
labels={labels}
onAdd={onCardAdd ? () => onCardAdd(column.id) : undefined}
onHide={() => view.toggleHidden(column.id)}
/>
))}
</div>
{bands ? (
<div className='min-h-0 flex-1 overflow-y-auto'>
{bands.map((band) => {
const key = bandKey(band.id);
const fallback = groupBy?.defaultCollapsed?.(band.id) ?? false;
// Narrowed by both search and column visibility so the header count always
// agrees with the cells rendered beneath it.
const bandItems = band.items.filter(
(item) => visibleIds.has(getId(item)) && shownColumnIds.has(getColumn(item)),
);
const cells = shownColumns.map((column) => {
const all = bandItems.filter((item) => getColumn(item) === column.id);
return { column, ...sliceOf(all, key) };
});
const remainingInBand = cells.reduce((total, cell) => total + cell.remaining, 0);
return (
<DataBoardBandRow
key={band.id}
label={band.label}
count={bandItems.length}
description={groupBy?.description?.(band.value)}
collapsed={view.isCollapsed(band.id, fallback)}
onToggle={() => view.toggleCollapsed(band.id, fallback)}
footer={remainingInBand > 0 ? renderMoreButton(key, remainingInBand, 'self-start') : null}
>
{cells.map((cell) =>
renderColumnBody(cell.column, cell.rendered, cell.remaining, key, band.id),
)}
</DataBoardBandRow>
);
})}
</div>
) : (
<div className='flex min-h-0 flex-1 gap-2 pt-2'>
{shownColumns.map((column) => {
const all = itemsByColumn.get(column.id) ?? [];
const key = columnKey(column.id);
const { rendered, remaining } = sliceOf(all, key);
return renderColumnBody(column, rendered, remaining, key);
})}
</div>
)}
</div>
)}
</div>
<DataBoardHiddenRail columns={hiddenColumnList} counts={counts} labels={labels} onShow={view.toggleHidden} />
</div>
<DragOverlay>
{activeItem ? (
<DataBoardCardFace
title={renderTitle(activeItem)}
body={renderBody(activeItem)}
className={cn('rotate-3 shadow-lg', cardClassName?.(activeItem))}
handle={<GripVertical className='mt-0.5 size-4 shrink-0 text-muted-foreground' />}
/>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}
Loading...

DataBoard renders any array of typed items into draggable columns and stays usable when that array grows: the board has a finite height with sticky column headers, each column scrolls on its own, and cards are paginated per column. Columns, card fields, grouping and search are all configured against your own data shape — nothing is hardcoded to a domain.

Built-in features

  • Generic data model: works with any item type T via idField / columnField.
  • Bounded height: sticky column headers, one scroll container per column.
  • Grouping into bands: groupBy any field to get collapsible horizontal bands; dropping a card in another band emits the new value.
  • Per-column pagination: a "show more" step per column, or one per band when grouped.
  • Hideable columns: hide from the header, recall from the right-hand rail.
  • Persisted view: hidden columns, page size and collapsed bands survive a reload via persistKey.
  • Global search: filter cards across searchableFields.
  • Hybrid state: standalone from defaultData, or controlled via data + onDataChange.

Cards become draggable as soon as you pass either onCardMove or onDataChange. A board that passes neither is genuinely read-only — the cards have no drag handle at all.

Card order is implicit in the data array order. On any move the board emits the reordered array via onDataChange and a semantic onCardMove event; map either to your own persistence.

Choosing between Kanban and Data Board

Both are drag-and-drop boards. Pick on volume and on how much view control your users need.

kanbandata-board
Heightgrows with the tallest columnbounded, sticky headers, per-column scroll
Volumetens of cardshundreds, via pagination
GroupingnonegroupBy any field, collapsible bands
Columnsfixedhideable, with a recall rail
View persistencenonelocalStorage via persistKey
Sizesmalllarger surface, more props

If a board fits on one screen and users never need to hide a column, kanban is the smaller and simpler install.

Sizing the board

The board is flex min-h-0 flex-col and fills the height its parent gives it — so it never needs to know the page chrome around it, and there is no hardcoded calc(). The contract is on the parent:

<div className='flex h-[calc(100svh-4rem)] flex-col'>
  <DataBoard {...props} />
</div>

Where the parent constrains nothing, pass height instead. A number is read as pixels.

<DataBoard height={520} {...props} />

Column accents

The block ships no palette — it would impose a theme on your app. A column takes a Lucide icon and an accentClassName applied to it, so the semantics stay yours:

const columns: DataBoardColumn[] = [
  { id: 'todo', label: 'To do', icon: CircleDashed },
  { id: 'doing', label: 'Doing', icon: CircleDot, accentClassName: 'text-primary' },
  { id: 'blocked', label: 'Blocked', icon: CircleX, accentClassName: 'text-destructive' },
  { id: 'done', label: 'Done', icon: CircleCheck, accentClassName: 'text-emerald-600' },
];

Examples

Custom Card

Loading...
'use client';
import { CircleCheck, CircleDashed, CircleDot } from 'lucide-react';
import * as React from 'react';
import { DataBoard, type DataBoardColumn } from '@/components/block/shuip/data-board';
import { Badge } from '@/components/ui/badge';
type Deal = {
id: string;
company: string;
value: number;
probability: number;
stage: string;
};
const COLUMNS: DataBoardColumn[] = [
{ id: 'lead', label: 'Lead', icon: CircleDashed },
{ id: 'negotiation', label: 'Negotiation', icon: CircleDot, accentClassName: 'text-primary' },
{ id: 'won', label: 'Won', icon: CircleCheck, accentClassName: 'text-foreground' },
];
const DEALS: Deal[] = [
{ id: 'd1', company: 'Northwind', value: 24000, probability: 40, stage: 'lead' },
{ id: 'd2', company: 'Initech', value: 8000, probability: 60, stage: 'lead' },
{ id: 'd3', company: 'Contoso', value: 51000, probability: 75, stage: 'negotiation' },
{ id: 'd4', company: 'Umbrella', value: 12500, probability: 90, stage: 'negotiation' },
{ id: 'd5', company: 'Globex', value: 33000, probability: 100, stage: 'won' },
];
const money = (amount: number) => `$${amount.toLocaleString('en-US')}`;
export default function DataBoardCustomCardExample() {
const [deals, setDeals] = React.useState(DEALS);
return (
<div className='flex h-[28rem] flex-col'>
<DataBoard<Deal>
columns={COLUMNS}
data={deals}
onDataChange={setDeals}
columnField='stage'
title={(deal) => deal.company}
cardContent={(deal) => (
<div className='flex items-center justify-between text-xs text-muted-foreground'>
<span className='font-medium text-foreground'>{money(deal.value)}</span>
<span>{deal.probability}%</span>
</div>
)}
cardClassName={(deal) => (deal.probability >= 90 ? 'border-primary' : undefined)}
wrapCard={(deal, card) => (
<div className='relative'>
{card}
{deal.value >= 50000 ? <Badge className='-top-1.5 -right-1.5 absolute'>Key</Badge> : null}
</div>
)}
renderColumnSummary={(items) => money(items.reduce((total, deal) => total + deal.value, 0))}
onCardMove={(event) => console.log('moved', event.item.id, '->', event.toColumn)}
/>
</div>
);
}

Default

Loading...
'use client';
import { CircleCheck, CircleDashed, CircleDot, CirclePause } from 'lucide-react';
import { DataBoard, type DataBoardColumn } from '@/components/block/shuip/data-board';
type Task = {
id: string;
title: string;
assignee: string;
points: number;
status: string;
};
const COLUMNS: DataBoardColumn[] = [
{ id: 'backlog', label: 'Backlog', icon: CircleDashed },
{ id: 'in-progress', label: 'In progress', icon: CircleDot, accentClassName: 'text-primary' },
{ id: 'review', label: 'In review', icon: CirclePause, accentClassName: 'text-primary/70' },
{ id: 'done', label: 'Done', icon: CircleCheck, accentClassName: 'text-foreground' },
];
const ASSIGNEES = ['Ada', 'Grace', 'Alan', 'Katherine'];
const STATUSES = ['backlog', 'in-progress', 'review', 'done'];
const TASKS: Task[] = Array.from({ length: 34 }, (_, index) => ({
id: `task-${index + 1}`,
title: `Task ${index + 1}`,
assignee: ASSIGNEES[index % ASSIGNEES.length],
points: (index % 5) + 1,
status: STATUSES[index % STATUSES.length],
}));
export default function DataBoardDefaultExample() {
return (
<div className='flex h-[32rem] flex-col'>
<DataBoard<Task>
columns={COLUMNS}
defaultData={TASKS}
columnField='status'
title={(task) => task.title}
fields={[{ key: 'assignee' }, { key: 'points', label: 'pts' }]}
searchableFields={['title', 'assignee']}
renderColumnSummary={(tasks) => `${tasks.reduce((total, task) => total + task.points, 0)} pts`}
pageSize={5}
pageSizeOptions={[5, 10, 20]}
onCardMove={(event) => console.log('moved', event.item.id, '->', event.toColumn)}
onCardAdd={(columnId) => console.log('add to', columnId)}
onCardClick={(task) => console.log('open', task.id)}
/>
</div>
);
}

Grouped

Loading...
'use client';
import { CircleCheck, CircleDashed, CircleDot } from 'lucide-react';
import { DataBoard, type DataBoardColumn } from '@/components/block/shuip/data-board';
type Ticket = {
id: string;
title: string;
owner: string;
sprint: number | null;
status: string;
};
const COLUMNS: DataBoardColumn[] = [
{ id: 'todo', label: 'To do', icon: CircleDashed },
{ id: 'doing', label: 'Doing', icon: CircleDot, accentClassName: 'text-primary' },
{ id: 'done', label: 'Done', icon: CircleCheck, accentClassName: 'text-foreground' },
];
const SPRINT_NOTES: Record<string, string> = {
'12': 'Search relevance and indexing',
'13': 'Billing migration',
'14': 'Mobile polish',
};
const OWNERS = ['Ada', 'Grace', 'Alan'];
const STATUSES = ['todo', 'doing', 'done'];
const SPRINTS: (number | null)[] = [12, 13, 14, null];
const TICKETS: Ticket[] = Array.from({ length: 28 }, (_, index) => ({
id: `ticket-${index + 1}`,
title: `Ticket ${index + 1}`,
owner: OWNERS[index % OWNERS.length],
sprint: SPRINTS[index % SPRINTS.length],
status: STATUSES[index % STATUSES.length],
}));
export default function DataBoardGroupedExample() {
return (
<div className='flex h-[36rem] flex-col'>
<DataBoard<Ticket>
columns={COLUMNS}
defaultData={TICKETS}
columnField='status'
persistKey='docs-data-board-grouped'
title={(ticket) => ticket.title}
fields={[{ key: 'owner' }]}
pageSize={2}
pageSizeOptions={[2, 4, 8]}
groupBy={{
field: 'sprint',
groups: [
{ id: '14', label: 'Sprint 14' },
{ id: '13', label: 'Sprint 13' },
{ id: '12', label: 'Sprint 12' },
],
description: (value) => SPRINT_NOTES[String(value)] ?? null,
defaultCollapsed: (groupId) => groupId === '12',
}}
labels={{ ungrouped: 'No sprint' }}
onCardMove={(event) => console.log('moved', event.item.id, event.toColumn, event.toGroup)}
/>
</div>
);
}

Notes and limitations

  • toIndex is a whole-column index, including in grouped mode. When groupBy is active, the toIndex on onCardMove reflects the card's position within its entire column, not its position inside the band the user sees. If you persist a rank, compute it from the item's position within its group rather than from toIndex directly.
  • Dropping into a declared-but-empty group writes the group's id as a string. When groupBy.groups declares a group that currently holds no items, the board has no item from which to learn the field's real type, so a card dropped there receives the group's id as a string. If the grouped field is numeric, coerce the value in onCardMove until the group holds at least one item.
  • Releasing a card outside any drop zone commits the last hovered position. The board does not revert the move when a card is released outside a valid drop zone; it keeps the position the card last hovered over and fires onCardMove with that position. Implement your own revert inside onCardMove if you need Trello-style cancellation on an out-of-bounds release.

Props

Prop

Type

DataBoardColumn

Prop

Type

DataBoardGroupBy

Prop

Type

DataBoardLabels

labels takes a Partial<DataBoardLabels>, so a consumer overrides only the keys they need.

Prop

Type

On this page