{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-board",
  "type": "registry:component",
  "title": "Kanban board",
  "description": "Moves and reorders cards across named columns with drag and keyboard alternatives.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/native-select.json",
    "https://vlak.dev/r/button.json",
    "https://vlak.dev/r/icons.json",
    "https://vlak.dev/r/vlak-lib.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/kanban-board.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { Button } from \"./button\";\nimport { Icon } from \"./icons\";\nimport { NativeSelect } from \"./native-select\";\n\nexport interface KanbanColumn { id: string; label: string }\nexport interface KanbanCard { id: string; title: string; columnId: string; description?: React.ReactNode; disabled?: boolean }\nexport interface KanbanBoardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\"> {\n  columns: readonly KanbanColumn[];\n  value?: KanbanCard[];\n  defaultValue?: KanbanCard[];\n  onValueChange?: (cards: KanbanCard[]) => void;\n  label?: string;\n}\nconst styles = stylex.create({\n  root: { display: \"flex\", flexDirection: \"column\", width: \"100%\", minWidth: 0, gap: \"0.75rem\", color: vlak.ink },\n  columns: { display: \"grid\", gridAutoFlow: \"column\", gridAutoColumns: \"minmax(min(100%, 17rem), 1fr)\", gap: \"1rem\", width: \"100%\", minWidth: 0, maxWidth: \"100%\", boxSizing: \"border-box\", overflowX: \"auto\", overscrollBehaviorX: \"contain\", padding: \"0.25rem\", scrollPaddingInline: \"0.25rem\", scrollSnapType: \"x proximity\", outlineColor: vlak.ink, outlineOffset: -2 },\n  column: { display: \"flex\", flexDirection: \"column\", alignSelf: \"start\", gap: \"0.75rem\", minWidth: 0, borderWidth: vlak.hairline, borderStyle: \"solid\", borderColor: vlak.divider, borderRadius: vlak.radiusSm, padding: \"0.5rem\", scrollSnapAlign: \"start\" },\n  heading: { margin: 0, paddingInline: \"0.75rem\", minHeight: vlak.hit, fontSize: \"0.9375rem\", fontWeight: 600, display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\", gap: \"0.5rem\", overflowWrap: \"anywhere\" },\n  list: { display: \"grid\", gap: \"0.75rem\", listStyleType: \"none\", margin: 0, padding: 0, minWidth: 0 },\n  card: { display: \"grid\", gap: \"0.75rem\", minWidth: 0, padding: \"0.75rem\", borderWidth: vlak.hairline, borderStyle: \"solid\", borderColor: vlak.divider, borderRadius: vlak.radiusSm, backgroundColor: vlak.paper, fontSize: \"0.875rem\", lineHeight: 1.45 },\n  header: { display: \"grid\", gridTemplateColumns: `minmax(0, 1fr) ${vlak.hit}`, alignItems: \"center\", gap: \"0.5rem\", minWidth: 0 },\n  title: { fontWeight: 600, overflowWrap: \"anywhere\" },\n  detail: { color: vlak.gray, overflowWrap: \"anywhere\" },\n  controls: { display: \"flex\", flexWrap: \"wrap\", alignItems: \"center\", gap: \"0.5rem\", minWidth: 0 },\n  destination: { flex: \"1 1 8rem\", minWidth: 0 },\n  reorder: { display: \"flex\", gap: \"0.25rem\", marginInlineStart: \"auto\", flexShrink: 0 },\n  action: { width: vlak.hit, minWidth: vlak.hit, height: vlak.hit, minHeight: vlak.hit, padding: 0, flexShrink: 0, borderRadius: vlak.radiusSm },\n  status: { margin: 0, paddingInline: \"0.75rem\", color: vlak.gray, fontSize: \"0.75rem\", lineHeight: 1.45 },\n  help: { position: \"absolute\", width: 1, height: 1, overflow: \"hidden\", clipPath: \"inset(50%)\", whiteSpace: \"nowrap\" },\n});\n\n/** Movable cards with drag, keyboard reordering, and named destination selectors. */\nexport const KanbanBoard = React.forwardRef<HTMLDivElement, KanbanBoardProps>(function KanbanBoard({ columns, value, defaultValue = [], onValueChange, label = \"Board\", className, style, ...props }, ref) {\n  const [inner, setInner] = React.useState(defaultValue);\n  const cards = value ?? inner;\n  const [announcement, setAnnouncement] = React.useState(\"\");\n  const dragging = React.useRef<string | null>(null);\n  const destinations = React.useRef(new Map<string, HTMLSelectElement>());\n  const handles = React.useRef(new Map<string, HTMLButtonElement>());\n  const pending = React.useRef<{ id: string; order: Array<{ id: string; columnId: string }>; focus: \"destination\" | \"handle\" | null; message: string } | null>(null);\n  React.useLayoutEffect(() => {\n    const request = pending.current;\n    if (!request || request.order.length !== cards.length || !request.order.every((entry, index) => entry.id === cards[index]?.id && entry.columnId === cards[index]?.columnId)) return;\n    pending.current = null;\n    setAnnouncement(request.message);\n    if (request.focus === \"destination\") destinations.current.get(request.id)?.focus();\n    else if (request.focus === \"handle\") handles.current.get(request.id)?.focus();\n  }, [cards]);\n  const commit = (next: KanbanCard[], id: string, message: string, focus: \"destination\" | \"handle\" | null) => {\n    pending.current = { id, order: next.map(card => ({ id: card.id, columnId: card.columnId })), message, focus };\n    if (value === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n  const reorder = (id: string, destination: number) => {\n    const item = cards.find(card => card.id === id);\n    if (!item || item.disabled) return;\n    const items = cards.filter(card => card.columnId === item.columnId);\n    const source = items.findIndex(card => card.id === id);\n    if (destination < 0 || destination >= items.length || source === destination) return;\n    const ordered = [...items]; ordered.splice(source, 1); ordered.splice(destination, 0, item);\n    let at = 0;\n    commit(cards.map(card => card.columnId === item.columnId ? ordered[at++]! : card), id, `${item.title}, position ${destination + 1} of ${items.length}`, \"handle\");\n  };\n  const move = (id: string, columnId: string, before?: number) => {\n    const item = cards.find(card => card.id === id);\n    const destination = columns.find(column => column.id === columnId);\n    if (!item || item.disabled || !destination || item.columnId === columnId) return;\n    const remaining = cards.filter(card => card.id !== id);\n    const target = before == null ? undefined : remaining.filter(card => card.columnId === columnId)[before];\n    const insertAt = target ? remaining.findIndex(card => card.id === target.id) : remaining.length;\n    remaining.splice(insertAt, 0, { ...item, columnId });\n    const focus = document.activeElement === destinations.current.get(id) ? \"destination\" : document.activeElement === handles.current.get(id) ? \"handle\" : null;\n    commit(remaining, id, `${item.title} moved to ${destination.label}.`, focus);\n  };\n  const root = rs([\"rs-kanban-board\", className], styles.root);\n  const columnGrid = rs([\"rs-kanban-columns\"], styles.columns);\n  const columnStyle = rs([\"rs-kanban-column\"], styles.column);\n  const heading = rs([\"rs-kanban-heading\"], styles.heading);\n  const list = rs([\"rs-kanban-list\"], styles.list);\n  const cardStyle = rs([\"rs-kanban-card\"], styles.card);\n  const header = rs([\"rs-kanban-card-header\"], styles.header);\n  const title = rs([\"rs-kanban-title\"], styles.title);\n  const detail = rs([\"rs-kanban-detail\"], styles.detail);\n  const controls = rs([\"rs-kanban-controls\"], styles.controls);\n  const destination = rs([\"rs-kanban-destination\"], styles.destination);\n  const reorderStyle = rs([\"rs-kanban-reorder\"], styles.reorder);\n  const action = rs([\"rs-kanban-action\"], styles.action);\n  const actionStyle = { ...action.style, width: vlak.hit, padding: 0 };\n  const status = rs([\"rs-kanban-status\"], styles.status);\n  const help = rs([\"rs-kanban-help\"], styles.help);\n  const id = React.useId();\n  return <div ref={ref} role=\"region\" aria-label={label} {...props} className={root.className} style={{ ...root.style, ...style }}>\n    <span {...help} id={`${id}-help`}>Use the move buttons, or focus a card handle and press Alt with Arrow up or Arrow down. Scroll horizontally to see more columns.</span>\n    <div {...columnGrid} role=\"group\" aria-label={`${label} columns`} tabIndex={0}>\n      {columns.map((column, columnIndex) => { const items = cards.filter(card => card.columnId === column.id); const titleId = `${id}-${columnIndex}`; return <section key={column.id} {...columnStyle} aria-labelledby={titleId} onDragOver={event => { if (dragging.current) event.preventDefault(); }} onDrop={event => { if (dragging.current) { event.preventDefault(); move(dragging.current, column.id); dragging.current = null; } }}>\n        <h3 {...heading} id={titleId}>{column.label}<span>{items.length}</span></h3>\n        <ol {...list} aria-label={`${column.label} cards`}>{items.map((card, index) => <li key={card.id} {...cardStyle} onDragOver={event => { if (dragging.current) event.preventDefault(); }} onDrop={event => {\n          const source = cards.find(item => item.id === dragging.current);\n          if (!source) return;\n          event.preventDefault(); event.stopPropagation();\n          if (source.columnId === column.id) reorder(source.id, index);\n          else move(source.id, column.id, index);\n          dragging.current = null;\n        }}>\n          <div {...header}>\n            <span {...title}>{card.title}</span>\n            <Button {...action} style={actionStyle} ref={element => { if (element) handles.current.set(card.id, element); else handles.current.delete(card.id); }} variant=\"ghost\" disabled={card.disabled} draggable={!card.disabled} aria-label={`Move ${card.title}`} aria-describedby={`${id}-help`} onDragStart={event => { dragging.current = card.id; event.dataTransfer.effectAllowed = \"move\"; event.dataTransfer.setData(\"text/plain\", card.id); }} onDragEnd={() => { dragging.current = null; }} onKeyDown={event => {\n              if (event.altKey && (event.key === \"ArrowUp\" || event.key === \"ArrowDown\")) { event.preventDefault(); reorder(card.id, index + (event.key === \"ArrowUp\" ? -1 : 1)); }\n            }}><Icon name=\"grip\" /></Button>\n          </div>\n          {card.description != null && <div {...detail}>{card.description}</div>}\n          <div {...controls}>\n            <div {...destination}><NativeSelect ref={element => { if (element) destinations.current.set(card.id, element); else destinations.current.delete(card.id); }} aria-label={`Move ${card.title} to`} value={card.columnId} disabled={card.disabled} onChange={event => move(card.id, event.currentTarget.value)}>{columns.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}</NativeSelect></div>\n            <div {...reorderStyle}>\n              <Button {...action} style={actionStyle} variant=\"ghost\" disabled={card.disabled || index === 0} aria-label={`Move ${card.title} up`} onClick={() => reorder(card.id, index - 1)}><Icon name=\"arrow-up\" /></Button>\n              <Button {...action} style={actionStyle} variant=\"ghost\" disabled={card.disabled || index === items.length - 1} aria-label={`Move ${card.title} down`} onClick={() => reorder(card.id, index + 1)}><Icon name=\"arrow-down\" /></Button>\n            </div>\n          </div>\n        </li>)}</ol>\n        {items.length === 0 && <p {...status}>No cards.</p>}\n      </section>; })}\n    </div>\n    <p {...status} role=\"status\">{announcement}</p>\n  </div>;\n});\n",
      "type": "registry:component",
      "target": "components/vlak/kanban-board.tsx"
    },
    {
      "path": "vlak/styles/kanban-board.css",
      "content": "/* ── kanban-board: generated from packages/react/src/components/kanban-board.tsx ── */\n.rs-kanban-board{display:flex;flex-direction:column;width:100%;min-width:0;gap:0.75rem;color:var(--text)}\n.rs-kanban-columns{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(min(100%, 17rem), 1fr);gap:1rem;width:100%;min-width:0;max-width:100%;box-sizing:border-box;overflow-x:auto;overscroll-behavior-x:contain;padding:0.25rem;scroll-padding-inline:0.25rem;scroll-snap-type:x proximity;outline-color:var(--text);outline-offset:-2px}\n.rs-kanban-column{display:flex;flex-direction:column;align-self:start;gap:0.75rem;min-width:0;border-width:1px;border-style:solid;border-color:var(--divider);border-radius:var(--radius-sm);padding:0.5rem;scroll-snap-align:start}\n.rs-kanban-heading{margin:0;padding-inline:0.75rem;min-height:var(--hit);font-size:0.9375rem;font-weight:600;display:flex;align-items:center;justify-content:space-between;gap:0.5rem;overflow-wrap:anywhere}\n.rs-kanban-list{display:grid;gap:0.75rem;list-style-type:none;margin:0;padding:0;min-width:0}\n.rs-kanban-card{display:grid;gap:0.75rem;min-width:0;padding:0.75rem;border-width:1px;border-style:solid;border-color:var(--divider);border-radius:var(--radius-sm);background-color:var(--bg);font-size:0.875rem;line-height:1.45}\n.rs-kanban-card-header{display:grid;grid-template-columns:minmax(0, 1fr) var(--hit);align-items:center;gap:0.5rem;min-width:0}\n.rs-kanban-title{font-weight:600;overflow-wrap:anywhere}\n.rs-kanban-detail{color:var(--text-secondary);overflow-wrap:anywhere}\n.rs-kanban-controls{display:flex;flex-wrap:wrap;align-items:center;gap:0.5rem;min-width:0}\n.rs-kanban-destination{flex:1 1 8rem;min-width:0}\n.rs-kanban-reorder{display:flex;gap:0.25rem;margin-inline-start:auto;flex-shrink:0}\n.rs-kanban-action{width:var(--hit);min-width:var(--hit);height:var(--hit);min-height:var(--hit);padding:0;flex-shrink:0;border-radius:var(--radius-sm)}\n.rs-kanban-status{margin:0;padding-inline:0.75rem;color:var(--text-secondary);font-size:0.75rem;line-height:1.45}\n.rs-kanban-help{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}\n",
      "type": "registry:file",
      "target": "styles/vlak/kanban-board.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "patterns",
      "classes": [
        "rs-kanban-board",
        "rs-kanban-columns",
        "rs-kanban-column",
        "rs-kanban-heading",
        "rs-kanban-list",
        "rs-kanban-card",
        "rs-kanban-card-header",
        "rs-kanban-title",
        "rs-kanban-detail",
        "rs-kanban-controls",
        "rs-kanban-destination",
        "rs-kanban-reorder",
        "rs-kanban-action",
        "rs-kanban-status",
        "rs-kanban-help"
      ],
      "snippet": "<div class=\"rs-kanban-board\" role=\"region\" aria-label=\"Board\"><div class=\"rs-kanban-columns\"><section class=\"rs-kanban-column\"><h3 class=\"rs-kanban-heading\">In progress</h3><p class=\"rs-kanban-card\">Review the proof</p></section></div></div>",
      "cssOnly": false,
      "registryDependencies": [
        "native-select",
        "button",
        "icons",
        "vlak-lib"
      ],
      "aliases": [
        "KanbanBoard",
        "Task board",
        "Workflow board"
      ],
      "example": "import { KanbanBoard } from \"@noorddev/vlak-react\";\n\n<KanbanBoard columns={[{ id: \"todo\", label: \"To do\" }, { id: \"doing\", label: \"In progress\" }, { id: \"done\", label: \"Done\" }]} value={cards} onValueChange={setCards} />",
      "usage": {
        "use": [
          "Finite work items moving through named states.",
          "Column changes and ordering that should remain keyboard accessible."
        ],
        "avoid": [
          "A large virtualised issue tracker or automatic workflow rules."
        ]
      },
      "keyboard": [
        {
          "keys": "Alt+Arrow up, Alt+Arrow down",
          "does": "Reorders the focused card handle within its column."
        },
        {
          "keys": "Tab, Enter, Space",
          "does": "Operates move up and down buttons and each card's native destination selector."
        }
      ],
      "a11y": [
        "Every column is a named section; card counts are visible.",
        "A native destination selector is the keyboard alternative to dragging across columns.",
        "Moves are announced and disabled cards remain immovable."
      ]
    }
  }
}
