{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "kanban",
	"title": "Kanban",
	"type": "registry:ui",
	"description": "An accessible multi-column drag-and-drop board with optimistic commit metadata and independently scrolling columns.",
	"dependencies": [
		"@lucide/svelte@^1.28.0",
		"@thisux/sveltednd@0.4.1"
	],
	"registryDependencies": [
		"button",
		"card",
		"scroll-area"
	],
	"files": [
		{
			"content": "import DragHandle from './kanban-drag-handle.svelte';\nimport Root from './kanban.svelte';\n\nexport { DragHandle, Root as Kanban, DragHandle as KanbanDragHandle, Root };\nexport type { KanbanColumn, KanbanMoveMeta, KanbanProps, KanbanValue } from './types.js';\n",
			"type": "registry:file",
			"target": "kanban/index.ts"
		},
		{
			"content": "<script lang=\"ts\">\n\timport GripVerticalIcon from '@lucide/svelte/icons/grip-vertical';\n\n\timport { Button } from '$UI$/button/index.js';\n\n\tlet {\n\t\tlabel = 'Reorder item',\n\t\tdisabled = false,\n\t\tclass: className\n\t}: {\n\t\tclass?: string;\n\t\tdisabled?: boolean;\n\t\tlabel?: string;\n\t} = $props();\n</script>\n\n<Button\n\ttype=\"button\"\n\tvariant=\"ghost\"\n\tsize=\"icon-sm\"\n\tclass={className}\n\t{disabled}\n\tdata-kanban-drag-handle\n\taria-label={label}\n\taria-keyshortcuts=\"ArrowUp ArrowDown ArrowLeft ArrowRight\"\n>\n\t<GripVerticalIcon />\n</Button>\n",
			"type": "registry:file",
			"target": "kanban/kanban-drag-handle.svelte"
		},
		{
			"content": "<script lang=\"ts\" generics=\"T\">\n\timport { type DragDropState, draggable, droppable } from '@thisux/sveltednd';\n\timport { fromAction } from 'svelte/attachments';\n\n\timport * as Card from '$UI$/card/index.js';\n\timport * as ScrollArea from '$UI$/scroll-area/index.js';\n\timport { cn } from '$UTILS$.js';\n\n\timport type { KanbanColumn, KanbanMoveMeta, KanbanProps, KanbanValue } from './types.js';\n\n\tlet {\n\t\tcolumns,\n\t\tvalue,\n\t\tgetItemId,\n\t\tonValueChange,\n\t\tonValueCommit,\n\t\titem,\n\t\tcolumnAction,\n\t\tcolumnFooter,\n\t\tempty,\n\t\tdisabled = false,\n\t\tdragHandleSelector = '[data-kanban-drag-handle]',\n\t\tariaLabel = 'Kanban board',\n\t\tclass: className\n\t}: KanbanProps<T> = $props();\n\n\tlet announcement = $state('');\n\tlet boardElement = $state<HTMLDivElement | null>(null);\n\n\tfunction copyValue(current: KanbanValue<T>): KanbanValue<T> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(current).map(([columnId, items]) => [columnId, [...items]])\n\t\t);\n\t}\n\n\tfunction findItem(itemId: string) {\n\t\tfor (const [columnId, items] of Object.entries(value)) {\n\t\t\tconst index = items.findIndex((candidate) => getItemId(candidate) === itemId);\n\t\t\tif (index !== -1) return { columnId, index, item: items[index] };\n\t\t}\n\n\t\treturn undefined;\n\t}\n\n\tasync function moveItem(\n\t\titemId: string,\n\t\ttoColumn: string,\n\t\toverItemId?: string,\n\t\tposition: 'after' | 'before' | null = null\n\t) {\n\t\tconst source = findItem(itemId);\n\t\tif (!source || overItemId === itemId) return;\n\n\t\tconst previousValue = copyValue(value);\n\t\tconst next = copyValue(value);\n\t\tconst sourceItems = next[source.columnId] ?? [];\n\t\tconst [movedItem] = sourceItems.splice(source.index, 1);\n\t\tconst targetItems = source.columnId === toColumn ? sourceItems : [...(next[toColumn] ?? [])];\n\t\tlet toIndex = targetItems.length;\n\n\t\tif (overItemId) {\n\t\t\tconst overIndex = targetItems.findIndex((candidate) => getItemId(candidate) === overItemId);\n\t\t\tif (overIndex !== -1) {\n\t\t\t\ttoIndex = overIndex + (position === 'after' ? 1 : 0);\n\t\t\t}\n\t\t}\n\n\t\tif (source.columnId === toColumn && source.index === toIndex) return;\n\n\t\ttargetItems.splice(toIndex, 0, movedItem);\n\t\tnext[source.columnId] = sourceItems;\n\t\tnext[toColumn] = targetItems;\n\n\t\tconst meta: KanbanMoveMeta<T> = {\n\t\t\titem: movedItem,\n\t\t\titemId,\n\t\t\tfromColumn: source.columnId,\n\t\t\tfromIndex: source.index,\n\t\t\ttoColumn,\n\t\t\ttoIndex,\n\t\t\toverItemId,\n\t\t\tposition,\n\t\t\tpreviousValue\n\t\t};\n\n\t\tonValueChange(next);\n\t\tawait onValueCommit?.(next, meta);\n\t\treturn meta;\n\t}\n\n\tasync function handleDrop(\n\t\tstate: DragDropState<T>,\n\t\ttoColumn: string,\n\t\toverItemId?: string\n\t): Promise<void> {\n\t\tawait moveItem(getItemId(state.draggedItem), toColumn, overItemId, state.dropPosition);\n\t}\n\n\tfunction isColumnDisabled(column: KanbanColumn) {\n\t\treturn disabled || column.disabled === true;\n\t}\n\n\tfunction adjacentColumn(columnId: string, direction: -1 | 1) {\n\t\tlet index = columns.findIndex((column) => column.id === columnId) + direction;\n\t\twhile (index >= 0 && index < columns.length) {\n\t\t\tif (!isColumnDisabled(columns[index])) return columns[index];\n\t\t\tindex += direction;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tasync function handleBoardKeydown(event: KeyboardEvent) {\n\t\tif (!dragHandleSelector || !(event.target instanceof Element)) return;\n\t\tif (!boardElement?.contains(event.target) || !event.target.closest(dragHandleSelector)) return;\n\t\tif (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return;\n\n\t\tconst itemElement = event.target.closest<HTMLElement>('[data-kanban-item]');\n\t\tconst itemId = itemElement?.dataset.kanbanItem;\n\t\tconst columnId = itemElement?.dataset.kanbanColumn;\n\t\tif (!itemId || !columnId) return;\n\n\t\tconst source = findItem(itemId);\n\t\tconst column = columns.find((candidate) => candidate.id === columnId);\n\t\tif (!source || !column) return;\n\n\t\tlet toColumn = column.id;\n\t\tlet overItemId: string | undefined;\n\t\tlet position: 'after' | 'before';\n\n\t\tif (event.key === 'ArrowUp' || event.key === 'ArrowDown') {\n\t\t\tconst offset = event.key === 'ArrowUp' ? -1 : 1;\n\t\t\tconst overItem = value[column.id]?.[source.index + offset];\n\t\t\tif (!overItem) return;\n\t\t\toverItemId = getItemId(overItem);\n\t\t\tposition = event.key === 'ArrowUp' ? 'before' : 'after';\n\t\t} else {\n\t\t\tconst targetColumn = adjacentColumn(column.id, event.key === 'ArrowLeft' ? -1 : 1);\n\t\t\tif (!targetColumn) return;\n\t\t\ttoColumn = targetColumn.id;\n\t\t\tconst targetItems = value[toColumn] ?? [];\n\t\t\tconst overItem = targetItems[source.index];\n\t\t\tif (overItem) overItemId = getItemId(overItem);\n\t\t\tposition = 'before';\n\t\t}\n\n\t\tevent.preventDefault();\n\t\tconst meta = await moveItem(itemId, toColumn, overItemId, position);\n\t\tif (!meta) return;\n\n\t\tconst targetColumn = columns.find((candidate) => candidate.id === meta.toColumn);\n\t\tannouncement = `${meta.itemId} moved to ${targetColumn?.title ?? meta.toColumn}, position ${meta.toIndex + 1}.`;\n\t}\n</script>\n\n<svelte:window onkeydown={handleBoardKeydown} />\n\n<ScrollArea.Root\n\tbind:ref={boardElement}\n\torientation=\"horizontal\"\n\tclass={cn(\n\t\t'w-full pb-3 [&_[data-scroll-area-content]]:h-full [&_[data-scroll-area-content]]:min-h-0',\n\t\tclassName\n\t)}\n\tdata-kanban-board\n\trole=\"region\"\n\taria-label={ariaLabel}\n>\n\t<p class=\"sr-only\" aria-live=\"polite\">{announcement}</p>\n\t<div class=\"flex h-full min-w-max items-start gap-4 px-1 pt-1 pb-4\">\n\t\t{#each columns as column (column.id)}\n\t\t\t{@const items = value[column.id] ?? []}\n\t\t\t{@const columnDisabled = isColumnDisabled(column)}\n\t\t\t<Card.Root\n\t\t\t\tsize=\"sm\"\n\t\t\t\tclass=\"max-h-full w-80 shrink-0 bg-muted/40\"\n\t\t\t\trole=\"group\"\n\t\t\t\taria-label={column.title}\n\t\t\t\taria-disabled={columnDisabled}\n\t\t\t>\n\t\t\t\t<Card.Header class=\"shrink-0 border-b\">\n\t\t\t\t\t<Card.Title>{column.title}</Card.Title>\n\t\t\t\t\t{#if column.description}\n\t\t\t\t\t\t<Card.Description>{column.description}</Card.Description>\n\t\t\t\t\t{/if}\n\t\t\t\t\t{#if columnAction}\n\t\t\t\t\t\t<Card.Action>{@render columnAction(column, items)}</Card.Action>\n\t\t\t\t\t{/if}\n\t\t\t\t</Card.Header>\n\t\t\t\t<Card.Content class=\"flex min-h-0 flex-col\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"kanban-column-scroll -mr-3 flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-y-contain p-0.5 pr-3 [scrollbar-gutter:stable]\"\n\t\t\t\t\t\trole=\"list\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{#each items as currentItem, index (getItemId(currentItem))}\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\trole=\"listitem\"\n\t\t\t\t\t\t\t\tclass=\"shrink-0 rounded-lg\"\n\t\t\t\t\t\t\t\tdata-kanban-item={getItemId(currentItem)}\n\t\t\t\t\t\t\t\tdata-kanban-column={column.id}\n\t\t\t\t\t\t\t\t{@attach fromAction(draggable<T>, () => ({\n\t\t\t\t\t\t\t\t\tcontainer: column.id,\n\t\t\t\t\t\t\t\t\tdragData: currentItem,\n\t\t\t\t\t\t\t\t\tdisabled: columnDisabled,\n\t\t\t\t\t\t\t\t\thandle: dragHandleSelector ?? undefined,\n\t\t\t\t\t\t\t\t\tattributes: { draggingClass: 'opacity-60' }\n\t\t\t\t\t\t\t\t}))}\n\t\t\t\t\t\t\t\t{@attach fromAction(droppable<T>, () => ({\n\t\t\t\t\t\t\t\t\tcontainer: `${column.id}:${getItemId(currentItem)}`,\n\t\t\t\t\t\t\t\t\tdisabled: columnDisabled,\n\t\t\t\t\t\t\t\t\tdirection: 'vertical' as const,\n\t\t\t\t\t\t\t\t\tcallbacks: {\n\t\t\t\t\t\t\t\t\t\tonDrop: (state) => handleDrop(state, column.id, getItemId(currentItem))\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\tattributes: { dragOverClass: 'bg-accent/50' }\n\t\t\t\t\t\t\t\t}))}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{@render item(currentItem, column, index)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tclass=\"flex min-h-28 items-center justify-center rounded-3xl border border-dashed p-4 text-center text-sm text-muted-foreground\"\n\t\t\t\t\t\t\t\t{@attach fromAction(droppable<T>, () => ({\n\t\t\t\t\t\t\t\t\tcontainer: column.id,\n\t\t\t\t\t\t\t\t\tdisabled: columnDisabled,\n\t\t\t\t\t\t\t\t\tcallbacks: { onDrop: (state) => handleDrop(state, column.id) },\n\t\t\t\t\t\t\t\t\tattributes: { dragOverClass: 'bg-accent/50' }\n\t\t\t\t\t\t\t\t}))}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{#if empty}\n\t\t\t\t\t\t\t\t\t{@render empty(column)}\n\t\t\t\t\t\t\t\t{:else}\n\t\t\t\t\t\t\t\t\tDrop items here\n\t\t\t\t\t\t\t\t{/if}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t{/each}\n\t\t\t\t\t</div>\n\t\t\t\t</Card.Content>\n\t\t\t\t{#if columnFooter}\n\t\t\t\t\t<Card.Footer class=\"shrink-0 border-t pt-3\">{@render columnFooter(column, items)}</Card.Footer>\n\t\t\t\t{/if}\n\t\t\t</Card.Root>\n\t\t{/each}\n\t</div>\n</ScrollArea.Root>\n\n<style>\n\t:global([data-kanban-board] .drop-before::before),\n\t:global([data-kanban-board] .drop-after::after) {\n\t\tbackground-color: var(--primary);\n\t}\n\n\t/* Keep Chromium's persistent column scrollbar visually aligned with the board. */\n\t.kanban-column-scroll::-webkit-scrollbar {\n\t\twidth: 0.75rem;\n\t}\n\n\t.kanban-column-scroll::-webkit-scrollbar-track {\n\t\tbackground: transparent;\n\t}\n\n\t.kanban-column-scroll::-webkit-scrollbar-thumb {\n\t\tborder: 3px solid transparent;\n\t\tborder-radius: 9999px;\n\t\tbackground-clip: content-box;\n\t\tbackground-color: color-mix(in oklab, var(--muted-foreground) 40%, transparent);\n\t}\n\n\t.kanban-column-scroll::-webkit-scrollbar-thumb:hover {\n\t\tbackground-color: color-mix(in oklab, var(--muted-foreground) 65%, transparent);\n\t}\n\n\t@supports not selector(::-webkit-scrollbar) {\n\t\t.kanban-column-scroll {\n\t\t\tscrollbar-width: thin;\n\t\t\tscrollbar-color: color-mix(in oklab, var(--muted-foreground) 40%, transparent)\n\t\t\t\ttransparent;\n\t\t}\n\t}\n</style>\n",
			"type": "registry:file",
			"target": "kanban/kanban.svelte"
		},
		{
			"content": "import type { Snippet } from 'svelte';\n\nexport interface KanbanColumn {\n\tdescription?: string;\n\tdisabled?: boolean;\n\tid: string;\n\ttitle: string;\n}\n\nexport type KanbanValue<T> = Record<string, T[]>;\n\nexport interface KanbanMoveMeta<T> {\n\tfromColumn: string;\n\tfromIndex: number;\n\titem: T;\n\titemId: string;\n\toverItemId?: string;\n\tposition: 'after' | 'before' | null;\n\tpreviousValue: KanbanValue<T>;\n\ttoColumn: string;\n\ttoIndex: number;\n}\n\nexport interface KanbanProps<T> {\n\tariaLabel?: string;\n\tclass?: string;\n\tcolumnAction?: Snippet<[column: KanbanColumn, items: readonly T[]]>;\n\tcolumnFooter?: Snippet<[column: KanbanColumn, items: readonly T[]]>;\n\tcolumns: readonly KanbanColumn[];\n\tdisabled?: boolean;\n\tdragHandleSelector?: null | string;\n\tempty?: Snippet<[column: KanbanColumn]>;\n\tgetItemId: (item: T) => string;\n\titem: Snippet<[item: T, column: KanbanColumn, index: number]>;\n\tonValueChange: (value: KanbanValue<T>) => void;\n\tonValueCommit?: (value: KanbanValue<T>, meta: KanbanMoveMeta<T>) => Promise<void> | void;\n\tvalue: KanbanValue<T>;\n}\n",
			"type": "registry:file",
			"target": "kanban/types.ts"
		}
	]
}