Board — compositional kanban / sortable-list primitive
Vue 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; slot `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. State lives in the sibling `useBoard()` hook (insertion-index drop math, keyboard a11y, accept predicate). Animation comes from the `motion-list` motion preset by default — enter / leave / move all share one settle curve so a card travelling between two lanes reads as one continuous motion. Mirrors the Timeline / Card sub-component pattern (three-level context injection: board → lane → card). The current monolithic `@uipkge/kanban-board` block can be rebuilt on top of this primitive — Board is the layer underneath, kanban-board is one opinionated assembly.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/board.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/board.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/board.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/board.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/board
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
tone | 'default''plain' | default | optional |
state | 'idle''over''rejecting' | idle | optional |
class | HTMLAttributes['class'] | — | optional |
orientation | BoardOrientation | 'horizontal' | optional |
density | BoardDensity | 'default' | optional |
motion TransitionGroup name applied by BoardLaneBody. Defaults to `motion-list`. | string | 'motion-list' | optional |
accepts Predicate run per drop. Defaults to always-accept. | BoardAcceptsFn | () => () => true, moveItem: () => () => {}, draggingIds: … | optional |
moveItem Imperative move from a parent's useBoard composable. | (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 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 |
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
} BoardContext interface BoardContext {
orientation: Ref<BoardOrientation>
density: Ref<BoardDensity>
/** TransitionGroup name. Defaults to `motion-list` (the @uipkge motion preset). */
motion: Ref<string>
/** Currently-grabbed primary card id (pointer drag OR keyboard grab). */
draggingId: Ref<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: Ref<readonly string[]>
/** Lane currently under the pointer/keyboard cursor. */
dragOverLaneId: Ref<string | null>
/** Card that just landed — used by consumers for a momentary highlight. */
justMovedId: Ref<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: Ref<ReadonlySet<string>>
/** Predicate run by lanes; defaults to always-accept. */
accepts: Ref<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: Ref<symbol[]>
registerLane: (id: symbol) => void
unregisterLane: (id: symbol) => void
/** Lane disabled registry — lanes call register/unregister; useBoard +
* isLaneAcceptingFor read from it. */
registerLaneDisabled: (laneId: string, disabled: boolean) => void
unregisterLaneDisabled: (laneId: string) => void
} BoardLaneContext interface BoardLaneContext {
laneId: Ref<string>
isDragOver: Ref<boolean>
isAccepting: Ref<boolean>
disabled: Ref<boolean>
} BoardCardContext interface BoardCardContext {
cardId: Ref<string>
laneId: Ref<string>
isDragging: Ref<boolean>
isJustMoved: Ref<boolean>
isSelected: Ref<boolean>
disabled: Ref<boolean>
} Dependencies
Used by
Files (9)
-
app/components/ui/board/Board.vue 3.8 kB
<script setup lang="ts"> import { provide, ref, toRef, type Ref } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { BOARD_CONTEXT, type BoardAcceptsFn, type BoardDensity, type BoardOrientation } from './context' interface Props { class?: HTMLAttributes['class'] orientation?: BoardOrientation density?: BoardDensity /** TransitionGroup 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 useBoard composable. */ 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 } const props = withDefaults(defineProps<Props>(), { orientation: 'horizontal', density: 'default', motion: 'motion-list', accepts: () => () => true, moveItem: () => () => {}, draggingIds: () => [] as readonly string[], selectedIds: () => new Set<string>() as ReadonlySet<string>, toggleSelection: () => () => {}, clearSelection: () => () => {}, registerAllowedLanes: () => () => {}, unregisterAllowedLanes: () => () => {}, registerLaneDisabled: () => () => {}, unregisterLaneDisabled: () => () => {}, isLaneAcceptingFor: () => () => true, }) const draggingIdRef = toRef(props, 'draggingId') const draggingIdsRef = toRef(props, 'draggingIds') const dragOverLaneIdRef = toRef(props, 'dragOverLaneId') const justMovedIdRef = toRef(props, 'justMovedId') const selectedIdsRef = toRef(props, 'selectedIds') const laneIds = ref<symbol[]>([]) provide(BOARD_CONTEXT, { orientation: toRef(props, 'orientation'), density: toRef(props, 'density'), motion: toRef(props, 'motion'), draggingId: draggingIdRef as unknown as Ref<string | null>, draggingIds: draggingIdsRef as unknown as Ref<readonly string[]>, dragOverLaneId: dragOverLaneIdRef as unknown as Ref<string | null>, justMovedId: justMovedIdRef as unknown as Ref<string | null>, selectedIds: selectedIdsRef as unknown as Ref<ReadonlySet<string>>, accepts: toRef(props, 'accepts'), moveItem: (...args) => props.moveItem!(...args), toggleSelection: (...args) => props.toggleSelection!(...args), clearSelection: () => props.clearSelection!(), registerAllowedLanes: (...args) => props.registerAllowedLanes!(...args), unregisterAllowedLanes: (...args) => props.unregisterAllowedLanes!(...args), registerLaneDisabled: (...args) => props.registerLaneDisabled!(...args), unregisterLaneDisabled: (...args) => props.unregisterLaneDisabled!(...args), isLaneAcceptingFor: (laneId) => props.isLaneAcceptingFor!(laneId), laneIds, registerLane: (id) => { if (!laneIds.value.includes(id)) laneIds.value.push(id) }, unregisterLane: (id) => { laneIds.value = laneIds.value.filter((i) => i !== id) }, }) </script> <template> <div data-uipkge data-slot="board" :data-orientation="orientation" :class="cn('w-full', props.class)"> <slot /> </div> </template> -
app/components/ui/board/BoardLane.vue 3.2 kB
<script setup lang="ts"> import { computed, inject, onBeforeUnmount, onMounted, provide, toRef, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { BOARD_CONTEXT, BOARD_LANE_CONTEXT } from './context' import { boardLaneVariants } from './board.variants' interface Props { id: string class?: HTMLAttributes['class'] 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 } const props = withDefaults(defineProps<Props>(), { tone: 'default', disabled: false }) const board = inject(BOARD_CONTEXT, null) if (!board) { throw new Error('<BoardLane> must be a descendant of <Board>.') } const _laneSymbol = Symbol('BoardLane') onMounted(() => { board.registerLane(_laneSymbol) board.registerLaneDisabled(props.id, props.disabled) }) onBeforeUnmount(() => { board.unregisterLane(_laneSymbol) board.unregisterLaneDisabled(props.id) }) // Keep the disabled registry in sync when the prop changes mid-flight. watch( () => props.disabled, (v) => board.registerLaneDisabled(props.id, v), ) watch( () => props.id, (newId, oldId) => { board.unregisterLaneDisabled(oldId) board.registerLaneDisabled(newId, props.disabled) }, ) const isDragOver = computed(() => board.dragOverLaneId.value === props.id) const isAccepting = computed(() => { if (!board.draggingId.value) return false if (props.disabled) return false return board.isLaneAcceptingFor(props.id) }) provide(BOARD_LANE_CONTEXT, { laneId: toRef(props, 'id'), isDragOver, isAccepting, disabled: toRef(props, 'disabled'), }) const state = computed<'idle' | 'over' | 'rejecting'>(() => { if (!isDragOver.value) return 'idle' return isAccepting.value ? 'over' : 'rejecting' }) const emits = defineEmits<{ dragover: [e: DragEvent] drop: [e: DragEvent] dragleave: [] }>() function onDragOver(e: DragEvent) { // Emit unconditionally so the composable can set dragOverLaneId // (drives the rejecting-ring visual). The composable'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. emits('dragover', e) } function onDrop(e: DragEvent) { if (props.disabled) { e.preventDefault() return } emits('drop', e) } function onDragLeave() { emits('dragleave') } </script> <template> <div 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', props.class, ) " @dragover="onDragOver" @drop="onDrop" @dragleave="onDragLeave" > <slot :is-drag-over="isDragOver" :is-accepting="isAccepting" :disabled="disabled" /> </div> </template> -
app/components/ui/board/BoardLaneHeader.vue 0.3 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] }>() </script> <template> <div data-uipkge data-slot="board-lane-header" :class="cn('flex items-center justify-between gap-2', props.class)"> <slot /> </div> </template> -
app/components/ui/board/BoardLaneBody.vue 1.7 kB
<script setup lang="ts"> import { inject } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { BOARD_CONTEXT } from './context' interface Props { class?: HTMLAttributes['class'] /** Override the TransitionGroup name. Defaults to the board-level motion preset. */ motion?: string } const props = defineProps<Props>() const board = inject(BOARD_CONTEXT, null) const motionName = () => props.motion ?? board?.motion.value ?? 'motion-list' </script> <template> <!-- 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 data-uipkge data-slot="board-lane-body" :class=" cn('flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-0.5 py-1 pr-1 [scrollbar-width:thin]', props.class) " > <!-- ClientOnly: Vue 3 TransitionGroup emits Fragment vnodes on the server and proper DOM elements on the client, which trips a hydration mismatch on every card slot. Render a plain div on SSR (same shape, no animations — which the client can't see pre-hydration anyway) and swap to TransitionGroup on mount. --> <ClientOnly> <TransitionGroup :name="motionName()" tag="div" class="relative flex flex-col gap-2"> <slot /> </TransitionGroup> <template #fallback> <div class="relative flex flex-col gap-2"> <slot /> </div> </template> </ClientOnly> </div> </template> -
app/components/ui/board/BoardLaneEmpty.vue 0.6 kB
<script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = defineProps<{ class?: HTMLAttributes['class'] /** Show only when this is true (consumer wires from `lane.length === 0`). */ when?: boolean }>() </script> <template> <div v-if="when !== false" 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', props.class, ) " > <slot /> </div> </template> -
app/components/ui/board/BoardCard.vue 7.3 kB
<script setup lang="ts"> import { computed, inject, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { BOARD_CARD_CONTEXT, BOARD_CONTEXT, BOARD_LANE_CONTEXT } from './context' import { boardCardVariants } from './board.variants' interface Props { id: string class?: HTMLAttributes['class'] /** 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 } const props = withDefaults(defineProps<Props>(), { keyboardDraggable: true, disabled: false, selectable: true, }) const board = inject(BOARD_CONTEXT, null) const lane = inject(BOARD_LANE_CONTEXT, null) if (!board || !lane) { throw new Error('<BoardCard> must be a descendant of <Board> and <BoardLane>.') } const emits = defineEmits<{ dragstart: [e: DragEvent] dragend: [] click: [e: MouseEvent] }>() const isKeyboardGrabbed = ref(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 = computed( () => board.draggingIds.value.includes(props.id) || board.draggingId.value === props.id || isKeyboardGrabbed.value, ) const isJustMoved = computed(() => board.justMovedId.value === props.id) const isSelected = computed(() => board.selectedIds.value.has(props.id)) provide(BOARD_CARD_CONTEXT, { cardId: toRef(props, 'id'), laneId: lane.laneId, isDragging, isJustMoved, isSelected, disabled: toRef(props, 'disabled'), }) // Per-card allowed-lanes registry. Re-run if the id OR the list changes. onMounted(() => board.registerAllowedLanes(props.id, props.allowedLanes)) onBeforeUnmount(() => board.unregisterAllowedLanes(props.id)) watch( () => [props.id, props.allowedLanes] as const, ([id, lanes], _prev) => { const prevId = _prev?.[0] if (prevId && prevId !== id) board.unregisterAllowedLanes(prevId) board.registerAllowedLanes(id, lanes) }, { deep: true }, ) const state = computed<'idle' | 'dragging' | 'moved'>(() => { if (isDragging.value) return 'dragging' if (isJustMoved.value) return 'moved' return 'idle' }) const cardEl = ref<HTMLButtonElement | null>(null) function onKeydown(e: KeyboardEvent) { if (props.disabled) return // Enter activates the card (default action) — emits @click 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 @click slot. if (e.key === 'Enter') { e.preventDefault() emits('click', new MouseEvent('click', { bubbles: true, cancelable: true })) return } if (!props.keyboardDraggable) return if (e.key === ' ') { e.preventDefault() isKeyboardGrabbed.value = !isKeyboardGrabbed.value return } if (e.key === 'Escape' && isKeyboardGrabbed.value) { e.preventDefault() isKeyboardGrabbed.value = false return } if (!isKeyboardGrabbed.value) 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.value) 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(props.id, nextLane.dataset.laneId) requestAnimationFrame(() => { const moved = document.querySelector<HTMLElement>(`[data-board-card-id="${props.id}"]`) moved?.focus() }) } return } if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault() const laneEl = cardEl.value?.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 === props.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(props.id, lane.laneId.value, targetIdx) } } function onDragStart(e: DragEvent) { if (props.disabled) { e.preventDefault() return } emits('dragstart', e) } function onDragEnd() { emits('dragend') } function onClick(e: MouseEvent) { if (props.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 (props.selectable && (e.metaKey || e.ctrlKey || e.shiftKey)) { e.preventDefault() board.toggleSelection(props.id, true) return } if (board.draggingIds.value.includes(props.id)) return if (board.selectedIds.value.size > 0) board.clearSelection() emits('click', e) } </script> <template> <!-- 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 ref="cardEl" 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" :class=" 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', props.class, ) " :draggable="disabled ? false : true" :tabindex="disabled ? -1 : 0" :aria-roledescription="keyboardDraggable && !disabled ? 'draggable card' : undefined" @click="onClick" @keydown="onKeydown" @dragstart="onDragStart" @dragend="onDragEnd" > <slot /> </div> </template> -
app/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> -
app/components/ui/board/context.ts 3.6 kB
import type { InjectionKey, Ref } from 'vue' 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 BoardContext { orientation: Ref<BoardOrientation> density: Ref<BoardDensity> /** TransitionGroup name. Defaults to `motion-list` (the @uipkge motion preset). */ motion: Ref<string> /** Currently-grabbed primary card id (pointer drag OR keyboard grab). */ draggingId: Ref<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: Ref<readonly string[]> /** Lane currently under the pointer/keyboard cursor. */ dragOverLaneId: Ref<string | null> /** Card that just landed — used by consumers for a momentary highlight. */ justMovedId: Ref<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: Ref<ReadonlySet<string>> /** Predicate run by lanes; defaults to always-accept. */ accepts: Ref<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: Ref<symbol[]> registerLane: (id: symbol) => void unregisterLane: (id: symbol) => void /** Lane disabled registry — lanes call register/unregister; useBoard + * isLaneAcceptingFor read from it. */ registerLaneDisabled: (laneId: string, disabled: boolean) => void unregisterLaneDisabled: (laneId: string) => void } export const BOARD_CONTEXT: InjectionKey<BoardContext> = Symbol('BoardContext') export interface BoardLaneContext { laneId: Ref<string> isDragOver: Ref<boolean> isAccepting: Ref<boolean> disabled: Ref<boolean> } export const BOARD_LANE_CONTEXT: InjectionKey<BoardLaneContext> = Symbol('BoardLaneContext') export interface BoardCardContext { cardId: Ref<string> laneId: Ref<string> isDragging: Ref<boolean> isJustMoved: Ref<boolean> isSelected: Ref<boolean> disabled: Ref<boolean> } export const BOARD_CARD_CONTEXT: InjectionKey<BoardCardContext> = Symbol('BoardCardContext') -
app/components/ui/board/index.ts 0.7 kB
export { default as Board } from './Board.vue' export { default as BoardLane } from './BoardLane.vue' export { default as BoardLaneHeader } from './BoardLaneHeader.vue' export { default as BoardLaneBody } from './BoardLaneBody.vue' export { default as BoardLaneEmpty } from './BoardLaneEmpty.vue' export { default as BoardCard } from './BoardCard.vue' export { BOARD_CONTEXT, BOARD_LANE_CONTEXT, BOARD_CARD_CONTEXT, type BoardAcceptsFn, type BoardContext, type BoardCardContext, type BoardDensity, type BoardDropEvent, type BoardLaneContext, type BoardOrientation, } from './context' export { boardCardVariants, boardLaneVariants, type BoardCardVariantsProps, type BoardLaneVariantsProps, } from './board.variants'
Raw manifest: https://uipkge.dev/r/vue/board.json