
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; 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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/boardInstalls to:app/components/ui/board/| 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 |
motionTransitionGroup name applied by BoardLaneBody. Defaults to `motion-list`. | string | 'motion-list' | optional |
acceptsPredicate run per drop. Defaults to always-accept. | BoardAcceptsFn | () => () => true, moveItem: () => () => {}, draggingIds: … | optional |
moveItemImperative move from a parent's useBoard composable. | (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 |
toggleSelectionconsumers 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 |
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
}BoardContextinterface 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
}BoardLaneContextinterface BoardLaneContext {
laneId: Ref<string>
isDragOver: Ref<boolean>
isAccepting: Ref<boolean>
disabled: Ref<boolean>
}BoardCardContextinterface BoardCardContext {
cardId: Ref<string>
laneId: Ref<string>
isDragging: Ref<boolean>
isJustMoved: Ref<boolean>
isSelected: Ref<boolean>
disabled: Ref<boolean>
}<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>
<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>
<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>
<script setup lang="ts">
import { inject, onMounted, ref } 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'
// Framework-agnostic mount gate (replaces Nuxt-only <ClientOnly>). Vue 3
// TransitionGroup can emit Fragment vnodes during SSR and real elements on the
// client, which trips hydration mismatches. Render a plain div until mounted.
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
</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 [scrollbar-width:thin] flex-col gap-2 overflow-y-auto px-0.5 py-1 pr-1', props.class)
"
>
<TransitionGroup v-if="mounted" :name="motionName()" tag="div" class="relative flex flex-col gap-2">
<slot />
</TransitionGroup>
<div v-else class="relative flex flex-col gap-2">
<slot />
</div>
</div>
</template>
<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>
<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)!
const lane = inject(BOARD_LANE_CONTEXT)!
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<HTMLDivElement | 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>
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>
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')
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