@multicomma/arkcn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/bin/arkcn.mjs +78 -0
- package/cli/commands.mjs +251 -0
- package/cli/project.mjs +168 -0
- package/cli/registry.mjs +50 -0
- package/cli/ui.mjs +46 -0
- package/mcp/server.mjs +149 -0
- package/package.json +201 -0
- package/registry/base.css +680 -0
- package/registry/index.json +1833 -0
- package/registry/items/accordion.json +18 -0
- package/registry/items/activity-feed.json +18 -0
- package/registry/items/alert-dialog.json +18 -0
- package/registry/items/alert.json +17 -0
- package/registry/items/angle-slider.json +17 -0
- package/registry/items/app-shell.json +28 -0
- package/registry/items/aspect-ratio.json +13 -0
- package/registry/items/attachment.json +19 -0
- package/registry/items/avatar.json +17 -0
- package/registry/items/badge.json +18 -0
- package/registry/items/breadcrumb.json +18 -0
- package/registry/items/bubble.json +18 -0
- package/registry/items/button-group.json +19 -0
- package/registry/items/button.json +18 -0
- package/registry/items/calendar.json +19 -0
- package/registry/items/canvas.json +24 -0
- package/registry/items/card.json +15 -0
- package/registry/items/carousel.json +19 -0
- package/registry/items/cascader.json +23 -0
- package/registry/items/chart.json +17 -0
- package/registry/items/chat.json +23 -0
- package/registry/items/checkbox.json +18 -0
- package/registry/items/clipboard.json +19 -0
- package/registry/items/collapsible.json +15 -0
- package/registry/items/color-picker.json +19 -0
- package/registry/items/combobox.json +20 -0
- package/registry/items/command.json +21 -0
- package/registry/items/comment-thread.json +25 -0
- package/registry/items/context-menu.json +18 -0
- package/registry/items/controllable.json +14 -0
- package/registry/items/data-grid.json +24 -0
- package/registry/items/data-table-adapter.json +14 -0
- package/registry/items/data-table.json +32 -0
- package/registry/items/date-input.json +17 -0
- package/registry/items/dialog.json +19 -0
- package/registry/items/direction.json +13 -0
- package/registry/items/drag-preview.json +16 -0
- package/registry/items/drawer.json +18 -0
- package/registry/items/dropdown-menu.json +18 -0
- package/registry/items/editable.json +17 -0
- package/registry/items/empty.json +17 -0
- package/registry/items/field.json +19 -0
- package/registry/items/file-upload.json +19 -0
- package/registry/items/floating-panel.json +19 -0
- package/registry/items/floating-toolbar.json +16 -0
- package/registry/items/form.json +14 -0
- package/registry/items/format.json +15 -0
- package/registry/items/gantt.json +22 -0
- package/registry/items/history.json +14 -0
- package/registry/items/hotkeys.json +18 -0
- package/registry/items/hover-card.json +17 -0
- package/registry/items/image-cropper.json +17 -0
- package/registry/items/input-group.json +20 -0
- package/registry/items/input-otp.json +18 -0
- package/registry/items/input.json +15 -0
- package/registry/items/item.json +19 -0
- package/registry/items/json-tree-view.json +17 -0
- package/registry/items/kanban.json +26 -0
- package/registry/items/kbd.json +15 -0
- package/registry/items/label.json +15 -0
- package/registry/items/live-region.json +16 -0
- package/registry/items/marker.json +18 -0
- package/registry/items/marquee.json +17 -0
- package/registry/items/menubar.json +19 -0
- package/registry/items/message.json +15 -0
- package/registry/items/navigation-menu.json +19 -0
- package/registry/items/node-graph.json +22 -0
- package/registry/items/number-input.json +18 -0
- package/registry/items/pagination.json +18 -0
- package/registry/items/password-input.json +18 -0
- package/registry/items/popconfirm.json +20 -0
- package/registry/items/popover.json +18 -0
- package/registry/items/progress.json +17 -0
- package/registry/items/qr-code.json +17 -0
- package/registry/items/query-builder.json +23 -0
- package/registry/items/radio-group.json +17 -0
- package/registry/items/rating-group.json +18 -0
- package/registry/items/resizable.json +17 -0
- package/registry/items/rich-text-editor.json +37 -0
- package/registry/items/row-layout.json +15 -0
- package/registry/items/scheduler.json +23 -0
- package/registry/items/scroll-area.json +17 -0
- package/registry/items/segment-group.json +17 -0
- package/registry/items/select.json +19 -0
- package/registry/items/separator.json +15 -0
- package/registry/items/sheet.json +19 -0
- package/registry/items/sidebar.json +27 -0
- package/registry/items/signature-pad.json +19 -0
- package/registry/items/skeleton.json +15 -0
- package/registry/items/slider.json +17 -0
- package/registry/items/sonner.json +17 -0
- package/registry/items/spinner.json +17 -0
- package/registry/items/steps.json +19 -0
- package/registry/items/swap.json +17 -0
- package/registry/items/switch.json +17 -0
- package/registry/items/table.json +16 -0
- package/registry/items/tabs.json +18 -0
- package/registry/items/tags-input.json +18 -0
- package/registry/items/textarea.json +15 -0
- package/registry/items/time.json +14 -0
- package/registry/items/timer.json +18 -0
- package/registry/items/toc.json +17 -0
- package/registry/items/toggle-group.json +19 -0
- package/registry/items/toggle.json +18 -0
- package/registry/items/tooltip.json +17 -0
- package/registry/items/tour.json +19 -0
- package/registry/items/transfer-list.json +24 -0
- package/registry/items/tree-select.json +24 -0
- package/registry/items/tree-view.json +18 -0
- package/registry/items/use-mobile.json +13 -0
- package/registry/items/utils.json +17 -0
- package/registry/items/virtual-list.json +16 -0
- package/src/components/ui/accordion.tsx +111 -0
- package/src/components/ui/activity-feed.tsx +228 -0
- package/src/components/ui/alert-dialog.tsx +204 -0
- package/src/components/ui/alert.tsx +55 -0
- package/src/components/ui/angle-slider.tsx +117 -0
- package/src/components/ui/app-shell.tsx +985 -0
- package/src/components/ui/aspect-ratio.tsx +35 -0
- package/src/components/ui/attachment.tsx +193 -0
- package/src/components/ui/avatar.tsx +94 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/breadcrumb.tsx +93 -0
- package/src/components/ui/bubble.tsx +119 -0
- package/src/components/ui/button-group.tsx +79 -0
- package/src/components/ui/button.tsx +65 -0
- package/src/components/ui/calendar.tsx +172 -0
- package/src/components/ui/canvas.tsx +723 -0
- package/src/components/ui/card.tsx +69 -0
- package/src/components/ui/carousel.tsx +228 -0
- package/src/components/ui/cascader.tsx +754 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/chat.tsx +544 -0
- package/src/components/ui/checkbox.tsx +94 -0
- package/src/components/ui/clipboard.tsx +94 -0
- package/src/components/ui/collapsible.tsx +32 -0
- package/src/components/ui/color-picker.tsx +441 -0
- package/src/components/ui/combobox.tsx +400 -0
- package/src/components/ui/command.tsx +292 -0
- package/src/components/ui/comment-thread.tsx +923 -0
- package/src/components/ui/context-menu.tsx +331 -0
- package/src/components/ui/data-grid.tsx +1227 -0
- package/src/components/ui/data-table.tsx +993 -0
- package/src/components/ui/date-input.tsx +103 -0
- package/src/components/ui/dialog.tsx +159 -0
- package/src/components/ui/direction.tsx +32 -0
- package/src/components/ui/drawer.tsx +200 -0
- package/src/components/ui/dropdown-menu.tsx +329 -0
- package/src/components/ui/editable.tsx +90 -0
- package/src/components/ui/empty.tsx +86 -0
- package/src/components/ui/field.tsx +218 -0
- package/src/components/ui/file-upload.tsx +204 -0
- package/src/components/ui/floating-panel.tsx +199 -0
- package/src/components/ui/floating-toolbar.tsx +79 -0
- package/src/components/ui/format.tsx +22 -0
- package/src/components/ui/gantt.tsx +950 -0
- package/src/components/ui/hotkeys.tsx +340 -0
- package/src/components/ui/hover-card.tsx +83 -0
- package/src/components/ui/image-cropper.tsx +100 -0
- package/src/components/ui/input-group.tsx +133 -0
- package/src/components/ui/input-otp.tsx +82 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/item.tsx +174 -0
- package/src/components/ui/json-tree-view.tsx +37 -0
- package/src/components/ui/kanban.tsx +884 -0
- package/src/components/ui/kbd.tsx +20 -0
- package/src/components/ui/label.tsx +30 -0
- package/src/components/ui/live-region.tsx +54 -0
- package/src/components/ui/marker.tsx +66 -0
- package/src/components/ui/marquee.tsx +61 -0
- package/src/components/ui/menubar.tsx +456 -0
- package/src/components/ui/message.tsx +74 -0
- package/src/components/ui/navigation-menu.tsx +202 -0
- package/src/components/ui/node-graph.tsx +1723 -0
- package/src/components/ui/number-input.tsx +148 -0
- package/src/components/ui/pagination.tsx +88 -0
- package/src/components/ui/password-input.tsx +104 -0
- package/src/components/ui/popconfirm.tsx +149 -0
- package/src/components/ui/popover.tsx +126 -0
- package/src/components/ui/progress.tsx +114 -0
- package/src/components/ui/qr-code.tsx +51 -0
- package/src/components/ui/query-builder.tsx +938 -0
- package/src/components/ui/radio-group.tsx +108 -0
- package/src/components/ui/rating-group.tsx +91 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/rich-text-editor.tsx +845 -0
- package/src/components/ui/scheduler.tsx +1164 -0
- package/src/components/ui/scroll-area.tsx +82 -0
- package/src/components/ui/segment-group.tsx +95 -0
- package/src/components/ui/select.tsx +354 -0
- package/src/components/ui/separator.tsx +32 -0
- package/src/components/ui/sheet.tsx +130 -0
- package/src/components/ui/sidebar.tsx +665 -0
- package/src/components/ui/signature-pad.tsx +112 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +183 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/steps.tsx +152 -0
- package/src/components/ui/swap.tsx +33 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +100 -0
- package/src/components/ui/tags-input.tsx +153 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/timer.tsx +67 -0
- package/src/components/ui/toc.tsx +64 -0
- package/src/components/ui/toggle-group.tsx +89 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +113 -0
- package/src/components/ui/tour.tsx +168 -0
- package/src/components/ui/transfer-list.tsx +700 -0
- package/src/components/ui/tree-select.tsx +638 -0
- package/src/components/ui/tree-view.tsx +217 -0
- package/src/components/ui/virtual-list.tsx +343 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/lib/controllable.ts +31 -0
- package/src/lib/data-table-adapter.ts +67 -0
- package/src/lib/drag-preview.ts +39 -0
- package/src/lib/form.ts +39 -0
- package/src/lib/history.ts +55 -0
- package/src/lib/row-layout.ts +81 -0
- package/src/lib/table.ts +483 -0
- package/src/lib/time.ts +37 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/arkcn.css +764 -0
- package/src/styles/index.css +3 -0
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
|
|
3
|
+
import { draggable, dropTargetForElements, monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
|
|
4
|
+
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"
|
|
5
|
+
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"
|
|
6
|
+
import { attachClosestEdge, extractClosestEdge, type Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
|
|
7
|
+
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index"
|
|
8
|
+
import { ark } from "@ark-ui/react"
|
|
9
|
+
import { GripVerticalIcon } from "lucide-react"
|
|
10
|
+
import { Badge } from "@/components/ui/badge"
|
|
11
|
+
import { Button } from "@/components/ui/button"
|
|
12
|
+
import { cloneDragPreview } from "@/lib/drag-preview"
|
|
13
|
+
import { cn } from "@/lib/utils"
|
|
14
|
+
import { LiveRegion, useLiveRegion } from "@/components/ui/live-region"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Kanban — a compositional, data-agnostic board on Pragmatic drag and drop.
|
|
18
|
+
* The consumer owns columns and cards and re-renders them from its own state;
|
|
19
|
+
* the primitive wires dragging and reports intents through `onCardMove` and
|
|
20
|
+
* `onColumnMove`. `moveCard`/`moveColumn` are optional helpers for the common
|
|
21
|
+
* `{ id, cards: { id }[] }[]` shape.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type KanbanCardMoveDetails = {
|
|
25
|
+
cardId: string
|
|
26
|
+
fromColumnId: string
|
|
27
|
+
toColumnId: string
|
|
28
|
+
fromIndex: number
|
|
29
|
+
toIndex: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type KanbanColumnMoveDetails = {
|
|
33
|
+
columnId: string
|
|
34
|
+
fromIndex: number
|
|
35
|
+
toIndex: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type DragData =
|
|
39
|
+
| { instanceId: symbol; type: "card"; cardId: string; columnId: string }
|
|
40
|
+
| { instanceId: symbol; type: "column"; columnId: string }
|
|
41
|
+
|
|
42
|
+
const CARD_TARGET = "kanban-card-target"
|
|
43
|
+
const COLUMN_TARGET = "kanban-column-target"
|
|
44
|
+
|
|
45
|
+
type KanbanDragging = { type: "card" | "column"; id: string; columnId?: string; height: number }
|
|
46
|
+
/** Where a dragged card would land: before `beforeCardId`, or at the end when null. */
|
|
47
|
+
type KanbanPreview = { columnId: string; beforeCardId: string | null }
|
|
48
|
+
|
|
49
|
+
type KanbanGrabbed = { type: "card" | "column"; id: string }
|
|
50
|
+
|
|
51
|
+
type KanbanContextValue = {
|
|
52
|
+
instanceId: symbol
|
|
53
|
+
dragging: KanbanDragging | null
|
|
54
|
+
preview: KanbanPreview | null
|
|
55
|
+
/** Keyboard "pick up" state; arrows move the grabbed card/column. */
|
|
56
|
+
grabbed: KanbanGrabbed | null
|
|
57
|
+
grab: (item: KanbanGrabbed | null) => void
|
|
58
|
+
moveByKey: (item: KanbanGrabbed, direction: "up" | "down" | "left" | "right") => void
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const focusLater = (selector: string) =>
|
|
62
|
+
requestAnimationFrame(() => requestAnimationFrame(() => document.querySelector<HTMLElement>(selector)?.focus()))
|
|
63
|
+
|
|
64
|
+
const cssEscape = (value: string) => (typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : value)
|
|
65
|
+
|
|
66
|
+
const KanbanContext = React.createContext<KanbanContextValue | null>(null)
|
|
67
|
+
|
|
68
|
+
function useKanban() {
|
|
69
|
+
const ctx = React.useContext(KanbanContext)
|
|
70
|
+
if (!ctx) throw new Error("Kanban parts must be used within <Kanban>")
|
|
71
|
+
return ctx
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isOwnData = (instanceId: symbol, data: Record<string | symbol, unknown>): data is DragData =>
|
|
75
|
+
data.instanceId === instanceId && (data.type === "card" || data.type === "column")
|
|
76
|
+
|
|
77
|
+
function cardsOf(columnEl: Element) {
|
|
78
|
+
return Array.from(columnEl.querySelectorAll<HTMLElement>(":scope [data-slot=kanban-card]"))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type DropTargetLike = { data: Record<string | symbol, unknown> }
|
|
82
|
+
|
|
83
|
+
/** Resolve a card drag against the current drop targets into a concrete destination. */
|
|
84
|
+
function resolveCardDrop(root: HTMLElement, data: Extract<DragData, { type: "card" }>, targets: DropTargetLike[]) {
|
|
85
|
+
const columnTarget = targets.find((t) => t.data[COLUMN_TARGET] === true)
|
|
86
|
+
if (!columnTarget) return null
|
|
87
|
+
const toColumnId = String(columnTarget.data.columnId)
|
|
88
|
+
const columnEls = Array.from(root.querySelectorAll<HTMLElement>("[data-slot=kanban-column]"))
|
|
89
|
+
const fromColumnEl = columnEls.find((el) => el.dataset.value === data.columnId)
|
|
90
|
+
const toColumnEl = columnEls.find((el) => el.dataset.value === toColumnId)
|
|
91
|
+
if (!fromColumnEl || !toColumnEl) return null
|
|
92
|
+
const fromIndex = cardsOf(fromColumnEl).findIndex((el) => el.dataset.value === data.cardId)
|
|
93
|
+
const toCards = cardsOf(toColumnEl)
|
|
94
|
+
const cardTarget = targets.find((t) => t.data[CARD_TARGET] === true)
|
|
95
|
+
const sameColumn = data.columnId === toColumnId
|
|
96
|
+
|
|
97
|
+
let toIndex: number
|
|
98
|
+
if (cardTarget && String(cardTarget.data.cardId) !== data.cardId) {
|
|
99
|
+
const indexOfTarget = toCards.findIndex((el) => el.dataset.value === String(cardTarget.data.cardId))
|
|
100
|
+
const edge = extractClosestEdge(cardTarget.data)
|
|
101
|
+
toIndex = sameColumn
|
|
102
|
+
? getReorderDestinationIndex({
|
|
103
|
+
startIndex: fromIndex,
|
|
104
|
+
indexOfTarget,
|
|
105
|
+
closestEdgeOfTarget: edge,
|
|
106
|
+
axis: "vertical",
|
|
107
|
+
})
|
|
108
|
+
: edge === "bottom"
|
|
109
|
+
? indexOfTarget + 1
|
|
110
|
+
: indexOfTarget
|
|
111
|
+
} else if (cardTarget) {
|
|
112
|
+
// Hovering the dragged card itself: no move.
|
|
113
|
+
toIndex = fromIndex
|
|
114
|
+
} else {
|
|
115
|
+
toIndex = sameColumn ? toCards.length - 1 : toCards.length
|
|
116
|
+
}
|
|
117
|
+
const noop = sameColumn && toIndex === fromIndex
|
|
118
|
+
const remaining = toCards.filter((el) => el.dataset.value !== data.cardId)
|
|
119
|
+
const beforeCardId = remaining[toIndex]?.dataset.value ?? null
|
|
120
|
+
return { toColumnId, toIndex, fromIndex, sameColumn, noop, beforeCardId }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Kanban({
|
|
124
|
+
onCardMove,
|
|
125
|
+
onColumnMove,
|
|
126
|
+
className,
|
|
127
|
+
children,
|
|
128
|
+
...props
|
|
129
|
+
}: React.ComponentProps<"div"> & {
|
|
130
|
+
onCardMove?: (details: KanbanCardMoveDetails) => void
|
|
131
|
+
onColumnMove?: (details: KanbanColumnMoveDetails) => void
|
|
132
|
+
}) {
|
|
133
|
+
const [instanceId] = React.useState(() => Symbol("kanban"))
|
|
134
|
+
const [dragging, setDragging] = React.useState<KanbanDragging | null>(null)
|
|
135
|
+
const [preview, setPreviewState] = React.useState<KanbanPreview | null>(null)
|
|
136
|
+
const [grabbed, setGrabbed] = React.useState<KanbanGrabbed | null>(null)
|
|
137
|
+
const { message: announcement, announce: setAnnouncement } = useLiveRegion()
|
|
138
|
+
const previewKey = React.useRef<string | null>(null)
|
|
139
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
140
|
+
const callbacks = React.useRef({ onCardMove, onColumnMove })
|
|
141
|
+
callbacks.current = { onCardMove, onColumnMove }
|
|
142
|
+
|
|
143
|
+
const grab = React.useCallback(
|
|
144
|
+
(item: KanbanGrabbed | null) => {
|
|
145
|
+
setGrabbed(item)
|
|
146
|
+
setAnnouncement(
|
|
147
|
+
item
|
|
148
|
+
? `Picked up ${item.type} ${item.id}. Use the arrow keys to move, Space or Enter to drop, Escape to cancel.`
|
|
149
|
+
: "Dropped."
|
|
150
|
+
)
|
|
151
|
+
},
|
|
152
|
+
[setAnnouncement]
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
const moveByKey = React.useCallback(
|
|
156
|
+
(item: KanbanGrabbed, direction: "up" | "down" | "left" | "right") => {
|
|
157
|
+
const root = ref.current
|
|
158
|
+
if (!root) return
|
|
159
|
+
const columnEls = Array.from(root.querySelectorAll<HTMLElement>("[data-slot=kanban-column]"))
|
|
160
|
+
const columnIds = columnEls.map((el) => el.dataset.value ?? "")
|
|
161
|
+
const columnName = (el: HTMLElement) =>
|
|
162
|
+
el.querySelector("[data-slot=kanban-column-title]")?.textContent ?? el.dataset.value
|
|
163
|
+
|
|
164
|
+
if (item.type === "column") {
|
|
165
|
+
const fromIndex = columnIds.indexOf(item.id)
|
|
166
|
+
const toIndex = direction === "left" ? fromIndex - 1 : direction === "right" ? fromIndex + 1 : fromIndex
|
|
167
|
+
if (fromIndex < 0 || toIndex === fromIndex || toIndex < 0 || toIndex >= columnIds.length) return
|
|
168
|
+
callbacks.current.onColumnMove?.({ columnId: item.id, fromIndex, toIndex })
|
|
169
|
+
setAnnouncement(`Moved column to position ${toIndex + 1} of ${columnIds.length}.`)
|
|
170
|
+
focusLater(`[data-slot=kanban-column][data-value="${cssEscape(item.id)}"] [data-slot=kanban-column-handle]`)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fromColumnEl = columnEls.find((el) => cardsOf(el).some((c) => c.dataset.value === item.id))
|
|
175
|
+
if (!fromColumnEl) return
|
|
176
|
+
const fromColumnId = fromColumnEl.dataset.value ?? ""
|
|
177
|
+
const fromCards = cardsOf(fromColumnEl)
|
|
178
|
+
const fromIndex = fromCards.findIndex((c) => c.dataset.value === item.id)
|
|
179
|
+
let toColumnId = fromColumnId
|
|
180
|
+
let toIndex = fromIndex
|
|
181
|
+
if (direction === "up" || direction === "down") {
|
|
182
|
+
toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1
|
|
183
|
+
if (toIndex < 0 || toIndex >= fromCards.length) return
|
|
184
|
+
} else {
|
|
185
|
+
const ci = columnIds.indexOf(fromColumnId)
|
|
186
|
+
const targetEl = columnEls[direction === "left" ? ci - 1 : ci + 1]
|
|
187
|
+
if (!targetEl) return
|
|
188
|
+
toColumnId = targetEl.dataset.value ?? ""
|
|
189
|
+
toIndex = Math.min(fromIndex, cardsOf(targetEl).length)
|
|
190
|
+
}
|
|
191
|
+
callbacks.current.onCardMove?.({ cardId: item.id, fromColumnId, toColumnId, fromIndex, toIndex })
|
|
192
|
+
const targetEl = columnEls[columnIds.indexOf(toColumnId)]
|
|
193
|
+
const count = toColumnId === fromColumnId ? fromCards.length : cardsOf(targetEl).length + 1
|
|
194
|
+
setAnnouncement(`Moved to ${columnName(targetEl)}, position ${toIndex + 1} of ${count}.`)
|
|
195
|
+
focusLater(`[data-slot=kanban-card][data-value="${cssEscape(item.id)}"]`)
|
|
196
|
+
},
|
|
197
|
+
[setAnnouncement]
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
React.useEffect(() => {
|
|
201
|
+
const root = ref.current
|
|
202
|
+
if (!root) return
|
|
203
|
+
const setPreview = (next: KanbanPreview | null) => {
|
|
204
|
+
const key = next ? `${next.columnId}::${next.beforeCardId ?? ""}` : null
|
|
205
|
+
if (key === previewKey.current) return
|
|
206
|
+
previewKey.current = key
|
|
207
|
+
setPreviewState(next)
|
|
208
|
+
}
|
|
209
|
+
const updatePreview = (data: DragData, targets: DropTargetLike[]) => {
|
|
210
|
+
if (data.type !== "card") return
|
|
211
|
+
const resolved = resolveCardDrop(root, data, targets)
|
|
212
|
+
setPreview(
|
|
213
|
+
resolved && !resolved.noop ? { columnId: resolved.toColumnId, beforeCardId: resolved.beforeCardId } : null
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
return monitorForElements({
|
|
217
|
+
canMonitor: ({ source }) => isOwnData(instanceId, source.data),
|
|
218
|
+
onDragStart: ({ source }) => {
|
|
219
|
+
const data = source.data as DragData
|
|
220
|
+
setDragging({
|
|
221
|
+
type: data.type,
|
|
222
|
+
id: data.type === "card" ? data.cardId : data.columnId,
|
|
223
|
+
columnId: data.type === "card" ? data.columnId : undefined,
|
|
224
|
+
height: source.element.getBoundingClientRect().height,
|
|
225
|
+
})
|
|
226
|
+
},
|
|
227
|
+
onDrag: ({ source, location }) => updatePreview(source.data as DragData, location.current.dropTargets),
|
|
228
|
+
onDropTargetChange: ({ source, location }) =>
|
|
229
|
+
updatePreview(source.data as DragData, location.current.dropTargets),
|
|
230
|
+
onDrop: ({ source, location }) => {
|
|
231
|
+
setDragging(null)
|
|
232
|
+
setPreview(null)
|
|
233
|
+
const data = source.data as DragData
|
|
234
|
+
const targets = location.current.dropTargets
|
|
235
|
+
const columnEls = Array.from(root.querySelectorAll<HTMLElement>("[data-slot=kanban-column]"))
|
|
236
|
+
const columnIds = columnEls.map((el) => el.dataset.value ?? "")
|
|
237
|
+
|
|
238
|
+
if (data.type === "column") {
|
|
239
|
+
const target = targets.find((t) => t.data[COLUMN_TARGET] === true)
|
|
240
|
+
if (!target) return
|
|
241
|
+
const targetId = String(target.data.columnId)
|
|
242
|
+
const fromIndex = columnIds.indexOf(data.columnId)
|
|
243
|
+
const indexOfTarget = columnIds.indexOf(targetId)
|
|
244
|
+
if (fromIndex < 0 || indexOfTarget < 0) return
|
|
245
|
+
const toIndex = getReorderDestinationIndex({
|
|
246
|
+
startIndex: fromIndex,
|
|
247
|
+
indexOfTarget,
|
|
248
|
+
closestEdgeOfTarget: extractClosestEdge(target.data),
|
|
249
|
+
axis: "horizontal",
|
|
250
|
+
})
|
|
251
|
+
if (toIndex === fromIndex) return
|
|
252
|
+
callbacks.current.onColumnMove?.({ columnId: data.columnId, fromIndex, toIndex })
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const resolved = resolveCardDrop(root, data, targets)
|
|
257
|
+
if (!resolved || resolved.noop) return
|
|
258
|
+
callbacks.current.onCardMove?.({
|
|
259
|
+
cardId: data.cardId,
|
|
260
|
+
fromColumnId: data.columnId,
|
|
261
|
+
toColumnId: resolved.toColumnId,
|
|
262
|
+
fromIndex: resolved.fromIndex,
|
|
263
|
+
toIndex: resolved.toIndex,
|
|
264
|
+
})
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
}, [instanceId])
|
|
268
|
+
|
|
269
|
+
const ctx = React.useMemo(
|
|
270
|
+
() => ({ instanceId, dragging, preview, grabbed, grab, moveByKey }),
|
|
271
|
+
[instanceId, dragging, preview, grabbed, grab, moveByKey]
|
|
272
|
+
)
|
|
273
|
+
return (
|
|
274
|
+
<KanbanContext.Provider value={ctx}>
|
|
275
|
+
<div
|
|
276
|
+
ref={ref}
|
|
277
|
+
data-slot="kanban"
|
|
278
|
+
data-dragging={dragging ? dragging.type : undefined}
|
|
279
|
+
data-grabbed={grabbed ? grabbed.type : undefined}
|
|
280
|
+
className={cn("flex min-h-0 flex-1 flex-col", className)}
|
|
281
|
+
{...props}
|
|
282
|
+
>
|
|
283
|
+
{children}
|
|
284
|
+
<LiveRegion data-slot="kanban-live-region" message={announcement} />
|
|
285
|
+
</div>
|
|
286
|
+
</KanbanContext.Provider>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Keyboard handling shared by cards and column handles. */
|
|
291
|
+
function useKanbanKeyboard(item: KanbanGrabbed) {
|
|
292
|
+
const { grabbed, grab, moveByKey } = useKanban()
|
|
293
|
+
const isGrabbed = grabbed?.type === item.type && grabbed.id === item.id
|
|
294
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
295
|
+
const key = event.key
|
|
296
|
+
if (key === " " || key === "Enter") {
|
|
297
|
+
event.preventDefault()
|
|
298
|
+
grab(isGrabbed ? null : item)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (!isGrabbed) return
|
|
302
|
+
if (key === "Escape") {
|
|
303
|
+
event.preventDefault()
|
|
304
|
+
grab(null)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
const direction = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" }[key] as
|
|
308
|
+
"up" | "down" | "left" | "right" | undefined
|
|
309
|
+
if (!direction) return
|
|
310
|
+
event.preventDefault()
|
|
311
|
+
moveByKey(item, direction)
|
|
312
|
+
}
|
|
313
|
+
return { isGrabbed, onKeyDown, onBlur: () => isGrabbed && grab(null) }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* -------------------------------------------------------------------------- */
|
|
317
|
+
/* Board */
|
|
318
|
+
/* -------------------------------------------------------------------------- */
|
|
319
|
+
|
|
320
|
+
function KanbanBoard({ className, ...props }: React.ComponentProps<"div">) {
|
|
321
|
+
const { instanceId } = useKanban()
|
|
322
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
323
|
+
React.useEffect(() => {
|
|
324
|
+
const el = ref.current
|
|
325
|
+
if (!el) return
|
|
326
|
+
return autoScrollForElements({
|
|
327
|
+
element: el,
|
|
328
|
+
canScroll: ({ source }) => isOwnData(instanceId, source.data),
|
|
329
|
+
})
|
|
330
|
+
}, [instanceId])
|
|
331
|
+
return (
|
|
332
|
+
<div
|
|
333
|
+
ref={ref}
|
|
334
|
+
data-slot="kanban-board"
|
|
335
|
+
className={cn("flex min-h-0 flex-1 items-start gap-4 overflow-x-auto p-1 pb-4", className)}
|
|
336
|
+
{...props}
|
|
337
|
+
/>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* -------------------------------------------------------------------------- */
|
|
342
|
+
/* Column */
|
|
343
|
+
/* -------------------------------------------------------------------------- */
|
|
344
|
+
|
|
345
|
+
type ColumnContextValue = {
|
|
346
|
+
value: string
|
|
347
|
+
handleRef: React.RefObject<HTMLElement | null>
|
|
348
|
+
headerRef: React.RefObject<HTMLElement | null>
|
|
349
|
+
isOver: boolean
|
|
350
|
+
isDragging: boolean
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const ColumnContext = React.createContext<ColumnContextValue | null>(null)
|
|
354
|
+
|
|
355
|
+
function useKanbanColumn() {
|
|
356
|
+
const ctx = React.useContext(ColumnContext)
|
|
357
|
+
if (!ctx) throw new Error("Kanban column parts must be used within <KanbanColumn>")
|
|
358
|
+
return ctx
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function KanbanColumn({
|
|
362
|
+
value,
|
|
363
|
+
draggable: isDraggable = true,
|
|
364
|
+
className,
|
|
365
|
+
children,
|
|
366
|
+
...props
|
|
367
|
+
}: React.ComponentProps<"section"> & {
|
|
368
|
+
value: string
|
|
369
|
+
/** Allow the column itself to be reordered (drag by its handle or header). */
|
|
370
|
+
draggable?: boolean
|
|
371
|
+
}) {
|
|
372
|
+
const { instanceId, dragging, preview } = useKanban()
|
|
373
|
+
const ref = React.useRef<HTMLElement>(null)
|
|
374
|
+
const handleRef = React.useRef<HTMLElement | null>(null)
|
|
375
|
+
const headerRef = React.useRef<HTMLElement | null>(null)
|
|
376
|
+
const [isOver, setIsOver] = React.useState(false)
|
|
377
|
+
const [isDragging, setIsDragging] = React.useState(false)
|
|
378
|
+
const [closestEdge, setClosestEdge] = React.useState<Edge | null>(null)
|
|
379
|
+
|
|
380
|
+
React.useEffect(() => {
|
|
381
|
+
const el = ref.current
|
|
382
|
+
if (!el) return
|
|
383
|
+
const cleanups = [
|
|
384
|
+
dropTargetForElements({
|
|
385
|
+
element: el,
|
|
386
|
+
canDrop: ({ source }) => isOwnData(instanceId, source.data),
|
|
387
|
+
getIsSticky: () => true,
|
|
388
|
+
getData: ({ source, input, element }) => {
|
|
389
|
+
const base = { [COLUMN_TARGET]: true, columnId: value }
|
|
390
|
+
if ((source.data as DragData).type === "column") {
|
|
391
|
+
return attachClosestEdge(base, { element, input, allowedEdges: ["left", "right"] })
|
|
392
|
+
}
|
|
393
|
+
return base
|
|
394
|
+
},
|
|
395
|
+
onDragEnter: ({ source, self }) => {
|
|
396
|
+
const data = source.data as DragData
|
|
397
|
+
if (data.type === "card") setIsOver(true)
|
|
398
|
+
else if (data.columnId !== value) setClosestEdge(extractClosestEdge(self.data))
|
|
399
|
+
},
|
|
400
|
+
onDrag: ({ source, self }) => {
|
|
401
|
+
const data = source.data as DragData
|
|
402
|
+
if (data.type === "column" && data.columnId !== value) {
|
|
403
|
+
const edge = extractClosestEdge(self.data)
|
|
404
|
+
setClosestEdge((prev) => (prev === edge ? prev : edge))
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
onDragLeave: () => {
|
|
408
|
+
setIsOver(false)
|
|
409
|
+
setClosestEdge(null)
|
|
410
|
+
},
|
|
411
|
+
onDrop: () => {
|
|
412
|
+
setIsOver(false)
|
|
413
|
+
setClosestEdge(null)
|
|
414
|
+
},
|
|
415
|
+
}),
|
|
416
|
+
]
|
|
417
|
+
if (isDraggable) {
|
|
418
|
+
cleanups.push(
|
|
419
|
+
draggable({
|
|
420
|
+
element: el,
|
|
421
|
+
dragHandle: handleRef.current ?? headerRef.current ?? undefined,
|
|
422
|
+
getInitialData: (): DragData => ({ instanceId, type: "column", columnId: value }),
|
|
423
|
+
onGenerateDragPreview: (args) => cloneDragPreview(args),
|
|
424
|
+
onDragStart: () => setIsDragging(true),
|
|
425
|
+
onDrop: () => setIsDragging(false),
|
|
426
|
+
})
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
return combine(...cleanups)
|
|
430
|
+
}, [instanceId, value, isDraggable])
|
|
431
|
+
|
|
432
|
+
const ctx = React.useMemo(() => ({ value, handleRef, headerRef, isOver, isDragging }), [value, isOver, isDragging])
|
|
433
|
+
return (
|
|
434
|
+
<ColumnContext.Provider value={ctx}>
|
|
435
|
+
<section
|
|
436
|
+
ref={ref}
|
|
437
|
+
data-slot="kanban-column"
|
|
438
|
+
data-value={value}
|
|
439
|
+
data-over={isOver && dragging?.type === "card" ? "" : undefined}
|
|
440
|
+
data-target={preview?.columnId === value ? "" : undefined}
|
|
441
|
+
data-dragging={isDragging ? "" : undefined}
|
|
442
|
+
data-edge={closestEdge ?? undefined}
|
|
443
|
+
className={cn(
|
|
444
|
+
"group/kanban-column relative flex max-h-full w-72 shrink-0 flex-col rounded-xl border bg-muted/40 transition-colors data-dragging:opacity-40 data-target:border-primary/30",
|
|
445
|
+
className
|
|
446
|
+
)}
|
|
447
|
+
{...props}
|
|
448
|
+
>
|
|
449
|
+
{children}
|
|
450
|
+
{closestEdge && <KanbanDropIndicator edge={closestEdge} />}
|
|
451
|
+
</section>
|
|
452
|
+
</ColumnContext.Provider>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function KanbanColumnHeader({ className, ...props }: React.ComponentProps<"header">) {
|
|
457
|
+
const { headerRef } = useKanbanColumn()
|
|
458
|
+
return (
|
|
459
|
+
<header
|
|
460
|
+
ref={headerRef as React.RefObject<HTMLElement>}
|
|
461
|
+
data-slot="kanban-column-header"
|
|
462
|
+
className={cn("flex items-center gap-2 px-3 py-2.5", className)}
|
|
463
|
+
{...props}
|
|
464
|
+
/>
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function KanbanColumnHandle({
|
|
469
|
+
className,
|
|
470
|
+
children,
|
|
471
|
+
onKeyDown,
|
|
472
|
+
onBlur,
|
|
473
|
+
asChild,
|
|
474
|
+
...props
|
|
475
|
+
}: React.ComponentProps<typeof ark.button>) {
|
|
476
|
+
const { handleRef, value } = useKanbanColumn()
|
|
477
|
+
const keyboard = useKanbanKeyboard({ type: "column", id: value })
|
|
478
|
+
return (
|
|
479
|
+
<ark.button
|
|
480
|
+
asChild={asChild}
|
|
481
|
+
ref={handleRef as React.RefObject<HTMLButtonElement>}
|
|
482
|
+
type="button"
|
|
483
|
+
data-slot="kanban-column-handle"
|
|
484
|
+
data-grabbed={keyboard.isGrabbed ? "" : undefined}
|
|
485
|
+
aria-label="Drag column"
|
|
486
|
+
aria-pressed={keyboard.isGrabbed}
|
|
487
|
+
aria-describedby={undefined}
|
|
488
|
+
onKeyDown={(event) => {
|
|
489
|
+
onKeyDown?.(event)
|
|
490
|
+
if (!event.defaultPrevented) keyboard.onKeyDown(event)
|
|
491
|
+
}}
|
|
492
|
+
onBlur={(event) => {
|
|
493
|
+
onBlur?.(event)
|
|
494
|
+
keyboard.onBlur()
|
|
495
|
+
}}
|
|
496
|
+
className={cn(
|
|
497
|
+
"-ms-1 flex size-6 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 active:cursor-grabbing data-grabbed:bg-primary data-grabbed:text-primary-foreground [&_svg]:size-4",
|
|
498
|
+
className
|
|
499
|
+
)}
|
|
500
|
+
{...props}
|
|
501
|
+
>
|
|
502
|
+
{asChild ? children : (children ?? <GripVerticalIcon />)}
|
|
503
|
+
</ark.button>
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function KanbanColumnTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
|
508
|
+
return <h3 data-slot="kanban-column-title" className={cn("truncate text-sm font-semibold", className)} {...props} />
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function KanbanColumnCount({ className, ...props }: React.ComponentProps<typeof Badge>) {
|
|
512
|
+
return (
|
|
513
|
+
<Badge
|
|
514
|
+
data-slot="kanban-column-count"
|
|
515
|
+
variant="secondary"
|
|
516
|
+
className={cn("h-5 min-w-5 justify-center px-1.5 tabular-nums", className)}
|
|
517
|
+
{...props}
|
|
518
|
+
/>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function KanbanColumnActions({ className, ...props }: React.ComponentProps<"div">) {
|
|
523
|
+
return (
|
|
524
|
+
<div data-slot="kanban-column-actions" className={cn("ms-auto flex items-center gap-1", className)} {...props} />
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The scrolling list of cards. Renders the landing slot at the end when a card would be appended. */
|
|
529
|
+
function KanbanColumnContent({ className, children, ...props }: React.ComponentProps<"div">) {
|
|
530
|
+
const { instanceId, dragging, preview } = useKanban()
|
|
531
|
+
const column = useKanbanColumn()
|
|
532
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
533
|
+
React.useEffect(() => {
|
|
534
|
+
const el = ref.current
|
|
535
|
+
if (!el) return
|
|
536
|
+
return autoScrollForElements({
|
|
537
|
+
element: el,
|
|
538
|
+
canScroll: ({ source }) => isOwnData(instanceId, source.data),
|
|
539
|
+
})
|
|
540
|
+
}, [instanceId])
|
|
541
|
+
return (
|
|
542
|
+
<div
|
|
543
|
+
ref={ref}
|
|
544
|
+
data-slot="kanban-column-content"
|
|
545
|
+
className={cn(
|
|
546
|
+
"no-scrollbar flex min-h-16 flex-1 flex-col gap-(--kanban-gap) overflow-y-auto px-2 pb-2 [--kanban-gap:0.5rem]",
|
|
547
|
+
className
|
|
548
|
+
)}
|
|
549
|
+
{...props}
|
|
550
|
+
>
|
|
551
|
+
{children}
|
|
552
|
+
{dragging?.type === "card" && preview?.columnId === column.value && preview.beforeCardId === null && (
|
|
553
|
+
<KanbanDropSlot height={dragging.height} />
|
|
554
|
+
)}
|
|
555
|
+
</div>
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function KanbanColumnFooter({ className, ...props }: React.ComponentProps<"footer">) {
|
|
560
|
+
return <footer data-slot="kanban-column-footer" className={cn("flex items-center px-2 pb-2", className)} {...props} />
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** A ghost-style full-width trigger for the footer, e.g. "Add card". Polymorphic via `asChild`. */
|
|
564
|
+
function KanbanAddTrigger({
|
|
565
|
+
className,
|
|
566
|
+
variant = "ghost",
|
|
567
|
+
size = "sm",
|
|
568
|
+
...props
|
|
569
|
+
}: React.ComponentProps<typeof Button>) {
|
|
570
|
+
return (
|
|
571
|
+
<Button
|
|
572
|
+
data-slot="kanban-add-trigger"
|
|
573
|
+
variant={variant}
|
|
574
|
+
size={size}
|
|
575
|
+
className={cn("w-full justify-start text-muted-foreground hover:text-foreground", className)}
|
|
576
|
+
{...props}
|
|
577
|
+
/>
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function KanbanEmpty({ className, ...props }: React.ComponentProps<"div">) {
|
|
582
|
+
return (
|
|
583
|
+
<div
|
|
584
|
+
data-slot="kanban-empty"
|
|
585
|
+
className={cn(
|
|
586
|
+
"flex flex-1 items-center justify-center rounded-lg border border-dashed px-3 py-6 text-center text-xs text-muted-foreground",
|
|
587
|
+
className
|
|
588
|
+
)}
|
|
589
|
+
{...props}
|
|
590
|
+
/>
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/* -------------------------------------------------------------------------- */
|
|
595
|
+
/* Card */
|
|
596
|
+
/* -------------------------------------------------------------------------- */
|
|
597
|
+
|
|
598
|
+
type CardContextValue = { handleRef: React.RefObject<HTMLElement | null>; value: string }
|
|
599
|
+
const CardContext = React.createContext<CardContextValue | null>(null)
|
|
600
|
+
|
|
601
|
+
function KanbanCard({
|
|
602
|
+
value,
|
|
603
|
+
draggable: isDraggable = true,
|
|
604
|
+
className,
|
|
605
|
+
children,
|
|
606
|
+
onKeyDown,
|
|
607
|
+
onBlur,
|
|
608
|
+
...props
|
|
609
|
+
}: React.ComponentProps<"article"> & { value: string; draggable?: boolean }) {
|
|
610
|
+
const { instanceId, dragging, preview } = useKanban()
|
|
611
|
+
const column = useKanbanColumn()
|
|
612
|
+
const keyboard = useKanbanKeyboard({ type: "card", id: value })
|
|
613
|
+
const ref = React.useRef<HTMLElement>(null)
|
|
614
|
+
const handleRef = React.useRef<HTMLElement | null>(null)
|
|
615
|
+
const [isDragging, setIsDragging] = React.useState(false)
|
|
616
|
+
const isSlotBefore = dragging?.type === "card" && preview?.columnId === column.value && preview.beforeCardId === value
|
|
617
|
+
// The dragged card stays as an outlined placeholder until a landing spot exists, then collapses.
|
|
618
|
+
const isCollapsed = isDragging && preview !== null
|
|
619
|
+
|
|
620
|
+
React.useEffect(() => {
|
|
621
|
+
const el = ref.current
|
|
622
|
+
if (!el) return
|
|
623
|
+
const cleanups = [
|
|
624
|
+
dropTargetForElements({
|
|
625
|
+
element: el,
|
|
626
|
+
canDrop: ({ source }) => isOwnData(instanceId, source.data) && (source.data as DragData).type === "card",
|
|
627
|
+
getIsSticky: () => true,
|
|
628
|
+
getData: ({ input, element }) =>
|
|
629
|
+
attachClosestEdge(
|
|
630
|
+
{ [CARD_TARGET]: true, cardId: value, columnId: column.value },
|
|
631
|
+
{
|
|
632
|
+
element,
|
|
633
|
+
input,
|
|
634
|
+
allowedEdges: ["top", "bottom"],
|
|
635
|
+
}
|
|
636
|
+
),
|
|
637
|
+
}),
|
|
638
|
+
]
|
|
639
|
+
if (isDraggable) {
|
|
640
|
+
cleanups.push(
|
|
641
|
+
draggable({
|
|
642
|
+
element: el,
|
|
643
|
+
dragHandle: handleRef.current ?? undefined,
|
|
644
|
+
getInitialData: (): DragData => ({ instanceId, type: "card", cardId: value, columnId: column.value }),
|
|
645
|
+
onGenerateDragPreview: (args) => cloneDragPreview(args),
|
|
646
|
+
onDragStart: () => setIsDragging(true),
|
|
647
|
+
onDrop: () => setIsDragging(false),
|
|
648
|
+
})
|
|
649
|
+
)
|
|
650
|
+
}
|
|
651
|
+
return combine(...cleanups)
|
|
652
|
+
}, [instanceId, value, column.value, isDraggable])
|
|
653
|
+
|
|
654
|
+
const ctx = React.useMemo(() => ({ handleRef, value }), [value])
|
|
655
|
+
return (
|
|
656
|
+
<CardContext.Provider value={ctx}>
|
|
657
|
+
{isSlotBefore && dragging && <KanbanDropSlot height={dragging.height} />}
|
|
658
|
+
<article
|
|
659
|
+
ref={ref}
|
|
660
|
+
data-slot="kanban-card"
|
|
661
|
+
data-value={value}
|
|
662
|
+
data-dragging={isDragging ? "" : undefined}
|
|
663
|
+
data-collapsed={isCollapsed ? "" : undefined}
|
|
664
|
+
data-grabbed={keyboard.isGrabbed ? "" : undefined}
|
|
665
|
+
tabIndex={isDraggable ? 0 : undefined}
|
|
666
|
+
aria-roledescription={isDraggable ? "draggable card" : undefined}
|
|
667
|
+
onKeyDown={(event) => {
|
|
668
|
+
onKeyDown?.(event)
|
|
669
|
+
if (!event.defaultPrevented && event.target === event.currentTarget) keyboard.onKeyDown(event)
|
|
670
|
+
}}
|
|
671
|
+
onBlur={(event) => {
|
|
672
|
+
onBlur?.(event)
|
|
673
|
+
if (event.target === event.currentTarget) keyboard.onBlur()
|
|
674
|
+
}}
|
|
675
|
+
className={cn(
|
|
676
|
+
"group/kanban-card relative flex cursor-grab flex-col gap-2 rounded-lg border bg-card p-3 text-sm text-card-foreground shadow-xs transition-shadow outline-none hover:shadow-sm focus-visible:ring-3 focus-visible:ring-ring/50 active:cursor-grabbing data-grabbed:border-primary data-grabbed:ring-3 data-grabbed:ring-primary/30",
|
|
677
|
+
"data-dragging:border-dashed data-dragging:border-primary/40 data-dragging:bg-primary/5 data-dragging:shadow-none data-dragging:*:invisible",
|
|
678
|
+
// Collapse (not `display:none`): removing the dragged element from layout cancels a native drag in Chrome.
|
|
679
|
+
"data-collapsed:pointer-events-none data-collapsed:-mb-(--kanban-gap) data-collapsed:h-0 data-collapsed:min-h-0 data-collapsed:overflow-hidden data-collapsed:border-0 data-collapsed:p-0 data-collapsed:opacity-0",
|
|
680
|
+
className
|
|
681
|
+
)}
|
|
682
|
+
{...props}
|
|
683
|
+
>
|
|
684
|
+
{children}
|
|
685
|
+
</article>
|
|
686
|
+
</CardContext.Provider>
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** Optional explicit drag handle; without it the whole card is the handle. */
|
|
691
|
+
function KanbanCardHandle({
|
|
692
|
+
className,
|
|
693
|
+
children,
|
|
694
|
+
onKeyDown,
|
|
695
|
+
onBlur,
|
|
696
|
+
asChild,
|
|
697
|
+
...props
|
|
698
|
+
}: React.ComponentProps<typeof ark.button>) {
|
|
699
|
+
const ctx = React.useContext(CardContext)
|
|
700
|
+
const keyboard = useKanbanKeyboard({ type: "card", id: ctx?.value ?? "" })
|
|
701
|
+
return (
|
|
702
|
+
<ark.button
|
|
703
|
+
asChild={asChild}
|
|
704
|
+
ref={(el: HTMLElement | null) => {
|
|
705
|
+
if (ctx) ctx.handleRef.current = el
|
|
706
|
+
}}
|
|
707
|
+
type="button"
|
|
708
|
+
data-slot="kanban-card-handle"
|
|
709
|
+
data-grabbed={keyboard.isGrabbed ? "" : undefined}
|
|
710
|
+
aria-label="Drag card"
|
|
711
|
+
aria-pressed={keyboard.isGrabbed}
|
|
712
|
+
onKeyDown={(event) => {
|
|
713
|
+
onKeyDown?.(event)
|
|
714
|
+
if (!event.defaultPrevented) {
|
|
715
|
+
keyboard.onKeyDown(event)
|
|
716
|
+
event.stopPropagation()
|
|
717
|
+
}
|
|
718
|
+
}}
|
|
719
|
+
onBlur={(event) => {
|
|
720
|
+
onBlur?.(event)
|
|
721
|
+
keyboard.onBlur()
|
|
722
|
+
}}
|
|
723
|
+
className={cn(
|
|
724
|
+
"flex size-6 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 active:cursor-grabbing data-grabbed:bg-primary data-grabbed:text-primary-foreground [&_svg]:size-4",
|
|
725
|
+
className
|
|
726
|
+
)}
|
|
727
|
+
{...props}
|
|
728
|
+
>
|
|
729
|
+
{asChild ? children : (children ?? <GripVerticalIcon />)}
|
|
730
|
+
</ark.button>
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function KanbanCardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
735
|
+
return (
|
|
736
|
+
<div
|
|
737
|
+
data-slot="kanban-card-header"
|
|
738
|
+
className={cn("flex items-start justify-between gap-2", className)}
|
|
739
|
+
{...props}
|
|
740
|
+
/>
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function KanbanCardTitle({ className, ...props }: React.ComponentProps<"p">) {
|
|
745
|
+
return <p data-slot="kanban-card-title" className={cn("leading-snug font-medium", className)} {...props} />
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function KanbanCardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
749
|
+
return (
|
|
750
|
+
<p
|
|
751
|
+
data-slot="kanban-card-description"
|
|
752
|
+
className={cn("line-clamp-3 text-xs text-muted-foreground", className)}
|
|
753
|
+
{...props}
|
|
754
|
+
/>
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function KanbanCardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
759
|
+
return (
|
|
760
|
+
<div
|
|
761
|
+
data-slot="kanban-card-footer"
|
|
762
|
+
className={cn("flex items-center gap-2 text-xs text-muted-foreground", className)}
|
|
763
|
+
{...props}
|
|
764
|
+
/>
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/* -------------------------------------------------------------------------- */
|
|
769
|
+
/* Drop slot + indicator */
|
|
770
|
+
/* -------------------------------------------------------------------------- */
|
|
771
|
+
|
|
772
|
+
/** An outlined placeholder the size of the dragged card, marking where it will land. */
|
|
773
|
+
function KanbanDropSlot({ height, className, style, ...props }: React.ComponentProps<"div"> & { height?: number }) {
|
|
774
|
+
return (
|
|
775
|
+
<div
|
|
776
|
+
data-slot="kanban-drop-slot"
|
|
777
|
+
aria-hidden
|
|
778
|
+
style={{ height, ...style }}
|
|
779
|
+
className={cn(
|
|
780
|
+
"shrink-0 rounded-lg border-2 border-dashed border-primary/40 bg-primary/5",
|
|
781
|
+
height === undefined && "min-h-16",
|
|
782
|
+
className
|
|
783
|
+
)}
|
|
784
|
+
{...props}
|
|
785
|
+
/>
|
|
786
|
+
)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** A line on the given edge of its (relative) parent, with a terminal dot. */
|
|
790
|
+
function KanbanDropIndicator({
|
|
791
|
+
edge,
|
|
792
|
+
gap = "0.5rem",
|
|
793
|
+
className,
|
|
794
|
+
...props
|
|
795
|
+
}: React.ComponentProps<"div"> & { edge: Edge; gap?: string }) {
|
|
796
|
+
const horizontal = edge === "top" || edge === "bottom"
|
|
797
|
+
return (
|
|
798
|
+
<div
|
|
799
|
+
data-slot="kanban-drop-indicator"
|
|
800
|
+
data-edge={edge}
|
|
801
|
+
aria-hidden
|
|
802
|
+
style={{ ["--kanban-gap" as string]: gap }}
|
|
803
|
+
className={cn(
|
|
804
|
+
"pointer-events-none absolute z-10 bg-primary",
|
|
805
|
+
"before:absolute before:size-2 before:rounded-full before:border-2 before:border-primary before:bg-background",
|
|
806
|
+
horizontal
|
|
807
|
+
? "inset-x-0 h-0.5 before:top-1/2 before:-left-1 before:-translate-y-1/2"
|
|
808
|
+
: "inset-y-0 w-0.5 before:-top-1 before:left-1/2 before:-translate-x-1/2",
|
|
809
|
+
edge === "top" && "top-[calc(-1*var(--kanban-gap)/2-1px)]",
|
|
810
|
+
edge === "bottom" && "bottom-[calc(-1*var(--kanban-gap)/2-1px)]",
|
|
811
|
+
edge === "left" && "left-[calc(-1*var(--kanban-gap)/2-1px)]",
|
|
812
|
+
edge === "right" && "right-[calc(-1*var(--kanban-gap)/2-1px)]",
|
|
813
|
+
className
|
|
814
|
+
)}
|
|
815
|
+
{...props}
|
|
816
|
+
/>
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/* -------------------------------------------------------------------------- */
|
|
821
|
+
/* Helpers */
|
|
822
|
+
/* -------------------------------------------------------------------------- */
|
|
823
|
+
|
|
824
|
+
type KanbanColumnLike<TCard extends { id: string }> = { id: string; cards: TCard[] }
|
|
825
|
+
|
|
826
|
+
/** Apply an `onCardMove` intent to a `{ id, cards }[]` state shape. */
|
|
827
|
+
function moveCard<TCard extends { id: string }, TColumn extends KanbanColumnLike<TCard>>(
|
|
828
|
+
columns: TColumn[],
|
|
829
|
+
{ cardId, fromColumnId, toColumnId, toIndex }: KanbanCardMoveDetails
|
|
830
|
+
): TColumn[] {
|
|
831
|
+
const from = columns.find((c) => c.id === fromColumnId)
|
|
832
|
+
const card = from?.cards.find((c) => c.id === cardId)
|
|
833
|
+
if (!from || !card) return columns
|
|
834
|
+
return columns.map((column) => {
|
|
835
|
+
if (column.id === fromColumnId && column.id === toColumnId) {
|
|
836
|
+
const startIndex = column.cards.indexOf(card)
|
|
837
|
+
return { ...column, cards: reorder({ list: column.cards, startIndex, finishIndex: toIndex }) }
|
|
838
|
+
}
|
|
839
|
+
if (column.id === fromColumnId) return { ...column, cards: column.cards.filter((c) => c !== card) }
|
|
840
|
+
if (column.id === toColumnId) {
|
|
841
|
+
const cards = [...column.cards]
|
|
842
|
+
cards.splice(toIndex, 0, card)
|
|
843
|
+
return { ...column, cards }
|
|
844
|
+
}
|
|
845
|
+
return column
|
|
846
|
+
})
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Apply an `onColumnMove` intent to a column array. */
|
|
850
|
+
function moveColumn<TColumn extends { id: string }>(
|
|
851
|
+
columns: TColumn[],
|
|
852
|
+
{ fromIndex, toIndex }: KanbanColumnMoveDetails
|
|
853
|
+
): TColumn[] {
|
|
854
|
+
return reorder({ list: columns, startIndex: fromIndex, finishIndex: toIndex })
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export {
|
|
858
|
+
Kanban,
|
|
859
|
+
KanbanAddTrigger,
|
|
860
|
+
KanbanBoard,
|
|
861
|
+
KanbanCard,
|
|
862
|
+
KanbanCardDescription,
|
|
863
|
+
KanbanCardFooter,
|
|
864
|
+
KanbanCardHandle,
|
|
865
|
+
KanbanCardHeader,
|
|
866
|
+
KanbanCardTitle,
|
|
867
|
+
KanbanColumn,
|
|
868
|
+
KanbanColumnActions,
|
|
869
|
+
KanbanColumnContent,
|
|
870
|
+
KanbanColumnCount,
|
|
871
|
+
KanbanColumnFooter,
|
|
872
|
+
KanbanColumnHandle,
|
|
873
|
+
KanbanColumnHeader,
|
|
874
|
+
KanbanColumnTitle,
|
|
875
|
+
KanbanDropIndicator,
|
|
876
|
+
KanbanDropSlot,
|
|
877
|
+
KanbanEmpty,
|
|
878
|
+
moveCard,
|
|
879
|
+
moveColumn,
|
|
880
|
+
useKanban,
|
|
881
|
+
useKanbanColumn,
|
|
882
|
+
type KanbanCardMoveDetails,
|
|
883
|
+
type KanbanColumnMoveDetails,
|
|
884
|
+
}
|