UIPackage

Board — compositional kanban / sortable-list primitive

Vue data
Edit on GitHub

Six 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

$ npx 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