{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "board",
  "title": "Board — compositional kanban / sortable-list primitive",
  "type": "registry:ui",
  "description": "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; nest `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. State lives in a sibling `useBoard()` hook (insertion-index drop math, keyboard a11y, accept predicate). Cards use native HTML5 drag-and-drop (draggable + onDragStart / onLaneDragOver / onLaneDrop) — the lane components emit drag events up to the consumer, which threads them through useBoard and feeds the resulting state back in as props. Three-level React context (board → lane → card) mirrors the Timeline / Card sub-component pattern.",
  "categories": [
    "data",
    "layout"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/board/board.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { boardCardVariants, boardLaneVariants } from './board.variants'\n\nexport type BoardOrientation = 'horizontal' | 'vertical'\nexport type BoardDensity = 'default' | 'compact' | 'comfortable'\n\n/* --------------------------------------------------------------------- Board */\n\nexport class UipBoard extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    orientation: { type: String },\n    density: { type: String },\n    draggingId: { state: true },\n    dragOverLaneId: { state: true },\n    selectedIds: { state: true },\n  }\n\n  orientation: BoardOrientation = 'horizontal'\n  density: BoardDensity = 'default'\n\n  draggingId: string | null = null\n  dragOverLaneId: string | null = null\n  selectedIds = new Set<string>()\n\n  private cardLaneMap = new Map<string, string>()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'board')\n  }\n\n  setDragging(id: string | null) {\n    this.draggingId = id\n  }\n\n  setDragOverLane(laneId: string | null) {\n    this.dragOverLaneId = laneId\n  }\n\n  registerCardLane(cardId: string, laneId: string) {\n    this.cardLaneMap.set(cardId, laneId)\n  }\n\n  moveItem(itemId: string | string[], toLaneId: string, toIndex = 0) {\n    const ids = Array.isArray(itemId) ? itemId : [itemId]\n    const fromLaneId = this.cardLaneMap.get(ids[0]) ?? ''\n    this.dispatchEvent(\n      new CustomEvent('change', {\n        detail: {\n          itemId: ids[0],\n          itemIds: ids,\n          from: fromLaneId,\n          to: toLaneId,\n          index: toIndex,\n        },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n  }\n\n  toggleSelection(cardId: string, additive = false) {\n    const next = additive ? new Set(this.selectedIds) : new Set<string>()\n    if (next.has(cardId)) next.delete(cardId)\n    else next.add(cardId)\n    this.selectedIds = next\n    this.dispatchEvent(\n      new CustomEvent('selection-change', {\n        detail: { selectedIds: Array.from(next) },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n    this.requestUpdate()\n  }\n\n  render() {\n    return html`\n      <div\n        part=\"base\"\n        class=${cn(\n          'w-full',\n          this.orientation === 'horizontal' ? 'flex flex-row gap-4 overflow-x-auto' : 'flex flex-col gap-4',\n        )}\n      >\n        <slot></slot>\n      </div>\n    `\n  }\n}\n\n/* ---------------------------------------------------------------- Board Lane */\n\nexport class UipBoardLane extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: flex;\n        flex: 1 1 0%;\n        min-width: 240px;\n      }\n    `,\n  ]\n\n  static properties = {\n    laneId: { type: String, attribute: 'lane-id' },\n    tone: { type: String },\n    isOver: { state: true },\n  }\n\n  laneId = ''\n  tone: 'default' | 'plain' = 'default'\n  isOver = false\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'board-lane')\n  }\n\n  private onDragOver(e: DragEvent) {\n    e.preventDefault()\n    this.isOver = true\n    const board = this.closest('uip-board') as UipBoard | null\n    board?.setDragOverLane(this.laneId)\n  }\n\n  private onDragLeave() {\n    this.isOver = false\n  }\n\n  private onDrop(e: DragEvent) {\n    e.preventDefault()\n    this.isOver = false\n    const rawData = e.dataTransfer?.getData('text/plain')\n    if (rawData) {\n      const board = this.closest('uip-board') as UipBoard | null\n      try {\n        const parsed = JSON.parse(rawData)\n        const ids = Array.isArray(parsed) ? parsed : [parsed]\n        board?.moveItem(ids, this.laneId)\n      } catch {\n        board?.moveItem(rawData, this.laneId)\n      }\n    }\n  }\n\n  render() {\n    const state = this.isOver ? 'over' : 'idle'\n    return html`\n      <div\n        part=\"base\"\n        data-lane-id=${this.laneId}\n        class=${cn('w-full', boardLaneVariants({ tone: this.tone, state }))}\n        @dragover=${this.onDragOver}\n        @dragleave=${this.onDragLeave}\n        @drop=${this.onDrop}\n      >\n        <slot></slot>\n      </div>\n    `\n  }\n}\n\n/* --------------------------------------------------------- Board Lane Header */\n\nexport class UipBoardLaneHeader extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n      }\n    `,\n  ]\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'board-lane-header')\n  }\n\n  render() {\n    return html`\n      <div part=\"base\" class=\"flex items-center justify-between gap-2 px-1\">\n        <slot></slot>\n      </div>\n    `\n  }\n}\n\n/* ----------------------------------------------------------- Board Lane Body */\n\nexport class UipBoardLaneBody extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n        flex: 1 1 0%;\n      }\n    `,\n  ]\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'board-lane-body')\n  }\n\n  render() {\n    return html`\n      <div part=\"base\" class=\"flex flex-1 flex-col gap-2 overflow-y-auto\">\n        <slot></slot>\n      </div>\n    `\n  }\n}\n\n/* ---------------------------------------------------------- Board Lane Empty */\n\nexport class UipBoardLaneEmpty extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n      }\n    `,\n  ]\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'board-lane-empty')\n  }\n\n  render() {\n    return html`\n      <div\n        part=\"base\"\n        class=\"border-dashed border-border/70 text-muted-foreground flex h-20 items-center justify-center rounded-lg border-2 text-xs\"\n      >\n        <slot>Empty lane</slot>\n      </div>\n    `\n  }\n}\n\n/* ---------------------------------------------------------------- Board Card */\n\nexport class UipBoardCard extends LitElement {\n  static styles = [\n    tailwind,\n    css`\n      :host {\n        display: block;\n        width: 100%;\n      }\n    `,\n  ]\n\n  static properties = {\n    cardId: { type: String, attribute: 'card-id' },\n    disabled: { type: Boolean },\n    isDragging: { state: true },\n    isSelected: { state: true },\n  }\n\n  cardId = ''\n  disabled = false\n  isDragging = false\n  isSelected = false\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'board-card')\n\n    const lane = this.closest('uip-board-lane') as UipBoardLane | null\n    const board = this.closest('uip-board') as UipBoard | null\n    if (lane && board && this.cardId) {\n      board.registerCardLane(this.cardId, lane.laneId)\n    }\n  }\n\n  private onDragStart(e: DragEvent) {\n    if (this.disabled) return\n    this.isDragging = true\n    const board = this.closest('uip-board') as UipBoard | null\n    const idsToDrag =\n      board && board.selectedIds.has(this.cardId) ? Array.from(board.selectedIds) : [this.cardId]\n    e.dataTransfer?.setData('text/plain', JSON.stringify(idsToDrag))\n    board?.setDragging(this.cardId)\n  }\n\n  private onDragEnd() {\n    this.isDragging = false\n    const board = this.closest('uip-board') as UipBoard | null\n    board?.setDragging(null)\n  }\n\n  private onClick(e: MouseEvent) {\n    if (this.disabled) return\n    const isAdditive = e.metaKey || e.ctrlKey || e.shiftKey\n    const board = this.closest('uip-board') as UipBoard | null\n    board?.toggleSelection(this.cardId, isAdditive)\n    this.isSelected = Boolean(board?.selectedIds.has(this.cardId))\n  }\n\n  render() {\n    const board = this.closest('uip-board') as UipBoard | null\n    const selected = board?.selectedIds.has(this.cardId) ?? this.isSelected\n    const state = this.isDragging ? 'dragging' : 'idle'\n\n    return html`\n      <div\n        part=\"base\"\n        draggable=${!this.disabled}\n        data-card-id=${this.cardId}\n        class=${cn(\n          boardCardVariants({ state }),\n          selected && 'ring-2 ring-primary/60 border-primary/50',\n          this.disabled && 'pointer-events-none opacity-40 grayscale',\n        )}\n        @dragstart=${this.onDragStart}\n        @dragend=${this.onDragEnd}\n        @click=${this.onClick}\n      >\n        <slot></slot>\n      </div>\n    `\n  }\n}\n\n/* -------------------------------------------------------------- Registration */\n\ncustomElements.get('uip-board') || customElements.define('uip-board', UipBoard)\ncustomElements.get('uip-board-lane') || customElements.define('uip-board-lane', UipBoardLane)\ncustomElements.get('uip-board-lane-header') || customElements.define('uip-board-lane-header', UipBoardLaneHeader)\ncustomElements.get('uip-board-lane-body') || customElements.define('uip-board-lane-body', UipBoardLaneBody)\ncustomElements.get('uip-board-lane-empty') || customElements.define('uip-board-lane-empty', UipBoardLaneEmpty)\ncustomElements.get('uip-board-card') || customElements.define('uip-board-card', UipBoardCard)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-board': UipBoard\n    'uip-board-lane': UipBoardLane\n    'uip-board-lane-header': UipBoardLaneHeader\n    'uip-board-lane-body': UipBoardLaneBody\n    'uip-board-lane-empty': UipBoardLaneEmpty\n    'uip-board-card': UipBoardCard\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/board/board.ts"
    },
    {
      "path": "packages/elements-lit/src/components/board/board.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\nexport const boardLaneVariants = cva(\n  '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)]',\n  {\n    variants: {\n      tone: {\n        default: 'bg-muted/30',\n        plain: 'bg-transparent',\n      },\n      state: {\n        idle: '',\n        over: 'border-primary/60 bg-primary/5 ring-2 ring-primary/30 ring-offset-1 ring-offset-background',\n        rejecting:\n          'border-destructive/40 bg-destructive/5 ring-2 ring-destructive/30 ring-offset-1 ring-offset-background',\n      },\n    },\n    defaultVariants: { tone: 'default', state: 'idle' },\n  },\n)\n\nexport const boardCardVariants = cva(\n  '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',\n  {\n    variants: {\n      state: {\n        idle: 'shadow-sm motion-safe:hover:-translate-y-1 hover:shadow-md',\n        dragging: 'scale-95 opacity-40 shadow-none',\n        moved: 'shadow-sm ring-2 ring-primary/50 ring-offset-1 ring-offset-background',\n      },\n    },\n    defaultVariants: { state: 'idle' },\n  },\n)\n\nexport type BoardLaneVariantsProps = VariantProps<typeof boardLaneVariants>\nexport type BoardCardVariantsProps = VariantProps<typeof boardCardVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/board/board.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
