Board — compositional kanban / sortable-list primitive
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; slot `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. State lives in a sibling board state helper (insertion-index drop math, keyboard a11y, accept predicate). Mirrors the Timeline / Card sub-component pattern (three-level context: board → lane → card).
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/board.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/board.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/board.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/board.jsonhttps://uipkge.dev/r/svelte/board.jsonInstalls to:src/lib/components/ui/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 |
motionTransition name 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 board state helper. | (itemId: string | string[], toLaneId: string, toIndex?: number) => void | — | optional |
draggingIdExternal state — pass `state.draggingId` etc. from the board state helper. | string | null | — | optional |
draggingIds | readonly string[] | — | optional |
dragOverLaneId | string | null | — | optional |
justMovedId | string | null | — | optional |
selectedIds | ReadonlySet<string> | — | optional |
toggleSelectionprimitive can expose selection mutations through context (consumed by the 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 |
refThe root <div>, via `bind:ref`. | HTMLDivElement | null | — | optional |
Schema
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
}BoardStateinterface BoardState {
draggingId?: string | null
draggingIds?: readonly string[]
dragOverLaneId?: string | null
justMovedId?: string | null
selectedIds?: ReadonlySet<string>
}BoardContextinterface BoardContext {
orientation: BoardOrientation
density: BoardDensity
/** Transition name for lane bodies. 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 registration (mirrors Timeline pattern). */
laneIds: symbol[]
registerLane: (id: symbol) => void
unregisterLane: (id: symbol) => void
/** Lane disabled registry — lanes call register/unregister; board state
* helpers + isLaneAcceptingFor read from it. */
registerLaneDisabled: (laneId: string, disabled: boolean) => void
unregisterLaneDisabled: (laneId: string) => void
}BoardLaneContextinterface BoardLaneContext {
laneId: string
isDragOver: boolean
isAccepting: boolean
disabled: boolean
}BoardCardContextinterface BoardCardContext {
cardId: string
laneId: string
isDragging: boolean
isJustMoved: boolean
isSelected: boolean
disabled: boolean
}npm dependencies
Files installed (9)
src/lib/components/ui/board/Board.svelte4.7 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { BoardAcceptsFn, BoardDensity, BoardOrientation, BoardState } from './context' export interface BoardProps extends HTMLAttributes<HTMLDivElement> { /** * External state snapshot (`draggingId` / `draggingIds` / `dragOverLaneId` / * `justMovedId` / `selectedIds`) — the Svelte equivalent of spreading * React `useBoard`'s `state` into `<Board>`. Read-only: the board never * writes back (mutations flow through `moveItem` / `toggleSelection`). * When both are given, `state` fields win over the individual props below. */ state?: BoardState orientation?: BoardOrientation density?: BoardDensity /** Transition name 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 board state helper. */ moveItem?: (itemId: string | string[], toLaneId: string, toIndex?: number) => void /** External state — pass `state.draggingId` etc. from the board state helper. */ draggingId?: string | null draggingIds?: readonly string[] dragOverLaneId?: string | null justMovedId?: string | null selectedIds?: ReadonlySet<string> /** Optional toggle/clearSelection from the board state helper so the * primitive can expose selection mutations through context (consumed * by the 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 /** The root <div>, via `bind:ref`. */ ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setContext } from 'svelte' import { cn } from '$lib/utils' import { BOARD_CONTEXT, type BoardContext } from './context' // NOTE: the public prop is `state`, but it is destructured as `boardState` // — a local named `state` would shadow the `$state` rune used below. let { class: className, state: boardState, orientation = 'horizontal', density = 'default', motion = 'motion-list', accepts = () => true, moveItem = () => {}, draggingId = null, draggingIds = [], dragOverLaneId = null, justMovedId = null, selectedIds = new Set<string>(), toggleSelection = () => {}, clearSelection = () => {}, registerAllowedLanes = () => {}, unregisterAllowedLanes = () => {}, registerLaneDisabled = () => {}, unregisterLaneDisabled = () => {}, isLaneAcceptingFor = () => true, children, ref = $bindable(null), ...restProps }: BoardProps = $props() let laneIds = $state<symbol[]>([]) // Getters keep the context reactive: children reading these inside // `$derived` re-run when the parent's props change. setContext<BoardContext>(BOARD_CONTEXT, { get orientation() { return orientation }, get density() { return density }, get motion() { return motion }, get draggingId() { return boardState?.draggingId ?? draggingId }, get draggingIds() { return boardState?.draggingIds ?? draggingIds }, get dragOverLaneId() { return boardState?.dragOverLaneId ?? dragOverLaneId }, get justMovedId() { return boardState?.justMovedId ?? justMovedId }, get selectedIds() { return boardState?.selectedIds ?? selectedIds }, get accepts() { return accepts }, moveItem: (...args) => moveItem(...args), toggleSelection: (...args) => toggleSelection(...args), clearSelection: () => clearSelection(), registerAllowedLanes: (...args) => registerAllowedLanes(...args), unregisterAllowedLanes: (...args) => unregisterAllowedLanes(...args), registerLaneDisabled: (...args) => registerLaneDisabled(...args), unregisterLaneDisabled: (...args) => unregisterLaneDisabled(...args), isLaneAcceptingFor: (laneId) => isLaneAcceptingFor(laneId), get laneIds() { return laneIds }, registerLane: (id) => { if (!laneIds.includes(id)) laneIds.push(id) }, unregisterLane: (id) => { laneIds = laneIds.filter((i) => i !== id) }, }) </script> <div bind:this={ref} data-uipkge="" data-slot="board" data-orientation={orientation} class={cn('w-full', className)} {...restProps}> {@render children?.()} </div>src/lib/components/ui/board/BoardLane.svelte4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { BoardLaneVariantsProps } from './board.variants' export interface BoardLaneSlotProps { isDragOver: boolean isAccepting: boolean disabled: boolean } export interface BoardLaneProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'ondragover' | 'ondrop' | 'ondragleave'> { id: string tone?: BoardLaneVariantsProps['tone'] /** Disable drops on this lane. Cards inside still render and stay * draggable; only the drop target is inert + visually dimmed. */ disabled?: boolean children?: Snippet<[BoardLaneSlotProps]> /** The lane <div>, via `bind:ref`. */ ref?: HTMLDivElement | null ondragover?: (e: DragEvent) => void ondrop?: (e: DragEvent) => void ondragleave?: () => void } </script> <script lang="ts"> import { getContext, onMount, setContext } from 'svelte' import { cn } from '$lib/utils' import { BOARD_CONTEXT, BOARD_LANE_CONTEXT, type BoardContext, type BoardLaneContext } from './context' import { boardLaneVariants } from './board.variants' let { class: className, id, tone = 'default', disabled = false, children, ref = $bindable(null), ondragover, ondrop, ondragleave, ...restProps }: BoardLaneProps = $props() const boardContext = getContext<BoardContext | null>(BOARD_CONTEXT) if (!boardContext) { throw new Error('<BoardLane> must be a descendant of <Board>.') } // Non-nullable alias: the throw above proves presence, and a plain const // keeps the narrowing inside every closure below. const board: BoardContext = boardContext const laneSymbol = Symbol('BoardLane') onMount(() => { board.registerLane(laneSymbol) return () => board.unregisterLane(laneSymbol) }) // Keep the disabled registry in sync when the id/disabled props change. let prevId = id $effect(() => { if (prevId !== id) { board.unregisterLaneDisabled(prevId) prevId = id } board.registerLaneDisabled(id, disabled) }) onMount(() => { return () => board.unregisterLaneDisabled(prevId) }) const isDragOver = $derived(board.dragOverLaneId === id) const isAccepting = $derived.by(() => { if (!board.draggingId) return false if (disabled) return false return board.isLaneAcceptingFor(id) }) setContext<BoardLaneContext>(BOARD_LANE_CONTEXT, { get laneId() { return id }, get isDragOver() { return isDragOver }, get isAccepting() { return isAccepting }, get disabled() { return disabled }, }) const state = $derived.by<'idle' | 'over' | 'rejecting'>(() => { if (!isDragOver) return 'idle' return isAccepting ? 'over' : 'rejecting' }) function onDragOver(e: DragEvent) { // Call unconditionally so the state helper can set dragOverLaneId // (drives the rejecting-ring visual). The helper'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. ondragover?.(e) } function onDrop(e: DragEvent) { if (disabled) { e.preventDefault() return } ondrop?.(e) } function onDragLeave() { ondragleave?.() } </script> <div bind:this={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} class={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={onDragOver} ondrop={onDrop} ondragleave={onDragLeave} {...restProps} > {@render children?.({ isDragOver, isAccepting, disabled })} </div>src/lib/components/ui/board/BoardLaneHeader.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface BoardLaneHeaderProps extends HTMLAttributes<HTMLDivElement> { /** The header <div>, via `bind:ref`. */ ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: BoardLaneHeaderProps = $props() </script> <div bind:this={ref} data-uipkge="" data-slot="board-lane-header" class={cn('flex items-center justify-between gap-2', className)} {...restProps}> {@render children?.()} </div>src/lib/components/ui/board/BoardLaneBody.svelte1.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface BoardLaneBodyProps extends HTMLAttributes<HTMLDivElement> { /** Override the transition name. Defaults to the board-level motion preset. */ motion?: string /** The body <div>, via `bind:ref`. */ ref?: HTMLDivElement | null } </script> <script lang="ts"> import { getContext } from 'svelte' import { cn } from '$lib/utils' import { BOARD_CONTEXT, type BoardContext } from './context' let { class: className, motion = undefined, children, ref = $bindable(null), ...restProps }: BoardLaneBodyProps = $props() const board = getContext<BoardContext | null>(BOARD_CONTEXT) const motionName = $derived(motion ?? board?.motion ?? 'motion-list') </script> <!-- 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. NOTE: the Vue twin wraps the slot in a TransitionGroup for FLIP reorder animation. Slotted children can't carry Svelte's `animate:flip`, so reorder motion comes from the card-level CSS transitions only; `motion` is kept for API parity and exposed as a data attribute for consumer CSS hooks. --> <div bind:this={ref} data-uipkge="" data-slot="board-lane-body" data-motion={motionName} class={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)} {...restProps} > <div class="relative flex flex-col gap-2"> {@render children?.()} </div> </div>src/lib/components/ui/board/BoardLaneEmpty.svelte0.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface BoardLaneEmptyProps extends HTMLAttributes<HTMLDivElement> { /** Show only when this is true (consumer wires from `lane.length === 0`). */ when?: boolean /** The empty-state <div>, via `bind:ref`. */ ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, when = undefined, children, ref = $bindable(null), ...restProps }: BoardLaneEmptyProps = $props() </script> {#if when !== false} <div bind:this={ref} data-uipkge="" data-slot="board-lane-empty" class={cn( 'text-muted-foreground/70 border-border/60 rounded-lg border border-dashed py-6 text-center text-xs', className, )} {...restProps} > {@render children?.()} </div> {/if}src/lib/components/ui/board/BoardCard.svelte8.3 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface BoardCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onclick' | 'onkeydown' | 'ondragstart' | 'ondragend'> { 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; the state helper 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 /** The card <div>, via `bind:ref`. */ ref?: HTMLDivElement | null /** Merged with the internal handlers (selection, Enter-to-activate). */ onclick?: (e: MouseEvent) => void onkeydown?: (e: KeyboardEvent) => void ondragstart?: (e: DragEvent) => void ondragend?: () => void } </script> <script lang="ts"> import { getContext, onMount, setContext } from 'svelte' import { cn } from '$lib/utils' import { BOARD_CARD_CONTEXT, BOARD_CONTEXT, BOARD_LANE_CONTEXT } from './context' import type { BoardCardContext, BoardContext, BoardLaneContext } from './context' import { boardCardVariants } from './board.variants' let { class: className, id, keyboardDraggable = true, disabled = false, allowedLanes = undefined, selectable = true, children, ref = $bindable(null), onclick, onkeydown, ondragstart, ondragend, ...restProps }: BoardCardProps = $props() const boardContext = getContext<BoardContext | null>(BOARD_CONTEXT) const laneContext = getContext<BoardLaneContext | null>(BOARD_LANE_CONTEXT) if (!boardContext || !laneContext) { throw new Error('<BoardCard> must be a descendant of <Board> and <BoardLane>.') } // Non-nullable aliases: the throw above proves presence, and plain consts // keep the narrowing inside every closure below. const board: BoardContext = boardContext const lane: BoardLaneContext = laneContext let isKeyboardGrabbed = $state(false) // 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 = $derived( board.draggingIds.includes(id) || board.draggingId === id || isKeyboardGrabbed, ) const isJustMoved = $derived(board.justMovedId === id) const isSelected = $derived(board.selectedIds.has(id)) setContext<BoardCardContext>(BOARD_CARD_CONTEXT, { get cardId() { return id }, get laneId() { return lane.laneId }, get isDragging() { return isDragging }, get isJustMoved() { return isJustMoved }, get isSelected() { return isSelected }, get disabled() { return disabled }, }) // Per-card allowed-lanes registry. Re-run if the id OR the list changes. onMount(() => { board.registerAllowedLanes(id, allowedLanes) return () => board.unregisterAllowedLanes(prevCardId) }) let prevCardId = id $effect(() => { if (prevCardId !== id) { board.unregisterAllowedLanes(prevCardId) prevCardId = id } board.registerAllowedLanes(id, allowedLanes) }) const cardState = $derived.by<'idle' | 'dragging' | 'moved'>(() => { if (isDragging) return 'dragging' if (isJustMoved) return 'moved' return 'idle' }) function onKeydown(e: KeyboardEvent) { if (!disabled) { // Enter activates the card (default action) — calls 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" — see commit context). The event still surfaces through // the standard onclick handler. if (e.key === 'Enter') { e.preventDefault() onclick?.(new MouseEvent('click', { bubbles: true, cancelable: true })) } else if (keyboardDraggable) { if (e.key === ' ') { e.preventDefault() isKeyboardGrabbed = !isKeyboardGrabbed } else if (e.key === 'Escape' && isKeyboardGrabbed) { e.preventDefault() isKeyboardGrabbed = false } else if (isKeyboardGrabbed && (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) { 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() }) } } } else if (isKeyboardGrabbed && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault() const laneEl = ref?.closest('[data-board-lane]') if (laneEl) { const cards = Array.from(laneEl.querySelectorAll<HTMLElement>('[data-board-card]')) const currentIdx = cards.findIndex((el) => el.dataset.boardCardId === id) if (currentIdx !== -1) { 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) } } } } } onkeydown?.(e) } function onDragStart(e: DragEvent) { if (disabled) { e.preventDefault() return } ondragstart?.(e) } function onDragEnd() { ondragend?.() } function onClick(e: MouseEvent) { if (disabled) { e.preventDefault() return } // Cmd/Ctrl/Shift+click toggles the selection (multi-select for drag); // plain click clears any selection and just calls through 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) } </script> <!-- 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 onKeydown to match the native button default-action behaviour. --> <div bind:this={ref} role="button" data-uipkge="" data-slot="board-card" data-board-card="" data-board-card-id={id} data-state={cardState} data-selected={isSelected || undefined} data-disabled={disabled || undefined} aria-disabled={disabled || undefined} aria-pressed={selectable ? isSelected : undefined} class={cn( boardCardVariants({ state: cardState }), 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={onClick} onkeydown={onKeydown} ondragstart={onDragStart} ondragend={onDragEnd} {...restProps} > {@render children?.()} </div>src/lib/components/ui/board/board.variants.ts2 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-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>src/lib/components/ui/board/context.ts5.4 kB
import { getContext } from 'svelte' export type BoardOrientation = 'horizontal' | 'vertical' export type BoardDensity = 'compact' | 'default' | 'comfortable' /** Drop event emitted by board state helpers (see the `use-board` hook twin). */ 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 /** * External state snapshot — mirrors React `useBoard`'s `state` * (`UseBoardState`). Pass the whole object as `<Board state={...}>` instead * of threading the five fields as individual props. */ export interface BoardState { draggingId?: string | null draggingIds?: readonly string[] dragOverLaneId?: string | null justMovedId?: string | null selectedIds?: ReadonlySet<string> } export interface BoardContext { orientation: BoardOrientation density: BoardDensity /** Transition name for lane bodies. 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 registration (mirrors Timeline pattern). */ laneIds: symbol[] registerLane: (id: symbol) => void unregisterLane: (id: symbol) => void /** Lane disabled registry — lanes call register/unregister; board state * helpers + isLaneAcceptingFor read from it. */ registerLaneDisabled: (laneId: string, disabled: boolean) => void unregisterLaneDisabled: (laneId: string) => void } export const BOARD_CONTEXT = Symbol('BoardContext') /** React-parity alias: React names this interface `BoardContextValue`. */ export type BoardContextValue = BoardContext export interface BoardLaneContext { laneId: string isDragOver: boolean isAccepting: boolean disabled: boolean } export const BOARD_LANE_CONTEXT = Symbol('BoardLaneContext') /** React-parity alias: React names this interface `BoardLaneContextValue`. */ export type BoardLaneContextValue = BoardLaneContext export interface BoardCardContext { cardId: string laneId: string isDragging: boolean isJustMoved: boolean isSelected: boolean disabled: boolean } export const BOARD_CARD_CONTEXT = Symbol('BoardCardContext') /** React-parity alias: React names this interface `BoardCardContextValue`. */ export type BoardCardContextValue = BoardCardContext // --------------------------------------------------------------------------- // Context helpers (Svelte-idiomatic `get*` twins of React's `useBoard*` hooks) // --------------------------------------------------------------------------- /** Read the board context. Throws outside `<Board>` — mirrors React's `useBoardContext`. */ export function getBoardContext(): BoardContext { const ctx = getContext<BoardContext | null>(BOARD_CONTEXT) if (!ctx) throw new Error('<BoardCard> / <BoardLane> must be a descendant of <Board>.') return ctx } /** Read the lane context. Throws outside `<BoardLane>` — mirrors React's `useBoardLaneContext`. */ export function getBoardLaneContext(): BoardLaneContext { const ctx = getContext<BoardLaneContext | null>(BOARD_LANE_CONTEXT) if (!ctx) throw new Error('<BoardCard> must be a descendant of <BoardLane>.') return ctx } /** Read the card context. Throws outside `<BoardCard>`. */ export function getBoardCardContext(): BoardCardContext { const ctx = getContext<BoardCardContext | null>(BOARD_CARD_CONTEXT) if (!ctx) throw new Error('Board card parts must be a descendant of <BoardCard>.') return ctx }src/lib/components/ui/board/index.ts1.1 kB
export { default as Board, type BoardProps } from './Board.svelte' export { default as BoardLane, type BoardLaneProps, type BoardLaneSlotProps } from './BoardLane.svelte' export { default as BoardLaneHeader, type BoardLaneHeaderProps } from './BoardLaneHeader.svelte' export { default as BoardLaneBody, type BoardLaneBodyProps } from './BoardLaneBody.svelte' export { default as BoardLaneEmpty, type BoardLaneEmptyProps } from './BoardLaneEmpty.svelte' export { default as BoardCard, type BoardCardProps } from './BoardCard.svelte' export { BOARD_CONTEXT, BOARD_LANE_CONTEXT, BOARD_CARD_CONTEXT, getBoardContext, getBoardLaneContext, getBoardCardContext, type BoardAcceptsFn, type BoardContext, type BoardContextValue, type BoardCardContext, type BoardCardContextValue, type BoardDensity, type BoardDropEvent, type BoardLaneContext, type BoardLaneContextValue, type BoardOrientation, type BoardState, } from './context' export { boardCardVariants, boardLaneVariants, type BoardCardVariantsProps, type BoardLaneVariantsProps, } from './board.variants'
Raw manifest:https://uipkge.dev/r/svelte/board.json