shadimage
Shuip
blockdata-boardsource: stale · stale

Data Board

npx shadcn add "https://shuip.plvo.dev/r/data-board.json"
free

This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.

Where it goes

kanban

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

buttoncardinputselecttooltip

Files1 · source cached 10/8/2026, 6:47:18 AM

./items/blocks/data-board/component.tsx
'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>
  );
}

More from Shuip

Browse all components in this library →