Board — compositional kanban / sortable-list primitive
React dataSix 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
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/board.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/board.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/board.json$ bunx 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