UIPackage

Board — compositional kanban / sortable-list primitive

React data
Edit on GitHub

Six small composable components for any board / kanban / sortable-list surface — opinionated about drop targeting and animation, agnostic about layout, data, and chrome. Drop `<Board>` around a grid of `<BoardLane>` columns; nest `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. State lives in a sibling `useBoard()` hook (insertion-index drop math, keyboard a11y, accept predicate). Cards use native HTML5 drag-and-drop (draggable + onDragStart / onLaneDragOver / onLaneDrop) — the lane components emit drag events up to the consumer, which threads them through useBoard and feeds the resulting state back in as props. Three-level React context (board → lane → card) mirrors the Timeline / Card sub-component pattern.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/board.json

Or with the named registry: npx shadcn@latest add @uipkge-react/board

Examples

Props

Name Type / Values Default Required
tone
'default''plain'
default optional
state
'idle''over''rejecting'
idle optional
orientation BoardOrientation horizontal optional
density BoardDensity default optional
motion

Animation preset class applied by BoardLaneBody. Defaults to `motion-list`.

string motion-list optional
accepts

Predicate run per drop. Defaults to always-accept.

BoardAcceptsFn optional
moveItem

Imperative move from a parent's useBoard hook.

(itemId: string | string[], toLaneId: string, toIndex?: number) => void optional
draggingId

External state — pass `state.draggingId` etc. from useBoard.

string | null optional
draggingIds readonly string[] optional
dragOverLaneId string | null optional
justMovedId string | null optional
selectedIds ReadonlySet<string> optional
toggleSelection

expose selection mutations through context (consumed by BoardCard click handler). Both default to no-ops, so Board still works with consumers that don't wire selection.

(itemId: string, additive?: boolean) => void optional
clearSelection () => void optional
registerAllowedLanes (cardId: string, lanes: readonly string[] | undefined) => void optional
unregisterAllowedLanes (cardId: string) => void optional
registerLaneDisabled (laneId: string, disabled: boolean) => void optional
unregisterLaneDisabled (laneId: string) => void optional
isLaneAcceptingFor (laneId: string) => boolean optional
children React.ReactNode optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

BoardDropEvent
interface BoardDropEvent {
  /** First (anchor) item moved. For multi-item drops, see `itemIds`. */
  itemId: string
  /** All items moved in this drop, in their final visual order. */
  itemIds: string[]
  from: string
  to: string
  /** Insertion index of the first item in the target lane. */
  index: number
}
BoardContextValue
interface BoardContextValue {
  orientation: BoardOrientation
  density: BoardDensity
  /** Animation preset class name. Defaults to `motion-list` (the @uipkge motion preset). */
  motion: string
  /** Currently-grabbed primary card id (pointer drag OR keyboard grab). */
  draggingId: string | null
  /** All cards being dragged this turn — usually `[draggingId]`, but
   *  expands to the full selection when the user grabs one of a multi-
   *  selected set. Lanes read this to compute drop math (the dragged
   *  cards are excluded from the insertion-index walk). */
  draggingIds: readonly string[]
  /** Lane currently under the pointer/keyboard cursor. */
  dragOverLaneId: string | null
  /** Card that just landed — used by consumers for a momentary highlight. */
  justMovedId: string | null
  /** Multi-select set. Click-and-drag any selected card moves the whole
   *  set; click a non-selected card to drag that one alone. */
  selectedIds: ReadonlySet<string>
  /** Predicate run by lanes; defaults to always-accept. */
  accepts: BoardAcceptsFn
  /** Imperative move — used by keyboard handlers + external callers. */
  moveItem: (itemId: string | string[], toLaneId: string, toIndex?: number) => void
  /** Toggle one item in the selection set (or replace it if `additive` is false). */
  toggleSelection: (itemId: string, additive?: boolean) => void
  /** Drop the entire selection. */
  clearSelection: () => void
  /** Per-card allow-list registry. BoardCard registers its own `allowedLanes`
   *  on mount; lanes consult this to short-circuit rejected drops without
   *  the consumer having to encode the rule inside `accepts`. */
  registerAllowedLanes: (cardId: string, lanes: readonly string[] | undefined) => void
  unregisterAllowedLanes: (cardId: string) => void
  /** Whether the lane's drops are accepted for the dragging item, given
   *  the current `accepts` + per-card allowedLanes + lane disabled state. */
  isLaneAcceptingFor: (laneId: string) => boolean
  /** Lane disabled registry — lanes call register/unregister; useBoard +
   *  isLaneAcceptingFor read from it. */
  registerLaneDisabled: (laneId: string, disabled: boolean) => void
  unregisterLaneDisabled: (laneId: string) => void
}
BoardLaneContextValue
interface BoardLaneContextValue {
  laneId: string
  isDragOver: boolean
  isAccepting: boolean
  disabled: boolean
}
BoardCardContextValue
interface BoardCardContextValue {
  cardId: string
  laneId: string
  isDragging: boolean
  isJustMoved: boolean
  isSelected: boolean
  disabled: boolean
}

Dependencies

Used by

Files (4)

  • components/ui/board/board.tsx 19.1 kB
    'use client'
    
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import {
      BoardCardContext,
      BoardContext,
      BoardLaneContext,
      useBoardContext,
      useBoardLaneContext,
      type BoardAcceptsFn,
      type BoardContextValue,
      type BoardDensity,
      type BoardOrientation,
    } from './context'
    import { boardCardVariants, boardLaneVariants } from './board.variants'
    
    /* ------------------------------------------------------------------ */
    /* Board (Root)                                                        */
    /* ------------------------------------------------------------------ */
    
    const ALWAYS_ACCEPT: BoardAcceptsFn = () => true
    const NOOP = () => {}
    
    export interface BoardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
      orientation?: BoardOrientation
      density?: BoardDensity
      /** Animation preset class applied by BoardLaneBody. Defaults to `motion-list`. */
      motion?: string
      /** Predicate run per drop. Defaults to always-accept. */
      accepts?: BoardAcceptsFn
      /** Imperative move from a parent's useBoard hook. */
      moveItem?: (itemId: string | string[], toLaneId: string, toIndex?: number) => void
      /** External state — pass `state.draggingId` etc. from useBoard. */
      draggingId?: string | null
      draggingIds?: readonly string[]
      dragOverLaneId?: string | null
      justMovedId?: string | null
      selectedIds?: ReadonlySet<string>
      /** Optional toggle/clearSelection from useBoard so the primitive can
       *  expose selection mutations through context (consumed by BoardCard
       *  click handler). Both default to no-ops, so Board still works with
       *  consumers that don't wire selection. */
      toggleSelection?: (itemId: string, additive?: boolean) => void
      clearSelection?: () => void
      registerAllowedLanes?: (cardId: string, lanes: readonly string[] | undefined) => void
      unregisterAllowedLanes?: (cardId: string) => void
      registerLaneDisabled?: (laneId: string, disabled: boolean) => void
      unregisterLaneDisabled?: (laneId: string) => void
      isLaneAcceptingFor?: (laneId: string) => boolean
      children?: React.ReactNode
    }
    
    const EMPTY_IDS: readonly string[] = []
    const EMPTY_SELECTION: ReadonlySet<string> = new Set<string>()
    
    const Board = React.forwardRef<HTMLDivElement, BoardProps>(
      (
        {
          className,
          orientation = 'horizontal',
          density = 'default',
          motion = 'motion-list',
          accepts = ALWAYS_ACCEPT,
          moveItem = NOOP,
          draggingId = null,
          draggingIds = EMPTY_IDS,
          dragOverLaneId = null,
          justMovedId = null,
          selectedIds = EMPTY_SELECTION,
          toggleSelection = NOOP,
          clearSelection = NOOP,
          registerAllowedLanes = NOOP,
          unregisterAllowedLanes = NOOP,
          registerLaneDisabled = NOOP,
          unregisterLaneDisabled = NOOP,
          isLaneAcceptingFor = () => true,
          children,
          ...props
        },
        ref,
      ) => {
        const ctx = React.useMemo<BoardContextValue>(
          () => ({
            orientation,
            density,
            motion,
            draggingId,
            draggingIds,
            dragOverLaneId,
            justMovedId,
            selectedIds,
            accepts,
            moveItem,
            toggleSelection,
            clearSelection,
            registerAllowedLanes,
            unregisterAllowedLanes,
            registerLaneDisabled,
            unregisterLaneDisabled,
            isLaneAcceptingFor,
          }),
          [
            orientation,
            density,
            motion,
            draggingId,
            draggingIds,
            dragOverLaneId,
            justMovedId,
            selectedIds,
            accepts,
            moveItem,
            toggleSelection,
            clearSelection,
            registerAllowedLanes,
            unregisterAllowedLanes,
            registerLaneDisabled,
            unregisterLaneDisabled,
            isLaneAcceptingFor,
          ],
        )
    
        return (
          <BoardContext.Provider value={ctx}>
            <div
              ref={ref}
              data-uipkge=""
              data-slot="board"
              data-orientation={orientation}
              className={cn('w-full', className)}
              {...props}
            >
              {children}
            </div>
          </BoardContext.Provider>
        )
      },
    )
    Board.displayName = 'Board'
    
    /* ------------------------------------------------------------------ */
    /* BoardLane                                                           */
    /* ------------------------------------------------------------------ */
    
    export interface BoardLaneRenderProps {
      isDragOver: boolean
      isAccepting: boolean
      disabled: boolean
    }
    
    export interface BoardLaneProps
      extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children' | 'id'> {
      id: string
      tone?: 'default' | 'plain'
      /** Disable drops on this lane. Cards inside still render and stay
       *  draggable; only the drop target is inert + visually dimmed. */
      disabled?: boolean
      onLaneDragOver?: (e: React.DragEvent<HTMLDivElement>) => void
      onLaneDrop?: (e: React.DragEvent<HTMLDivElement>) => void
      onLaneDragLeave?: () => void
      children?: React.ReactNode | ((props: BoardLaneRenderProps) => React.ReactNode)
    }
    
    const BoardLane = React.forwardRef<HTMLDivElement, BoardLaneProps>(
      (
        {
          id,
          className,
          tone = 'default',
          disabled = false,
          onLaneDragOver,
          onLaneDrop,
          onLaneDragLeave,
          children,
          ...props
        },
        ref,
      ) => {
        const board = useBoardContext()
    
        // Register the lane's disabled flag; keep it in sync when id/disabled change.
        React.useEffect(() => {
          board.registerLaneDisabled(id, disabled)
          return () => board.unregisterLaneDisabled(id)
          // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [id, disabled])
    
        const isDragOver = board.dragOverLaneId === id
        const isAccepting = (() => {
          if (!board.draggingId) return false
          if (disabled) return false
          return board.isLaneAcceptingFor(id)
        })()
    
        const laneCtx = React.useMemo(
          () => ({ laneId: id, isDragOver, isAccepting, disabled }),
          [id, isDragOver, isAccepting, disabled],
        )
    
        const state: 'idle' | 'over' | 'rejecting' = !isDragOver ? 'idle' : isAccepting ? 'over' : 'rejecting'
    
        // Emit unconditionally so the hook can set dragOverLaneId
        // (drives the rejecting-ring visual). The hook's isAllowed
        // checks the disabled-lane registry and refuses preventDefault when
        // it should — the browser's own refuse-to-drop semantics + our
        // rejecting visual cover the rest.
        function handleDragOver(e: React.DragEvent<HTMLDivElement>) {
          onLaneDragOver?.(e)
        }
        function handleDrop(e: React.DragEvent<HTMLDivElement>) {
          if (disabled) {
            e.preventDefault()
            return
          }
          onLaneDrop?.(e)
        }
        function handleDragLeave() {
          onLaneDragLeave?.()
        }
    
        return (
          <BoardLaneContext.Provider value={laneCtx}>
            <div
              ref={ref}
              data-uipkge=""
              data-slot="board-lane"
              data-board-lane=""
              data-lane-id={id}
              data-state={state}
              data-disabled={disabled || undefined}
              aria-disabled={disabled || undefined}
              className={cn(
                boardLaneVariants({ tone, state }),
                // Disabled lane dims only the lane chrome (border, background,
                // header). Cards inside stay legible — the rejection signal is
                // carried by the no-drop cursor + the missing accept-ring.
                disabled && 'opacity-80 [&>[data-slot=board-lane-header]]:opacity-60',
                className,
              )}
              onDragOver={handleDragOver}
              onDrop={handleDrop}
              onDragLeave={handleDragLeave}
              {...props}
            >
              {typeof children === 'function' ? children({ isDragOver, isAccepting, disabled }) : children}
            </div>
          </BoardLaneContext.Provider>
        )
      },
    )
    BoardLane.displayName = 'BoardLane'
    
    /* ------------------------------------------------------------------ */
    /* BoardLaneHeader                                                     */
    /* ------------------------------------------------------------------ */
    
    const BoardLaneHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
      ({ className, ...props }, ref) => (
        <div
          ref={ref}
          data-uipkge=""
          data-slot="board-lane-header"
          className={cn('flex items-center justify-between gap-2', className)}
          {...props}
        />
      ),
    )
    BoardLaneHeader.displayName = 'BoardLaneHeader'
    
    /* ------------------------------------------------------------------ */
    /* BoardLaneBody                                                       */
    /* ------------------------------------------------------------------ */
    
    export interface BoardLaneBodyProps extends React.HTMLAttributes<HTMLDivElement> {
      /** Override the animation preset class. Defaults to the board-level motion preset. */
      motion?: string
    }
    
    const BoardLaneBody = React.forwardRef<HTMLDivElement, BoardLaneBodyProps>(
      ({ className, motion, children, ...props }, ref) => {
        const board = React.useContext(BoardContext)
        const motionName = motion ?? board?.motion ?? 'motion-list'
        return (
          // Inner padding (py-1 / px-0.5) reserves breathing room for the
          // per-card hover-lift (-translate-y-0.5), the focus / drag / moved
          // rings (ring-2 + ring-offset-1 ≈ 3px outward), and the hover
          // shadow halo. Without it, the first / last cards' hover state
          // crops against the overflow-y-auto edge. pr-1 still wins on the
          // right so the thin scrollbar has a gutter.
          <div
            ref={ref}
            data-uipkge=""
            data-slot="board-lane-body"
            className={cn(
              'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-0.5 py-1 pr-1 [scrollbar-width:thin]',
              className,
            )}
            {...props}
          >
            {/* The Vue source wraps cards in a TransitionGroup for enter/leave/move
                animations (the `motion-list` preset). React has no built-in
                equivalent — the per-card transitions live on the card variants
                (transform/box-shadow/opacity). The motion preset class is still
                applied so consumers can target it with their own keyframes. */}
            <div className={cn('relative flex flex-col gap-2', motionName)}>{children}</div>
          </div>
        )
      },
    )
    BoardLaneBody.displayName = 'BoardLaneBody'
    
    /* ------------------------------------------------------------------ */
    /* BoardLaneEmpty                                                      */
    /* ------------------------------------------------------------------ */
    
    export interface BoardLaneEmptyProps extends React.HTMLAttributes<HTMLDivElement> {
      /** Show only when this is true (consumer wires from `lane.length === 0`). */
      when?: boolean
    }
    
    const BoardLaneEmpty = React.forwardRef<HTMLDivElement, BoardLaneEmptyProps>(
      ({ className, when, children, ...props }, ref) => {
        if (when === false) return null
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="board-lane-empty"
            className={cn(
              'text-muted-foreground/70 border-border/60 rounded-lg border border-dashed py-6 text-center text-xs',
              className,
            )}
            {...props}
          >
            {children}
          </div>
        )
      },
    )
    BoardLaneEmpty.displayName = 'BoardLaneEmpty'
    
    /* ------------------------------------------------------------------ */
    /* BoardCard                                                           */
    /* ------------------------------------------------------------------ */
    
    export interface BoardCardProps
      extends Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'onDragStart' | 'onDragEnd' | 'onClick'> {
      id: string
      /** Disable keyboard grab (e.g. for read-only boards). Default: enabled. */
      keyboardDraggable?: boolean
      /** Disable the card entirely — non-draggable, non-clickable, dimmed.
       *  Use for cards locked by a workflow rule or a server policy. */
      disabled?: boolean
      /** Per-card allow-list. When set, the card can only be dropped into
       *  these lane ids; useBoard rejects any other target silently. Omit
       *  to allow every lane the global `accepts` predicate permits. */
      allowedLanes?: readonly string[]
      /** Show the click-to-select chrome (ring + cmd/shift-click multi-select).
       *  Default true. Turn off for read-only or single-tap-to-open boards. */
      selectable?: boolean
      onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void
      onDragEnd?: () => void
      onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
    }
    
    const BoardCard = React.forwardRef<HTMLDivElement, BoardCardProps>(
      (
        {
          id,
          className,
          keyboardDraggable = true,
          disabled = false,
          allowedLanes,
          selectable = true,
          onDragStart,
          onDragEnd,
          onClick,
          children,
          ...props
        },
        ref,
      ) => {
        const board = useBoardContext()
        const lane = useBoardLaneContext()
    
        const [isKeyboardGrabbed, setIsKeyboardGrabbed] = React.useState(false)
    
        const cardElRef = React.useRef<HTMLDivElement | null>(null)
        const setRefs = React.useCallback(
          (node: HTMLDivElement | null) => {
            cardElRef.current = node
            if (typeof ref === 'function') ref(node)
            else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node
          },
          [ref],
        )
    
        // Either source of truth wins — `draggingIds` is the multi-select-aware
        // list, `draggingId` is the single-anchor (backward-compat for consumers
        // that don't pass the multi state through). Keyboard-grabbed adds the
        // same visual without touching parent state.
        const isDragging = board.draggingIds.includes(id) || board.draggingId === id || isKeyboardGrabbed
        const isJustMoved = board.justMovedId === id
        const isSelected = board.selectedIds.has(id)
    
        const cardCtx = React.useMemo(
          () => ({ cardId: id, laneId: lane.laneId, isDragging, isJustMoved, isSelected, disabled }),
          [id, lane.laneId, isDragging, isJustMoved, isSelected, disabled],
        )
    
        // Per-card allowed-lanes registry. Re-run if the id OR the list changes.
        React.useEffect(() => {
          board.registerAllowedLanes(id, allowedLanes)
          return () => board.unregisterAllowedLanes(id)
          // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [id, allowedLanes])
    
        const state: 'idle' | 'dragging' | 'moved' = isDragging ? 'dragging' : isJustMoved ? 'moved' : 'idle'
    
        function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
          if (disabled) return
          // Enter activates the card (default action) — emits onClick for the
          // consumer to open a detail panel / navigate / etc. Native <button>
          // gets this for free; we re-emit because the card root is a
          // <div role="button"> (chosen so consumers can nest <button>/links
          // inside the card without violating "no interactive content in a
          // button").
          if (e.key === 'Enter') {
            e.preventDefault()
            onClick?.(
              new MouseEvent('click', { bubbles: true, cancelable: true }) as unknown as React.MouseEvent<HTMLDivElement>,
            )
            return
          }
          if (!keyboardDraggable) return
          if (e.key === ' ') {
            e.preventDefault()
            setIsKeyboardGrabbed((v) => !v)
            return
          }
          if (e.key === 'Escape' && isKeyboardGrabbed) {
            e.preventDefault()
            setIsKeyboardGrabbed(false)
            return
          }
          if (!isKeyboardGrabbed) return
          if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
            e.preventDefault()
            const allLanes = Array.from(document.querySelectorAll<HTMLElement>('[data-board-lane]')).filter(
              (el) => !el.hasAttribute('data-disabled'),
            )
            const currentIdx = allLanes.findIndex((el) => el.dataset.laneId === lane.laneId)
            if (currentIdx === -1 || allLanes.length === 0) return
            const delta = e.key === 'ArrowLeft' ? -1 : 1
            const nextLane = allLanes[(currentIdx + delta + allLanes.length) % allLanes.length]
            if (nextLane?.dataset.laneId) {
              board.moveItem(id, nextLane.dataset.laneId)
              requestAnimationFrame(() => {
                const moved = document.querySelector<HTMLElement>(`[data-board-card-id="${id}"]`)
                moved?.focus()
              })
            }
            return
          }
          if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
            e.preventDefault()
            const laneEl = cardElRef.current?.closest('[data-board-lane]')
            if (!laneEl) return
            const cards = Array.from(laneEl.querySelectorAll<HTMLElement>('[data-board-card]'))
            const currentIdx = cards.findIndex((el) => el.dataset.boardCardId === id)
            if (currentIdx === -1) return
            const delta = e.key === 'ArrowUp' ? -1 : 1
            const targetIdx = Math.max(0, Math.min(cards.length - 1, currentIdx + delta))
            if (targetIdx !== currentIdx) board.moveItem(id, lane.laneId, targetIdx)
          }
        }
    
        function handleDragStart(e: React.DragEvent<HTMLDivElement>) {
          if (disabled) {
            e.preventDefault()
            return
          }
          onDragStart?.(e)
        }
        function handleDragEnd() {
          onDragEnd?.()
        }
        function handleClick(e: React.MouseEvent<HTMLDivElement>) {
          if (disabled) {
            e.preventDefault()
            return
          }
          // Cmd/Ctrl/Shift+click toggles the selection (multi-select for drag);
          // plain click clears any selection and just emits to the consumer
          // (typically opens a detail Sheet).
          if (selectable && (e.metaKey || e.ctrlKey || e.shiftKey)) {
            e.preventDefault()
            board.toggleSelection(id, true)
            return
          }
          if (board.draggingIds.includes(id)) return
          if (board.selectedIds.size > 0) board.clearSelection()
          onClick?.(e)
        }
    
        return (
          // Root is `<div role="button">` rather than `<button type="button">`
          // so consumers can nest interactive content (buttons, links, menus)
          // inside cards. The HTML5 spec forbids interactive content inside a
          // <button>; browsers silently de-nest the inner element which
          // breaks its events. Enter is wired manually in handleKeyDown to match
          // the native button default-action behaviour.
          <BoardCardContext.Provider value={cardCtx}>
            <div
              ref={setRefs}
              role="button"
              data-uipkge=""
              data-slot="board-card"
              data-board-card=""
              data-board-card-id={id}
              data-state={state}
              data-selected={isSelected || undefined}
              data-disabled={disabled || undefined}
              aria-disabled={disabled || undefined}
              aria-pressed={selectable ? isSelected : undefined}
              className={cn(
                boardCardVariants({ state }),
                isSelected && 'ring-primary/60 ring-offset-background ring-2 ring-offset-1',
                disabled && 'pointer-events-none cursor-not-allowed opacity-50 shadow-none grayscale hover:translate-y-0',
                className,
              )}
              draggable={disabled ? false : true}
              tabIndex={disabled ? -1 : 0}
              aria-roledescription={keyboardDraggable && !disabled ? 'draggable card' : undefined}
              onClick={handleClick}
              onKeyDown={handleKeyDown}
              onDragStart={handleDragStart}
              onDragEnd={handleDragEnd}
              {...props}
            >
              {children}
            </div>
          </BoardCardContext.Provider>
        )
      },
    )
    BoardCard.displayName = 'BoardCard'
    
    export { Board, BoardLane, BoardLaneHeader, BoardLaneBody, BoardLaneEmpty, BoardCard }
  • components/ui/board/board.variants.ts 2 kB
    import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    /**
     * Variant definitions live in their own file so consuming SFCs avoid the
     * circular-dep trap when importing from `index.ts`. Same pattern as
     * `timeline.variants.ts` / `card.variants.ts`.
     */
    
    export const boardLaneVariants = cva(
      'flex min-h-0 flex-col gap-3 rounded-xl border p-3 motion-safe:transition-[background-color,border-color,box-shadow] motion-safe:duration-200 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)]',
      {
        variants: {
          tone: {
            default: 'bg-muted/30',
            plain: 'bg-transparent',
          },
          // Visual weight of `over` and `rejecting` deliberately matches so
          // the eye reads them as the same kind of signal (drop intent),
          // differing only in tone (primary = OK, destructive = NO).
          state: {
            idle: '',
            over: 'border-primary/60 bg-primary/5 ring-2 ring-primary/30 ring-offset-1 ring-offset-background',
            rejecting:
              'border-destructive/40 bg-destructive/5 ring-2 ring-destructive/30 ring-offset-1 ring-offset-background',
          },
        },
        defaultVariants: { tone: 'default', state: 'idle' },
      },
    )
    
    export const boardCardVariants = cva(
      'bg-card hover:border-primary/40 group block w-full cursor-grab rounded-md border p-2 text-left outline-none motion-safe:transition-[transform,box-shadow,opacity,border-color] motion-safe:duration-200 motion-safe:ease-[cubic-bezier(0.16,1,0.3,1)] focus-visible:border-primary/60 focus-visible:ring-2 focus-visible:ring-primary/30 active:cursor-grabbing',
      {
        variants: {
          state: {
            idle: 'shadow-sm motion-safe:hover:-translate-y-0.5 hover:shadow-md',
            dragging: 'scale-95 opacity-40 shadow-none',
            moved: 'shadow-sm ring-2 ring-primary/50 ring-offset-1 ring-offset-background',
          },
        },
        defaultVariants: { state: 'idle' },
      },
    )
    
    export type BoardLaneVariantsProps = VariantProps<typeof boardLaneVariants>
    export type BoardCardVariantsProps = VariantProps<typeof boardCardVariants>
  • components/ui/board/context.ts 3.8 kB
    import * as React from 'react'
    
    export type BoardOrientation = 'horizontal' | 'vertical'
    export type BoardDensity = 'compact' | 'default' | 'comfortable'
    
    /** Drop event emitted by useBoard handlers. */
    export interface BoardDropEvent {
      /** First (anchor) item moved. For multi-item drops, see `itemIds`. */
      itemId: string
      /** All items moved in this drop, in their final visual order. */
      itemIds: string[]
      from: string
      to: string
      /** Insertion index of the first item in the target lane. */
      index: number
    }
    
    /** Predicate consumers pass to control which items each lane accepts. */
    export type BoardAcceptsFn = (itemId: string, fromLaneId: string, toLaneId: string) => boolean
    
    export interface BoardContextValue {
      orientation: BoardOrientation
      density: BoardDensity
      /** Animation preset class name. Defaults to `motion-list` (the @uipkge motion preset). */
      motion: string
      /** Currently-grabbed primary card id (pointer drag OR keyboard grab). */
      draggingId: string | null
      /** All cards being dragged this turn — usually `[draggingId]`, but
       *  expands to the full selection when the user grabs one of a multi-
       *  selected set. Lanes read this to compute drop math (the dragged
       *  cards are excluded from the insertion-index walk). */
      draggingIds: readonly string[]
      /** Lane currently under the pointer/keyboard cursor. */
      dragOverLaneId: string | null
      /** Card that just landed — used by consumers for a momentary highlight. */
      justMovedId: string | null
      /** Multi-select set. Click-and-drag any selected card moves the whole
       *  set; click a non-selected card to drag that one alone. */
      selectedIds: ReadonlySet<string>
      /** Predicate run by lanes; defaults to always-accept. */
      accepts: BoardAcceptsFn
      /** Imperative move — used by keyboard handlers + external callers. */
      moveItem: (itemId: string | string[], toLaneId: string, toIndex?: number) => void
      /** Toggle one item in the selection set (or replace it if `additive` is false). */
      toggleSelection: (itemId: string, additive?: boolean) => void
      /** Drop the entire selection. */
      clearSelection: () => void
      /** Per-card allow-list registry. BoardCard registers its own `allowedLanes`
       *  on mount; lanes consult this to short-circuit rejected drops without
       *  the consumer having to encode the rule inside `accepts`. */
      registerAllowedLanes: (cardId: string, lanes: readonly string[] | undefined) => void
      unregisterAllowedLanes: (cardId: string) => void
      /** Whether the lane's drops are accepted for the dragging item, given
       *  the current `accepts` + per-card allowedLanes + lane disabled state. */
      isLaneAcceptingFor: (laneId: string) => boolean
      /** Lane disabled registry — lanes call register/unregister; useBoard +
       *  isLaneAcceptingFor read from it. */
      registerLaneDisabled: (laneId: string, disabled: boolean) => void
      unregisterLaneDisabled: (laneId: string) => void
    }
    
    export const BoardContext = React.createContext<BoardContextValue | null>(null)
    
    export function useBoardContext(): BoardContextValue {
      const ctx = React.useContext(BoardContext)
      if (!ctx) throw new Error('<BoardCard> / <BoardLane> must be a descendant of <Board>.')
      return ctx
    }
    
    export interface BoardLaneContextValue {
      laneId: string
      isDragOver: boolean
      isAccepting: boolean
      disabled: boolean
    }
    
    export const BoardLaneContext = React.createContext<BoardLaneContextValue | null>(null)
    
    export function useBoardLaneContext(): BoardLaneContextValue {
      const ctx = React.useContext(BoardLaneContext)
      if (!ctx) throw new Error('<BoardCard> must be a descendant of <BoardLane>.')
      return ctx
    }
    
    export interface BoardCardContextValue {
      cardId: string
      laneId: string
      isDragging: boolean
      isJustMoved: boolean
      isSelected: boolean
      disabled: boolean
    }
    
    export const BoardCardContext = React.createContext<BoardCardContextValue | null>(null)
  • components/ui/board/index.ts 0.7 kB
    export {
      Board,
      BoardLane,
      BoardLaneHeader,
      BoardLaneBody,
      BoardLaneEmpty,
      BoardCard,
      type BoardProps,
      type BoardLaneProps,
      type BoardLaneRenderProps,
      type BoardLaneBodyProps,
      type BoardLaneEmptyProps,
      type BoardCardProps,
    } from './board'
    
    export {
      BoardContext,
      BoardLaneContext,
      BoardCardContext,
      useBoardContext,
      useBoardLaneContext,
      type BoardAcceptsFn,
      type BoardContextValue,
      type BoardCardContextValue,
      type BoardDensity,
      type BoardDropEvent,
      type BoardLaneContextValue,
      type BoardOrientation,
    } from './context'
    
    export {
      boardCardVariants,
      boardLaneVariants,
      type BoardCardVariantsProps,
      type BoardLaneVariantsProps,
    } from './board.variants'

Raw manifest: https://react.uipkge.dev/r/react/board.json