{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "board",
  "title": "Board",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/board/board.component.ts",
      "content": "import {\n  Component,\n  ContentChild,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  Output,\n  SimpleChanges,\n  TemplateRef,\n  ViewContainerRef,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport {\n  boardCardVariants,\n  boardLaneVariants,\n  type BoardCardVariantsProps,\n  type BoardLaneVariantsProps,\n} from './board.variants'\n\nexport type BoardOrientation = 'horizontal' | 'vertical'\nexport type BoardDensity = 'compact' | 'default' | 'comfortable'\n\n/** Drop event a board state helper emits on every move. */\nexport interface BoardDropEvent {\n  /** First (anchor) item moved. For multi-item drops, see `itemIds`. */\n  itemId: string\n  /** All items moved in this drop, in their final visual order. */\n  itemIds: string[]\n  from: string\n  to: string\n  /** Insertion index of the first item in the target lane. */\n  index: number\n}\n\n/** Predicate consumers pass to control which items each lane accepts. */\nexport type BoardAcceptsFn = (itemId: string, fromLaneId: string, toLaneId: string) => boolean\nexport type BoardMoveItemFn = (itemId: string | string[], toLaneId: string, toIndex?: number) => void\n\nexport interface BoardLaneRenderProps {\n  isDragOver: boolean\n  isAccepting: boolean\n  disabled: boolean\n}\n\nconst ALWAYS_ACCEPT: BoardAcceptsFn = () => true\nconst NOOP = () => {}\nconst EMPTY_IDS: readonly string[] = []\nconst EMPTY_SELECTION: ReadonlySet<string> = new Set<string>()\n\n/* ------------------------------------------------------------------ */\n/* Board (root)                                                        */\n/* ------------------------------------------------------------------ */\n\n/**\n * Angular port of UIPKGE Board (React parity). Like React, the primitive owns no data: the\n * consumer's board state is passed in (`draggingId`, `draggingIds`, `dragOverLaneId`,\n * `justMovedId`, `selectedIds`) together with the functions lanes and cards call\n * (`moveItem`, `toggleSelection`, `clearSelection`, the allowed-lanes / disabled-lane\n * registries and `isLaneAcceptingFor`). Lanes and cards read it through DI (React context).\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-board, [ui-board]',\n  standalone: true,\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"board\"',\n    '[attr.data-orientation]': 'orientation',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiBoardComponent {\n  @Input() orientation: BoardOrientation = 'horizontal'\n  @Input() density: BoardDensity = 'default'\n  /** Animation preset class applied by BoardLaneBody. */\n  @Input() motion = 'motion-list'\n  /** Predicate run per drop. Defaults to always-accept. */\n  @Input() accepts: BoardAcceptsFn = ALWAYS_ACCEPT\n  /** Imperative move from the consumer's board state. */\n  @Input() moveItem: BoardMoveItemFn = NOOP\n  @Input() draggingId: string | null = null\n  @Input() draggingIds: readonly string[] = EMPTY_IDS\n  @Input() dragOverLaneId: string | null = null\n  @Input() justMovedId: string | null = null\n  @Input() selectedIds: ReadonlySet<string> = EMPTY_SELECTION\n  @Input() toggleSelection: (itemId: string, additive?: boolean) => void = NOOP\n  @Input() clearSelection: () => void = NOOP\n  @Input() registerAllowedLanes: (cardId: string, lanes: readonly string[] | undefined) => void = NOOP\n  @Input() unregisterAllowedLanes: (cardId: string) => void = NOOP\n  @Input() registerLaneDisabled: (laneId: string, disabled: boolean) => void = NOOP\n  @Input() unregisterLaneDisabled: (laneId: string) => void = NOOP\n  @Input() isLaneAcceptingFor: (laneId: string) => boolean = () => true\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn('block w-full', this.className)\n  }\n}\n\n/* ------------------------------------------------------------------ */\n/* BoardLane                                                           */\n/* ------------------------------------------------------------------ */\n\n/** `<ng-template uiBoardLaneContent let-isDragOver=\"isDragOver\">`: React's children-as-function. */\n@Directive({ selector: 'ng-template[uiBoardLaneContent]', standalone: true })\nexport class UiBoardLaneContentDirective {\n  readonly template = inject<TemplateRef<BoardLaneRenderProps>>(TemplateRef)\n}\n\n/** Renders the lane render-prop template with its state as context. */\n@Directive({ selector: '[uiBoardLaneOutlet]', standalone: true })\nexport class UiBoardLaneOutletDirective implements OnChanges, OnDestroy {\n  @Input('uiBoardLaneOutlet') template: TemplateRef<BoardLaneRenderProps> | null | undefined = null\n  @Input('uiBoardLaneOutletContext') context!: BoardLaneRenderProps\n  private readonly vcr = inject(ViewContainerRef)\n\n  ngOnChanges(): void {\n    this.vcr.clear()\n    if (this.template) this.vcr.createEmbeddedView(this.template, this.context)\n  }\n\n  ngOnDestroy(): void {\n    this.vcr.clear()\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-board-lane, [ui-board-lane]',\n  standalone: true,\n  imports: [UiBoardLaneOutletDirective],\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"board-lane\"',\n    '[attr.data-board-lane]': '\"\"',\n    '[attr.data-lane-id]': 'id',\n    '[attr.data-state]': 'state',\n    '[attr.data-disabled]': 'disabled || null',\n    '[attr.aria-disabled]': 'disabled || null',\n    '[class]': 'hostClass',\n    '(dragover)': 'laneDragOver.emit($event)',\n    '(drop)': 'onDrop($event)',\n    '(dragleave)': 'laneDragLeave.emit()',\n  },\n  template: `<ng-content />\n    @if (contentTemplate) {\n      <ng-container\n        [uiBoardLaneOutlet]=\"contentTemplate.template\"\n        [uiBoardLaneOutletContext]=\"{ isDragOver, isAccepting, disabled }\"\n      />\n    }`,\n})\nexport class UiBoardLaneComponent implements OnChanges, OnDestroy {\n  readonly board = inject(UiBoardComponent)\n  @Input({ required: true }) id!: string\n  @Input() tone: 'default' | 'plain' = 'default'\n  /** Disable drops on this lane. Cards inside still render and stay draggable. */\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input('class') className?: string\n  /** React `onLaneDragOver`: emitted unconditionally so the consumer can set dragOverLaneId. */\n  @Output() laneDragOver = new EventEmitter<DragEvent>()\n  /** React `onLaneDrop` (not emitted on a disabled lane). */\n  @Output() laneDrop = new EventEmitter<DragEvent>()\n  /** React `onLaneDragLeave`. */\n  @Output() laneDragLeave = new EventEmitter<void>()\n  @ContentChild(UiBoardLaneContentDirective) contentTemplate?: UiBoardLaneContentDirective\n  private registeredId?: string\n\n  get isDragOver(): boolean {\n    return this.board.dragOverLaneId === this.id\n  }\n\n  get isAccepting(): boolean {\n    if (!this.board.draggingId) return false\n    if (this.disabled) return false\n    return this.board.isLaneAcceptingFor(this.id)\n  }\n\n  get state(): 'idle' | 'over' | 'rejecting' {\n    return !this.isDragOver ? 'idle' : this.isAccepting ? 'over' : 'rejecting'\n  }\n\n  get hostClass(): string {\n    return cn(\n      boardLaneVariants({ tone: this.tone, state: this.state }),\n      // Disabled lane dims only the lane chrome; cards inside stay legible.\n      this.disabled && 'opacity-80 [&>[data-slot=board-lane-header]]:opacity-60',\n      this.className,\n    )\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    if (changes['id'] || changes['disabled']) {\n      if (this.registeredId !== undefined) this.board.unregisterLaneDisabled(this.registeredId)\n      this.board.registerLaneDisabled(this.id, this.disabled)\n      this.registeredId = this.id\n    }\n  }\n\n  onDrop(e: DragEvent): void {\n    if (this.disabled) {\n      e.preventDefault()\n      return\n    }\n    this.laneDrop.emit(e)\n  }\n\n  ngOnDestroy(): void {\n    if (this.registeredId !== undefined) this.board.unregisterLaneDisabled(this.registeredId)\n  }\n}\n\n/* ------------------------------------------------------------------ */\n/* BoardLaneHeader / Body / Empty                                      */\n/* ------------------------------------------------------------------ */\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-board-lane-header, [ui-board-lane-header]',\n  standalone: true,\n  host: { '[attr.data-uipkge]': '\"\"', '[attr.data-slot]': '\"board-lane-header\"', '[class]': 'hostClass' },\n  template: `<ng-content />`,\n})\nexport class UiBoardLaneHeaderComponent {\n  @Input('class') className?: string\n  get hostClass(): string {\n    return cn('flex items-center justify-between gap-2', this.className)\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-board-lane-body, [ui-board-lane-body]',\n  standalone: true,\n  host: { '[attr.data-uipkge]': '\"\"', '[attr.data-slot]': '\"board-lane-body\"', '[class]': 'hostClass' },\n  // The inner wrapper carries the motion preset class (React / Vue TransitionGroup target).\n  template: `<div [class]=\"innerClass\"><ng-content /></div>`,\n})\nexport class UiBoardLaneBodyComponent {\n  private readonly board = inject(UiBoardComponent, { optional: true })\n  /** Override the animation preset class. Defaults to the board-level motion preset. */\n  @Input() motion?: string\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn(\n      'flex min-h-0 flex-1 [scrollbar-width:thin] flex-col gap-2 overflow-y-auto px-0.5 py-1 pr-1',\n      this.className,\n    )\n  }\n\n  get innerClass(): string {\n    return cn('relative flex flex-col gap-2', this.motion ?? this.board?.motion ?? 'motion-list')\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-board-lane-empty, [ui-board-lane-empty]',\n  standalone: true,\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"board-lane-empty\"',\n    '[class]': 'hostClass',\n    // React renders nothing when `when === false`.\n    '[style.display]': 'when === false ? \"none\" : null',\n  },\n  template: `@if (when !== false) {\n    <ng-content />\n  }`,\n})\nexport class UiBoardLaneEmptyComponent {\n  /** Show only when this is true (consumer wires from `lane.length === 0`). */\n  @Input() when?: boolean\n  @Input('class') className?: string\n  get hostClass(): string {\n    return cn(\n      'block text-muted-foreground/70 border-border/60 rounded-lg border border-dashed py-6 text-center text-xs',\n      this.className,\n    )\n  }\n}\n\n/* ------------------------------------------------------------------ */\n/* BoardCard                                                           */\n/* ------------------------------------------------------------------ */\n\n/**\n * A draggable card: `<div role=\"button\">` so consumers can nest buttons / links inside.\n * Space grabs by keyboard, arrows then move it across lanes (skipping disabled lanes,\n * wrapping) or within its lane, Space / Escape drops. Enter emits `cardClick`;\n * Cmd / Ctrl / Shift + click toggles it in the selection.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-board-card, [ui-board-card]',\n  standalone: true,\n  host: {\n    role: 'button',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"board-card\"',\n    '[attr.data-board-card]': '\"\"',\n    '[attr.data-board-card-id]': 'id',\n    '[attr.data-state]': 'state',\n    '[attr.data-selected]': 'isSelected || null',\n    '[attr.data-disabled]': 'disabled || null',\n    '[attr.aria-disabled]': 'disabled || null',\n    '[attr.aria-pressed]': 'selectable ? isSelected : null',\n    '[attr.draggable]': 'disabled ? false : true',\n    '[attr.tabindex]': 'disabled ? -1 : 0',\n    '[attr.aria-roledescription]': 'keyboardDraggable && !disabled ? \"draggable card\" : null',\n    '[class]': 'hostClass',\n    '(click)': 'onClick($event)',\n    '(keydown)': 'onKeyDown($event)',\n    '(dragstart)': 'onDragStart($event)',\n    '(dragend)': 'dragEnd.emit()',\n  },\n  template: `<ng-content />`,\n})\nexport class UiBoardCardComponent implements OnChanges, OnDestroy {\n  readonly board = inject(UiBoardComponent)\n  readonly lane = inject(UiBoardLaneComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  @Input({ required: true }) id!: string\n  /** Disable keyboard grab (e.g. for read-only boards). */\n  @Input({ transform: booleanAttribute }) keyboardDraggable = true\n  /** Disable the card entirely: non-draggable, non-clickable, dimmed. */\n  @Input({ transform: booleanAttribute }) disabled = false\n  /** Per-card allow-list of destination lane ids. */\n  @Input() allowedLanes?: readonly string[]\n  /** Show the click-to-select chrome (ring + cmd/shift-click multi-select). */\n  @Input({ transform: booleanAttribute }) selectable = true\n  @Input('class') className?: string\n  /** React `onDragStart`. */\n  @Output() dragStart = new EventEmitter<DragEvent>()\n  /** React `onDragEnd`. */\n  @Output() dragEnd = new EventEmitter<void>()\n  /** React `onClick` (renamed: a `click` output would collide with the native DOM event). */\n  @Output() cardClick = new EventEmitter<MouseEvent>()\n\n  readonly isKeyboardGrabbed = signal(false)\n  private registeredId?: string\n\n  get isDragging(): boolean {\n    return this.board.draggingIds.includes(this.id) || this.board.draggingId === this.id || this.isKeyboardGrabbed()\n  }\n  get isJustMoved(): boolean {\n    return this.board.justMovedId === this.id\n  }\n  get isSelected(): boolean {\n    return this.board.selectedIds.has(this.id)\n  }\n  get state(): 'idle' | 'dragging' | 'moved' {\n    return this.isDragging ? 'dragging' : this.isJustMoved ? 'moved' : 'idle'\n  }\n\n  get hostClass(): string {\n    return cn(\n      boardCardVariants({ state: this.state }),\n      this.isSelected && 'ring-primary/60 ring-offset-background ring-2 ring-offset-1',\n      this.disabled && 'pointer-events-none cursor-not-allowed opacity-50 shadow-none grayscale hover:translate-y-0',\n      this.className,\n    )\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    if (changes['id'] || changes['allowedLanes']) {\n      if (this.registeredId !== undefined) this.board.unregisterAllowedLanes(this.registeredId)\n      this.board.registerAllowedLanes(this.id, this.allowedLanes)\n      this.registeredId = this.id\n    }\n  }\n\n  ngOnDestroy(): void {\n    if (this.registeredId !== undefined) this.board.unregisterAllowedLanes(this.registeredId)\n  }\n\n  onKeyDown(e: KeyboardEvent): void {\n    if (this.disabled) return\n    // Enter activates the card (the native <button> default action).\n    if (e.key === 'Enter') {\n      e.preventDefault()\n      this.cardClick.emit(new MouseEvent('click', { bubbles: true, cancelable: true }))\n      return\n    }\n    if (!this.keyboardDraggable) return\n    if (e.key === ' ') {\n      e.preventDefault()\n      this.isKeyboardGrabbed.update((v) => !v)\n      return\n    }\n    if (e.key === 'Escape' && this.isKeyboardGrabbed()) {\n      e.preventDefault()\n      this.isKeyboardGrabbed.set(false)\n      return\n    }\n    if (!this.isKeyboardGrabbed()) return\n    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {\n      e.preventDefault()\n      const allLanes = Array.from(document.querySelectorAll<HTMLElement>('[data-board-lane]')).filter(\n        (el) => !el.hasAttribute('data-disabled'),\n      )\n      const currentIdx = allLanes.findIndex((el) => el.dataset['laneId'] === this.lane.id)\n      if (currentIdx === -1 || allLanes.length === 0) return\n      const delta = e.key === 'ArrowLeft' ? -1 : 1\n      const nextLane = allLanes[(currentIdx + delta + allLanes.length) % allLanes.length]\n      const nextId = nextLane?.dataset['laneId']\n      if (nextId) {\n        this.board.moveItem(this.id, nextId)\n        requestAnimationFrame(() => {\n          document.querySelector<HTMLElement>(`[data-board-card-id=\"${this.id}\"]`)?.focus()\n        })\n      }\n      return\n    }\n    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {\n      e.preventDefault()\n      const laneEl = this.el.closest('[data-board-lane]')\n      if (!laneEl) return\n      const cards = Array.from(laneEl.querySelectorAll<HTMLElement>('[data-board-card]'))\n      const currentIdx = cards.findIndex((el) => el.dataset['boardCardId'] === this.id)\n      if (currentIdx === -1) return\n      const delta = e.key === 'ArrowUp' ? -1 : 1\n      const targetIdx = Math.max(0, Math.min(cards.length - 1, currentIdx + delta))\n      if (targetIdx !== currentIdx) this.board.moveItem(this.id, this.lane.id, targetIdx)\n    }\n  }\n\n  onDragStart(e: DragEvent): void {\n    if (this.disabled) {\n      e.preventDefault()\n      return\n    }\n    this.dragStart.emit(e)\n  }\n\n  onClick(e: MouseEvent): void {\n    if (this.disabled) {\n      e.preventDefault()\n      return\n    }\n    // Cmd/Ctrl/Shift+click toggles the selection; plain click clears it and emits.\n    if (this.selectable && (e.metaKey || e.ctrlKey || e.shiftKey)) {\n      e.preventDefault()\n      this.board.toggleSelection(this.id, true)\n      return\n    }\n    if (this.board.draggingIds.includes(this.id)) return\n    if (this.board.selectedIds.size > 0) this.board.clearSelection()\n    this.cardClick.emit(e)\n  }\n}\n\nexport { boardCardVariants, boardLaneVariants, type BoardCardVariantsProps, type BoardLaneVariantsProps }\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/board/board.component.ts"
    },
    {
      "path": "packages/registry-angular/components/board/board.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Variant definitions live in their own file so consuming SFCs avoid the\n * circular-dep trap when importing from `index.ts`. Same pattern as\n * `timeline.variants.ts` / `card.variants.ts`.\n */\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      // Visual weight of `over` and `rejecting` deliberately matches so\n      // the eye reads them as the same kind of signal (drop intent),\n      // differing only in tone (primary = OK, destructive = NO).\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:ui",
      "target": "~/src/app/components/ui/board/board.variants.ts"
    },
    {
      "path": "packages/registry-angular/components/board/index.ts",
      "content": "export {\n  UiBoardComponent,\n  UiBoardLaneComponent,\n  UiBoardLaneHeaderComponent,\n  UiBoardLaneBodyComponent,\n  UiBoardLaneEmptyComponent,\n  UiBoardCardComponent,\n  UiBoardLaneContentDirective,\n  UiBoardLaneOutletDirective,\n  boardCardVariants,\n  boardLaneVariants,\n  type BoardAcceptsFn,\n  type BoardMoveItemFn,\n  type BoardDensity,\n  type BoardDropEvent,\n  type BoardLaneRenderProps,\n  type BoardOrientation,\n  type BoardCardVariantsProps,\n  type BoardLaneVariantsProps,\n} from './board.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/board/index.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "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; slot `<BoardLaneHeader>`, `<BoardLaneBody>`, `<BoardLaneEmpty>`, and `<BoardCard>` items inside each lane. Like React, the primitive owns no data: pass your board state (draggingId, dragOverLaneId, selection, moveItem, lane / card registries) as inputs; cards handle keyboard grab + arrow moves and multi-select clicks, lanes emit laneDragOver / laneDrop / laneDragLeave. 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.",
  "categories": [
    "data",
    "layout"
  ]
}