
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
boarduiSix 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 ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/board.json$npx shadcn@latest add https://uipkge.dev/r/react/board.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/board.json$bunx shadcn@latest add https://uipkge.dev/r/react/board.jsonnpx shadcn@latest add @uipkge-react/boardInstalls to:components/ui/board/| Name | Type / Values | Default | Required |
|---|---|---|---|
tone | 'default''plain' | default | optional |
state | 'idle''over''rejecting' | idle | optional |
orientation | BoardOrientation | horizontal | optional |
density | BoardDensity | default | optional |
motionAnimation preset class applied by BoardLaneBody. Defaults to `motion-list`. | string | motion-list | optional |
acceptsPredicate run per drop. Defaults to always-accept. | BoardAcceptsFn | — | optional |
moveItemImperative move from a parent's useBoard hook. | (itemId: string | string[], toLaneId: string, toIndex?: number) => void | — | optional |
draggingIdExternal 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 |
toggleSelectionexpose 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 |
Type aliases from this item's source — use them to shape the data you pass in.
BoardDropEventinterface 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
}BoardContextValueinterface 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
}BoardLaneContextValueinterface BoardLaneContextValue {
laneId: string
isDragOver: boolean
isAccepting: boolean
disabled: boolean
}BoardCardContextValueinterface BoardCardContextValue {
cardId: string
laneId: string
isDragging: boolean
isJustMoved: boolean
isSelected: boolean
disabled: boolean
}'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 [scrollbar-width:thin] flex-col gap-2 overflow-y-auto px-0.5 py-1 pr-1',
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 }
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-1 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>
'use client'
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)
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://uipkge.dev/r/react/board.json