reui
Kanban
Kanban — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/kanban/Kanban.vue
<script setup lang="ts" generic="T">
/**
* Порт ReUI Kanban (registry-reui/bases/radix/reui/kanban.tsx, MIT).
* См. context.ts для полного описания замены dnd-kit -> Pragmatic Drag and
* Drop (ADR-002), устройства данных на draggable/dropTarget и осознанного
* упрощения "без живого предпросмотра во время dragover".
*
* Осознанные отступления от сигнатуры оригинала:
* - `accessibility` (dnd-kit `DndContext["accessibility"]`) и `modifiers`
* (dnd-kit `Modifiers`) не перенесены: это типы, специфичные для
* dnd-kit-сенсоров/скринридер-объявлений, без аналога у Pragmatic.
* Собственная доступность (aria-describedby, live-region) реализована
* напрямую, как в Sortable.
* - `restoreOnCancel` без эффекта на мышином пути: в отличие от
* оригинала, здесь нет живого предпросмотра во время наведения (см.
* комментарий у `resolveItemDropTarget` ниже), поэтому мышиному
* "отпусканию мимо цели" уже нечего откатывать — состояние не меняется,
* пока не случится настоящий валидный дроп. Для КЛАВИАТУРНОГО Escape
* прямое соответствие сохранено: каждый шаг стрелки коммитится
* немедленно (как и в Sortable), поэтому `restoreOnCancel=true`
* восстанавливает снимок, снятый в момент захвата (см.
* `restoreSnapshot` ниже), а `restoreOnCancel=false` (по умолчанию)
* оставляет уже применённые шаги как есть — ровно как в оригинале
* `handleDragCancel` с `restoreOnCancel=false` и без `onValueCommit`.
*/
import type { HTMLAttributes } from "vue"
import { computed, onBeforeUnmount, onMounted, provide, ref } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { cn } from "@/lib/utils"
import { reorderArray, moveBetweenContainers } from "@/lib/dnd/reorder"
import { transparentPixel } from "@/lib/dnd/transparent-pixel"
import {
IsOverlayContextKey,
KanbanInternalContextKey,
type KanbanCommitMeta,
type KanbanColumns,
type KanbanDragEvent,
type KanbanDropIndicator,
type KanbanMoveEvent,
} from "./context"
defineOptions({ inheritAttrs: true })
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
getItemValue: (item: T) => string
asChild?: boolean
restoreOnCancel?: boolean
onMove?: (event: KanbanMoveEvent) => void
onValueCommit?: (value: KanbanColumns<T>, meta: KanbanCommitMeta<T>) => void
onDragStart?: (event: KanbanDragEvent) => void
onDragEnd?: (event: KanbanDragEvent) => void
onDragCancel?: (event: KanbanDragEvent) => void
}>(),
{
asChild: false,
restoreOnCancel: false,
}
)
const value = defineModel<KanbanColumns<T>>("value", { required: true })
const { forwardRef, currentElement } = useForwardExpose()
const instanceId = Symbol("kanban-instance")
const activeId = ref<string | null>(null)
const liveMessage = ref("")
const dropIndicator = ref<KanbanDropIndicator | null>(null)
const describedById = `kanban-instructions-${Math.random().toString(36).slice(2)}`
interface DragVisual {
offsetX: number
offsetY: number
width: number
height: number
x: number
y: number
}
const dragVisual = ref<DragVisual | null>(null)
// --- columnIds/isColumn/findContainer + предупреждение о дублях -----------
const columnIds = computed(() => {
const keys = Object.keys(value.value)
if (import.meta.env?.DEV) {
const seen = new Set<string>()
for (const key of keys) {
for (const item of value.value[key] as T[]) {
const itemId = props.getItemValue(item)
if (seen.has(itemId)) {
console.warn(
`[Kanban] Duplicate item id "${itemId}". Item ids must be unique across all columns, or drag and drop will misbehave.`
)
break
}
seen.add(itemId)
}
}
}
return keys
})
function isColumn(id: string): boolean {
return columnIds.value.includes(id)
}
function findContainer(id: string): string | undefined {
if (isColumn(id)) return id
return columnIds.value.find((key) =>
(value.value[key] as T[]).some((item) => props.getItemValue(item) === id)
)
}
function announce(message: string) {
liveMessage.value = message
}
function getSnapshot(): unknown {
return value.value
}
/**
* Аналог ветки `restoreOnCancel` в `handleDragCancel` оригинала: каждый
* шаг стрелки коммитится немедленно (см. `moveItemByKeyboard`/
* `moveColumnByKeyboard` ниже), поэтому по умолчанию (`restoreOnCancel`
* не передан, `false`) `Escape` НЕ откатывает уже применённые шаги — так
* же, как в оригинале `handleDragCancel` при `restoreOnCancel=false` и
* отсутствующем `onValueCommit` не делает ничего с данными, только
* снимает `activeId`. Восстановление снимка происходит только если
* потребитель явно попросил об этом через `restoreOnCancel`.
*/
function restoreSnapshot(snapshot: unknown) {
if (props.restoreOnCancel) {
value.value = snapshot as KanbanColumns<T>
}
announce("Kanban drag cancelled.")
}
// --- клавиатурное перемещение -----------------------------------------------
function moveItemByKeyboard(itemValue: string, direction: -1 | 1) {
const container = findContainer(itemValue)
if (!container || isColumn(itemValue)) return
const items = value.value[container] as T[]
const activeIndex = items.findIndex((item) => props.getItemValue(item) === itemValue)
if (activeIndex === -1) return
const overIndex = activeIndex + direction
if (overIndex < 0 || overIndex >= items.length) return
const overValue = props.getItemValue(items[overIndex] as T)
const syntheticEvent: KanbanDragEvent = {
active: { id: itemValue },
over: { id: overValue },
}
if (props.onMove) {
props.onMove({
event: syntheticEvent,
activeContainer: container,
activeIndex,
overContainer: container,
overIndex,
})
} else {
const previousValue = value.value
const newItems = reorderArray(items, activeIndex, overIndex)
const newColumns = { ...previousValue, [container]: newItems }
value.value = newColumns
props.onValueCommit?.(newColumns, {
kind: "item",
event: syntheticEvent,
activeContainer: container,
activeIndex,
overContainer: container,
overIndex,
previousValue,
})
}
announce(`Kanban item moved to position ${overIndex + 1} of ${items.length}.`)
}
function moveColumnByKeyboard(columnValue: string, direction: -1 | 1) {
const keys = columnIds.value
const activeIndex = keys.indexOf(columnValue)
if (activeIndex === -1) return
const overIndex = activeIndex + direction
if (overIndex < 0 || overIndex >= keys.length) return
const overValue = keys[overIndex] as string
const syntheticEvent: KanbanDragEvent = {
active: { id: columnValue },
over: { id: overValue },
}
const previousValue = value.value
const newKeys = reorderArray(keys, activeIndex, overIndex)
const newColumns: KanbanColumns<T> = {}
for (const key of newKeys) newColumns[key] = previousValue[key] as T[]
value.value = newColumns
if (props.onValueCommit) {
props.onValueCommit(newColumns, {
kind: "column",
event: syntheticEvent,
activeContainer: columnValue,
activeIndex,
overContainer: overValue,
overIndex,
previousValue,
})
}
announce(`Kanban column moved to position ${overIndex + 1} of ${keys.length}.`)
}
const dragOverlayStyle = computed<Record<string, string | number> | null>(() => {
const visual = dragVisual.value
if (!visual) return null
return {
position: "fixed",
top: 0,
left: 0,
zIndex: 50,
pointerEvents: "none",
width: `${visual.width}px`,
transform: `translate3d(${visual.x - visual.offsetX}px, ${visual.y - visual.offsetY}px, 0)`,
}
})
provide(KanbanInternalContextKey, {
columns: computed(() => value.value as KanbanColumns<unknown>),
getItemId: props.getItemValue as (item: unknown) => string,
columnIds,
activeId,
instanceId,
isColumn,
findContainer,
dropIndicator,
moveItemByKeyboard,
moveColumnByKeyboard,
getSnapshot,
restoreSnapshot,
announce,
describedById,
dragOverlayStyle,
})
provide(IsOverlayContextKey, false)
// --- разрешение цели перетаскивания карточки -------------------------------
// Осознанное упрощение относительно оригинала (см. context.ts): вместо
// живого предпросмотра "на каждом пересечении с новой целью, пока карточка
// ещё в воздухе" (как делает dnd-kit в `handleDragOver`) здесь перестановка
// вычисляется ОДИН раз, при отпускании (`onDrop`) — исходя из места, где
// курсор оказался в момент отпускания, а не из истории пересечений по
// пути. Живой предпросмотр технически возможен и на Pragmatic (через
// `onDropTargetChange`), но воспроизведение ЖИВОГО reflow целевого списка
// на каждое пересечение создаёт петлю обратной связи с самим собой:
// вставка карточки в позицию под курсором сдвигает соседние карточки, что
// меняет то, что физически находится под курсором, что снова меняет
// вычисленную позицию — и так по кругу, пока курсор не остановится
// (наблюдаемо как заметное дрожание списка). Компоненты на dnd-kit не
// подвержены этому, поскольку измеряют коллизии по кэшированным
// прямоугольникам, а не по живому layout после каждой перестановки — само
// по себе это отдельная архитектура, которую нецелесообразно воспроизводить
// ради драг-н-дропа поверх нативного HTML5 DnD. Требование ADR-002
// (совпадение ИТОГОВОГО состояния, а не ощущения при перетаскивании)
// выполняется: `attachClosestEdge`/`extractClosestEdge` здесь определяют
// точную половину карточки под курсором в момент отпускания — "до" или
// "после" неё вставляется перетаскиваемая карточка, — что и даёт
// предсказуемый, воспроизводимый результат независимо от пути наведения.
function resolveItemDropTarget(
dropTargets: Array<{ data: Record<string, unknown> }>
): { container: string; index: number; overValue?: string } | undefined {
const itemTarget = dropTargets.find((target) => target.data.kanbanKind === "item")
if (itemTarget) {
const container = itemTarget.data.kanbanContainer as string
const overValue = itemTarget.data.kanbanValue as string
const items = (value.value[container] ?? []) as T[]
const overIndex = items.findIndex((item) => props.getItemValue(item) === overValue)
if (overIndex === -1) return undefined
// Как и в оригинале (`handleDragOver` в kanban.tsx) и в Sortable.vue:
// индекс вставки — это просто индекс элемента под курсором, БЕЗ
// поправки на верх/низ половины. dnd-kit тоже не различает половины
// тут — итоговое "до"/"после" целиком следствие направления
// перетаскивания и arrayMove-математики (см. moveBetweenContainers/
// reorderArray), а не отдельного edge-расчёта. `extractClosestEdge`
// используется только для визуального индикатора (dropIndicator) —
// он не участвует в подсчёте итоговой позиции.
return { container, index: overIndex, overValue }
}
// Запасной вариант: под курсором нет конкретной карточки, но есть
// dropTarget самой колонки (её собственный droppable покрывает всю
// колонку, включая пустое место) — приземляем в конец её списка. См.
// комментарий в шапке KanbanColumnContent.vue.
const columnTarget = dropTargets.find((target) => target.data.kanbanKind === "column")
if (columnTarget) {
const container = columnTarget.data.kanbanValue as string
return { container, index: ((value.value[container] ?? []) as T[]).length }
}
return undefined
}
// --- центральный обработчик отпускания (аналог handleDragEnd в оригинале) -
function commitColumnDrop(
activeValue: string,
columnTarget: { data: Record<string, unknown> } | undefined
) {
const wasActive = activeId.value === activeValue
activeId.value = null
dragVisual.value = null
dropIndicator.value = null
const overValue = columnTarget?.data.kanbanValue as string | undefined
const syntheticEvent: KanbanDragEvent = {
active: { id: activeValue },
over: overValue ? { id: overValue } : null,
}
if (wasActive) {
if (overValue) props.onDragEnd?.(syntheticEvent)
else props.onDragCancel?.(syntheticEvent)
}
if (!overValue) return
const keys = columnIds.value
const activeIndex = keys.indexOf(activeValue)
const overIndex = keys.indexOf(overValue)
if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) return
const previousValue = value.value
const newKeys = reorderArray(keys, activeIndex, overIndex)
const newColumns: KanbanColumns<T> = {}
for (const key of newKeys) newColumns[key] = previousValue[key] as T[]
value.value = newColumns
props.onValueCommit?.(newColumns, {
kind: "column",
event: syntheticEvent,
activeContainer: activeValue,
activeIndex,
overContainer: overValue,
overIndex,
previousValue,
})
announce("Kanban column was moved.")
}
/**
* Финальный (и единственный) коммит перетаскивания карточки — см.
* комментарий у `resolveItemDropTarget` про отказ от живого предпросмотра.
* `activeContainer` — контейнер, зафиксированный в момент `onDragStart`
* (`source.data.kanbanContainer`, см. KanbanItem.vue); поскольку между
* стартом и отпусканием `value` не меняется (никакого промежуточного
* commit нет), он остаётся достоверным на момент вызова.
*/
function commitItemDrop(
activeValue: string,
activeContainer: string,
dropTargets: Array<{ data: Record<string, unknown> }>
) {
const wasActive = activeId.value === activeValue
activeId.value = null
dragVisual.value = null
dropIndicator.value = null
const target = resolveItemDropTarget(dropTargets)
const syntheticEvent: KanbanDragEvent = {
active: { id: activeValue },
over: target ? { id: target.overValue ?? target.container } : null,
}
if (wasActive) {
if (target) props.onDragEnd?.(syntheticEvent)
else props.onDragCancel?.(syntheticEvent)
}
if (!target) return
const activeItems = (value.value[activeContainer] ?? []) as T[]
const activeIndex = activeItems.findIndex((item) => props.getItemValue(item) === activeValue)
if (activeIndex === -1) return
if (props.onMove) {
props.onMove({
event: syntheticEvent,
activeContainer,
activeIndex,
overContainer: target.container,
overIndex: target.index,
})
announce("Kanban item was moved.")
return
}
// Раньше здесь также пропускался `target.index === activeIndex + 1` —
// это было верно ТОЛЬКО пока индекс вычислялся через
// edge-поправку (+1 на "нижней" половине); при raw-индексе (см.
// resolveItemDropTarget) это уже настоящий сдвиг на одну позицию
// вперёд, а не no-op, поэтому такой ранний возврат ошибочно съедал бы
// реальные перемещения.
if (activeContainer === target.container && target.index === activeIndex) {
return
}
const previousValue = value.value
const newColumns = moveBetweenContainers(
previousValue,
props.getItemValue,
activeContainer,
activeValue,
target.container,
target.index
)
value.value = newColumns
props.onValueCommit?.(newColumns, {
kind: "item",
event: syntheticEvent,
activeContainer,
activeIndex,
overContainer: target.container,
overIndex: target.index,
previousValue,
})
announce("Kanban item was moved.")
}
// --- monitor (аналог DndContext) + автоскролл ------------------------------
let stopEngine: (() => void) | undefined
onMounted(() => {
const el = currentElement.value
const teardown = [
monitorForElements({
canMonitor({ source }) {
return source.data.kanbanInstance === instanceId
},
onGenerateDragPreview({ nativeSetDragImage }) {
nativeSetDragImage?.(transparentPixel(), 0, 0)
},
onDragStart({ source, location }) {
const kind = source.data.kanbanKind as "item" | "column"
const activeValue = source.data.kanbanValue as string
const offset = source.data.kanbanGrabOffset as
| { x: number; y: number; width: number; height: number }
| undefined
if (offset) {
dragVisual.value = {
offsetX: offset.x,
offsetY: offset.y,
width: offset.width,
height: offset.height,
x: location.current.input.clientX,
y: location.current.input.clientY,
}
}
activeId.value = activeValue
props.onDragStart?.({ active: { id: activeValue }, over: null })
announce(kind === "column" ? "Picked up kanban column." : "Picked up kanban item.")
},
onDrag({ location }) {
if (dragVisual.value) {
dragVisual.value = {
...dragVisual.value,
x: location.current.input.clientX,
y: location.current.input.clientY,
}
}
},
onDropTargetChange({ source, location }) {
const kind = source.data.kanbanKind as "item" | "column"
const dropTargets = location.current.dropTargets.filter(
(target) => target.data.kanbanInstance === instanceId
)
if (kind === "item") {
const itemTarget = dropTargets.find((target) => target.data.kanbanKind === "item")
if (itemTarget) {
const edge = extractClosestEdge(itemTarget.data)
dropIndicator.value = edge
? { kind: "item", value: itemTarget.data.kanbanValue as string, edge }
: null
} else {
dropIndicator.value = null
}
} else {
const columnTarget = dropTargets.find((target) => target.data.kanbanKind === "column")
if (columnTarget) {
const edge = extractClosestEdge(columnTarget.data)
dropIndicator.value = edge
? { kind: "column", value: columnTarget.data.kanbanValue as string, edge }
: null
} else {
dropIndicator.value = null
}
}
},
onDrop({ source, location }) {
const kind = source.data.kanbanKind as "item" | "column"
const activeValue = source.data.kanbanValue as string
const dropTargets = location.current.dropTargets.filter(
(target) => target.data.kanbanInstance === instanceId
)
if (kind === "column") {
const columnTarget = dropTargets.find((target) => target.data.kanbanKind === "column")
commitColumnDrop(activeValue, columnTarget)
} else {
const activeContainer = source.data.kanbanContainer as string
commitItemDrop(activeValue, activeContainer, dropTargets)
}
},
}),
el
? autoScrollForElements({
element: el,
canScroll: ({ source }) => source.data.kanbanInstance === instanceId,
})
: () => {},
]
stopEngine = combine(...teardown)
})
onBeforeUnmount(() => {
stopEngine?.()
})
</script>
<template>
<Primitive
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="kanban"
:data-dragging="activeId !== null"
:class="cn(activeId !== null && 'cursor-grabbing!', props.class)"
>
<slot />
</Primitive>
<!-- Аналог DndContext screen-reader instructions (aria-describedby). -->
<p :id="describedById" class="sr-only">
To pick up a kanban card or column, press space or enter. While dragging,
use the arrow keys to move it. Press space or enter again to drop it in
its new position, or press escape to cancel.
</p>
<!-- Аналог accessibility.announcements dnd-kit: живая область для скринридера. -->
<div role="status" aria-live="assertive" class="sr-only">{{ liveMessage }}</div>
</template>
src/reui/kanban/KanbanBoard.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanBoard.
*
* Оригинал оборачивает детей в dnd-kit `SortableContext` (список
* `columnIds`, `rectSortingStrategy`) — это чисто декларативная область
* видимости коллизий/стратегии сортировки, у Pragmatic ей ничего не
* соответствует: каждая колонка регистрирует свой `draggable()`/
* `dropTargetForElements()` самостоятельно (см. KanbanColumn.vue) и
* привязывается к общему `instanceId` из `<Kanban>`, а не к обёртке
* `KanbanBoard`. Поэтому здесь остаётся только разметка.
*/
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
asChild?: boolean
}>(),
{
asChild: false,
}
)
</script>
<template>
<Primitive
:as="'div'"
:as-child="asChild"
data-slot="kanban-board"
:class="cn('grid auto-rows-fr gap-4 sm:grid-cols-3', props.class)"
>
<slot />
</Primitive>
</template>
src/reui/kanban/KanbanColumn.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanColumn. См. Kanban.vue/context.ts — замена dnd-kit
* `useSortable()` на `draggable()` + `dropTargetForElements()` +
* `attachClosestEdge` (allowedEdges: left/right — колонки выстроены в
* ряд) из `@atlaskit/pragmatic-drag-and-drop-hitbox`.
*
* Как и в оригинале: колонка перетаскивается мышью/тачем только через
* `KanbanColumnHandle` (`dragHandle`); сама колонка всегда остаётся целью
* дропа — и для карточек, приземляющихся в конец списка через
* `KanbanColumnContent` (см. этот файл), и для других колонок при
* переупорядочивании.
*
* Буквально воспроизведена особенность оригинала: `isColumnDragging`,
* попадающий в `ColumnContext` (и оттуда — в `data-dragging`/
* `aria-pressed` у `KanbanColumnHandle`), — это "перетаскивается ЛЮБАЯ
* колонка", а не именно эта (`activeId ? isColumn(activeId) : false` в
* оригинале, без сравнения с `value` этой колонки). Собственный
* `data-dragging` DOM-узла колонки при этом точный (сравнение с `value`).
*/
import type { HTMLAttributes } from "vue"
import { computed, inject, onBeforeUnmount, provide, ref, watchEffect } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { cn } from "@/lib/utils"
import { createKeyboardDragHandler } from "@/lib/dnd/keyboard-drag"
import { KanbanColumnContextKey, KanbanInternalContextKey, useIsOverlay } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
value: string
disabled?: boolean
asChild?: boolean
}>(),
{
asChild: false,
}
)
const isOverlay = useIsOverlay()
const internal = inject(KanbanInternalContextKey, undefined)
const { forwardRef, currentElement } = useForwardExpose()
const isSortableDragging = computed(
() => !isOverlay && internal?.activeId.value === props.value
)
const isColumnDragging = computed(() =>
internal?.activeId.value ? internal.isColumn(internal.activeId.value) : false
)
const handleElement = ref<HTMLElement | null>(null)
function registerHandle(element: HTMLElement | null) {
handleElement.value = element
}
// Как и `useSortable()` в оригинале (`useDraggable({disabled})` +
// безусловный `useDroppable()`): `disabled` выключает только СОБСТВЕННОЕ
// перетаскивание колонки, но не её роль дропзоны — колонка (в том числе
// disabled) остаётся и целью переупорядочивания других колонок, и
// запасной "приземлиться в конец списка / в пустую колонку" целью для
// карточек (см. KanbanItem.vue: если под курсором нет конкретной
// карточки, используется этот же dropTarget с `kanbanKind: "column"`).
if (!isOverlay) {
watchEffect((onCleanup) => {
const element = currentElement.value as HTMLElement | null
if (!element || !internal) return
const cleanups: Array<() => void> = [
dropTargetForElements({
element,
// Исключаем колонку из целей для её же собственного перетаскивания
// (иначе она остаётся "залипшей" целью самой себя во время
// наведения — тот же приём, что и self-исключение в KanbanItem.vue;
// не влияет на карточки, приземляющиеся в эту колонку как
// запасную цель, — для них `source.data.kanbanValue` это id
// карточки, а не этой колонки).
canDrop: ({ source }) => source.data.kanbanValue !== props.value,
getData: ({ input, element: target }) =>
attachClosestEdge(
{
kanbanInstance: internal.instanceId,
kanbanKind: "column",
kanbanValue: props.value,
},
{ element: target, input, allowedEdges: ["left", "right"] }
),
}),
]
if (!props.disabled) {
cleanups.push(
draggable({
element,
dragHandle: handleElement.value ?? undefined,
getInitialData: ({ input }) => {
const rect = element.getBoundingClientRect()
return {
kanbanInstance: internal.instanceId,
kanbanKind: "column",
kanbanValue: props.value,
kanbanGrabOffset: {
x: input.clientX - rect.left,
y: input.clientY - rect.top,
width: rect.width,
height: rect.height,
},
}
},
})
)
}
const stop = combine(...cleanups)
onCleanup(stop)
})
}
// --- клавиатурная доступность (см. KanbanColumnHandle.vue) -----------------
// Каждая стрелка коммитит перестановку немедленно (см.
// moveColumnByKeyboard в Kanban.vue), а не откладывает её до отпускания,
// как dnd-kit — `Escape` поэтому реализован как восстановление снимка
// `value`, снятого в момент захвата (тот же приём, что и в
// SortableItem.vue).
const grabbed = ref(false)
let snapshotOnGrab: unknown = null
const dispatchKeydown = createKeyboardDragHandler({
isGrabbed: () => grabbed.value,
onGrab: () => {
grabbed.value = true
snapshotOnGrab = internal!.getSnapshot()
internal!.activeId.value = props.value
internal!.announce("Picked up kanban column.")
},
onMove: (direction) => {
internal!.moveColumnByKeyboard(props.value, direction)
},
onDrop: () => {
grabbed.value = false
internal!.activeId.value = null
internal!.announce("Kanban column dropped.")
},
onCancel: () => {
grabbed.value = false
internal!.activeId.value = null
internal!.restoreSnapshot(snapshotOnGrab)
},
})
function handleKeydown(event: KeyboardEvent) {
if (props.disabled || isOverlay || !internal) return
dispatchKeydown(event)
}
provide(KanbanColumnContextKey, {
listeners: isOverlay ? undefined : { onKeydown: handleKeydown },
isDragging: isOverlay ? true : isColumnDragging.value,
disabled: isOverlay ? false : props.disabled,
registerHandle,
})
onBeforeUnmount(() => {
grabbed.value = false
registerHandle(null)
})
</script>
<template>
<Primitive
v-if="isOverlay"
:as="'div'"
:as-child="asChild"
data-slot="kanban-column"
:data-value="value"
:data-dragging="true"
:class="cn('group/kanban-column flex flex-col', props.class)"
>
<slot />
</Primitive>
<Primitive
v-else
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="kanban-column"
:data-value="value"
:data-dragging="isSortableDragging"
:data-disabled="disabled"
:class="
cn(
'group/kanban-column flex flex-col',
isSortableDragging && 'z-50 opacity-50',
disabled && 'opacity-50',
props.class
)
"
>
<slot />
</Primitive>
</template>
src/reui/kanban/KanbanColumnContent.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanColumnContent.
*
* Оригинал оборачивает детей в dnd-kit `SortableContext` (список id
* карточек этой колонки, `verticalListSortingStrategy`) — у Pragmatic
* этому ничего не соответствует напрямую (см. KanbanBoard.vue): здесь
* остаётся только разметка и передача id колонки вниз по дереву
* (`KanbanColumnIdContextKey`), чтобы `KanbanItem` не пересчитывал
* `findContainer()`.
*
* "Приземление в конец списка / в пустую колонку" (когда курсор над
* пустым местом колонки, а не над конкретной карточкой) в оригинале — это
* НЕ отдельная дропзона контента, а попадание `over.id` на id самой
* колонки: `useSortable()` у `KanbanColumn` регистрирует droppable
* безусловно (dnd-kit `useDroppable` внутри `useSortable` не зависит от
* `disabled`), и его прямоугольник — это вся колонка целиком (шапка +
* контент), а не только контент. Порт воспроизводит это буквально: у
* `KanbanColumnContent` своего dropTarget нет, запасной целью служит
* dropTarget самой `KanbanColumn` (`kanbanKind: "column"`) — см.
* `commitItemDrop`/`onDropTargetChange` в Kanban.vue.
*/
import type { HTMLAttributes } from "vue"
import { provide, watchEffect } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { cn } from "@/lib/utils"
import { KanbanColumnIdContextKey, useKanbanInternalContext } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
value: string
asChild?: boolean
}>(),
{
asChild: false,
}
)
const internal = useKanbanInternalContext()
const { forwardRef } = useForwardExpose()
watchEffect(() => {
if (!internal) return
if (!(props.value in internal.columns.value)) {
throw new Error(
`KanbanColumnContent: column "${props.value}" was not found in the Kanban value. ` +
`Available columns: ${Object.keys(internal.columns.value).join(", ") || "(none)"}.`
)
}
})
provide(KanbanColumnIdContextKey, props.value)
</script>
<template>
<Primitive
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="kanban-column-content"
:class="cn('flex flex-col gap-2', props.class)"
>
<slot />
</Primitive>
</template>
src/reui/kanban/KanbanColumnHandle.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanColumnHandle.
*
* Как и SortableItemHandle.vue: единственный узел, получающий фокус и
* обрабатывающий клавиши (role, tabindex, aria-roledescription/pressed/
* describedby, keydown). Мышиное/тачевое перетаскивание обеспечивает
* `draggable()` в KanbanColumn.vue через `dragHandle` — Handle лишь
* регистрирует свой DOM-узел вверх по дереву через `registerHandle`.
*/
import type { HTMLAttributes } from "vue"
import { onBeforeUnmount, watch } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { cn } from "@/lib/utils"
import { useKanbanColumnContext, useKanbanInternalContext } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
cursor?: boolean
asChild?: boolean
}>(),
{
cursor: true,
asChild: false,
}
)
const { listeners, isDragging, disabled, registerHandle } = useKanbanColumnContext()
const internal = useKanbanInternalContext()
const { forwardRef, currentElement } = useForwardExpose()
watch(
currentElement,
(element) => {
registerHandle((element as HTMLElement | null) ?? null)
},
{ immediate: true }
)
onBeforeUnmount(() => {
registerHandle(null)
})
</script>
<template>
<Primitive
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="kanban-column-handle"
:data-dragging="isDragging"
:data-disabled="disabled"
:role="listeners ? 'button' : undefined"
:aria-roledescription="listeners ? 'sortable' : undefined"
:aria-disabled="disabled || undefined"
:aria-pressed="isDragging || undefined"
:aria-describedby="listeners ? internal?.describedById : undefined"
:tabindex="listeners && !disabled ? 0 : undefined"
@keydown="listeners?.onKeydown"
:class="
cn(
'opacity-0 transition-opacity group-hover/kanban-column:opacity-100',
cursor && (isDragging ? 'cursor-grabbing!' : 'cursor-grab!'),
props.class
)
"
>
<slot />
</Primitive>
</template>
src/reui/kanban/KanbanItem.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanItem. См. Kanban.vue/context.ts.
*
* Замена dnd-kit `useSortable()` на `draggable()` + `dropTargetForElements()`
* + `attachClosestEdge` (allowedEdges: top/bottom — карточки уложены
* колонкой). `kanbanContainer` (id родительской колонки) берётся из
* `KanbanColumnIdContextKey`, который провайдит `KanbanColumnContent`.
*
* Буквально воспроизведена особенность оригинала: `isItemDragging`,
* попадающий в `ItemContext` (и оттуда — в `data-dragging`/`aria-pressed`
* у `KanbanItemHandle`), — это "перетаскивается ЛЮБАЯ карточка", а не
* именно эта (`activeId ? !isColumn(activeId) : false` в оригинале, без
* сравнения с `value` этой карточки) — см. тот же приём в KanbanColumn.vue.
* Собственный `data-dragging` DOM-узла карточки при этом точный.
*
* Индикатор места вставки (`data-drop-indicator`) — то, чего не было у
* Sortable: пока карточка перетаскивается над этой (см. `dropIndicator` в
* Kanban.vue, вычисляемый через `extractClosestEdge` в едином
* `onDrag`), рисуется полоса сверху/снизу. Это UI-надстройка сверх
* оригинала (недостижимая на dnd-kit-эквиваленте без него), она не
* является частью визуального гейта (виден только в покое, без
* перетаскивания) и не меняет ни один класс из cva/`cn()` оригинала.
*/
import type { HTMLAttributes } from "vue"
import { computed, inject, onBeforeUnmount, provide, ref, watchEffect } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
import { cn } from "@/lib/utils"
import { createKeyboardDragHandler } from "@/lib/dnd/keyboard-drag"
import {
KanbanColumnIdContextKey,
KanbanInternalContextKey,
KanbanItemContextKey,
useIsOverlay,
} from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
value: string
disabled?: boolean
asChild?: boolean
}>(),
{
asChild: false,
}
)
const isOverlay = useIsOverlay()
const internal = inject(KanbanInternalContextKey, undefined)
const containerValue = inject(KanbanColumnIdContextKey, undefined)
const { forwardRef, currentElement } = useForwardExpose()
const isSortableDragging = computed(
() => !isOverlay && internal?.activeId.value === props.value
)
const isItemDragging = computed(() =>
internal?.activeId.value ? !internal.isColumn(internal.activeId.value) : false
)
const dropIndicatorEdge = computed(() => {
const indicator = internal?.dropIndicator.value
if (!indicator || indicator.kind !== "item" || indicator.value !== props.value) return null
return indicator.edge
})
const handleElement = ref<HTMLElement | null>(null)
function registerHandle(element: HTMLElement | null) {
handleElement.value = element
}
if (!isOverlay) {
watchEffect((onCleanup) => {
const element = currentElement.value as HTMLElement | null
const container = containerValue
if (!element || !internal || !container || props.disabled) return
const stop = combine(
draggable({
element,
dragHandle: handleElement.value ?? undefined,
getInitialData: ({ input }) => {
const rect = element.getBoundingClientRect()
return {
kanbanInstance: internal.instanceId,
kanbanKind: "item",
kanbanValue: props.value,
kanbanContainer: container,
kanbanGrabOffset: {
x: input.clientX - rect.left,
y: input.clientY - rect.top,
width: rect.width,
height: rect.height,
},
}
},
}),
dropTargetForElements({
element,
// Исключаем карточку из целей для её же собственного
// перетаскивания: без этого во время наведения на СОСЕДНИЕ
// карточки Pragmatic периодически "залипает" на источнике (виден
// как ложный устойчивый `dropTarget` на самой перетаскиваемой
// карточке) — по всем признакам частный случай общего для
// Chromium/CDP-driven синтетических drag-жестов бага с
// `elementFromPoint()` во время активного HTML5 drag (см.
// `@atlaskit/pragmatic-drag-and-drop/honey-pot-fix`, который решает
// соседнюю разновидность той же проблемы для `:hover`). Явное
// исключение "себя" из `canDrop` полностью снимает эффект здесь.
canDrop: ({ source }) => source.data.kanbanValue !== props.value,
getData: ({ input, element: target }) =>
attachClosestEdge(
{
kanbanInstance: internal.instanceId,
kanbanKind: "item",
kanbanValue: props.value,
kanbanContainer: container,
},
{ element: target, input, allowedEdges: ["top", "bottom"] }
),
})
)
onCleanup(stop)
})
}
// --- клавиатурная доступность (см. KanbanItemHandle.vue) -------------------
const grabbed = ref(false)
let snapshotOnGrab: unknown = null
const dispatchKeydown = createKeyboardDragHandler({
isGrabbed: () => grabbed.value,
onGrab: () => {
grabbed.value = true
snapshotOnGrab = internal!.getSnapshot()
internal!.activeId.value = props.value
internal!.announce("Picked up kanban item.")
},
onMove: (direction) => {
internal!.moveItemByKeyboard(props.value, direction)
},
onDrop: () => {
grabbed.value = false
internal!.activeId.value = null
internal!.announce("Kanban item dropped.")
},
onCancel: () => {
grabbed.value = false
internal!.activeId.value = null
internal!.restoreSnapshot(snapshotOnGrab)
},
})
function handleKeydown(event: KeyboardEvent) {
if (props.disabled || isOverlay || !internal) return
dispatchKeydown(event)
}
provide(KanbanItemContextKey, {
listeners: isOverlay ? undefined : { onKeydown: handleKeydown },
isDragging: isOverlay ? true : isItemDragging.value,
disabled: isOverlay ? false : props.disabled,
registerHandle,
})
onBeforeUnmount(() => {
grabbed.value = false
registerHandle(null)
})
</script>
<template>
<Primitive
v-if="isOverlay"
:as="'div'"
:as-child="asChild"
data-slot="kanban-item"
:data-value="value"
:data-dragging="true"
:class="cn(props.class)"
>
<slot />
</Primitive>
<Primitive
v-else
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="kanban-item"
:data-value="value"
:data-dragging="isSortableDragging"
:data-disabled="disabled"
:data-drop-indicator="dropIndicatorEdge ?? undefined"
:class="
cn(
isSortableDragging && 'z-50 opacity-50',
disabled && 'opacity-50',
dropIndicatorEdge === 'top' && 'shadow-[inset_0_2px_0_0_var(--color-primary)]',
dropIndicatorEdge === 'bottom' && 'shadow-[inset_0_-2px_0_0_var(--color-primary)]',
props.class
)
"
>
<slot />
</Primitive>
</template>
src/reui/kanban/KanbanItemHandle.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanItemHandle. См. KanbanColumnHandle.vue/SortableItemHandle.vue
* — тот же приём: единственный узел, получающий фокус и обрабатывающий
* клавиши; мышиное/тачевое перетаскивание обеспечивает `draggable()` в
* KanbanItem.vue через `dragHandle`.
*/
import type { HTMLAttributes } from "vue"
import { onBeforeUnmount, watch } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { cn } from "@/lib/utils"
import { useKanbanInternalContext, useKanbanItemContext } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
cursor?: boolean
asChild?: boolean
}>(),
{
cursor: true,
asChild: false,
}
)
const { listeners, isDragging, disabled, registerHandle } = useKanbanItemContext()
const internal = useKanbanInternalContext()
const { forwardRef, currentElement } = useForwardExpose()
watch(
currentElement,
(element) => {
registerHandle((element as HTMLElement | null) ?? null)
},
{ immediate: true }
)
onBeforeUnmount(() => {
registerHandle(null)
})
</script>
<template>
<Primitive
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="kanban-item-handle"
:data-dragging="isDragging"
:data-disabled="disabled"
:role="listeners ? 'button' : undefined"
:aria-roledescription="listeners ? 'sortable' : undefined"
:aria-disabled="disabled || undefined"
:aria-pressed="isDragging || undefined"
:aria-describedby="listeners ? internal?.describedById : undefined"
:tabindex="listeners && !disabled ? 0 : undefined"
@keydown="listeners?.onKeydown"
:class="cn(cursor && (isDragging ? 'cursor-grabbing!' : 'cursor-grab!'), props.class)"
>
<slot />
</Primitive>
</template>
src/reui/kanban/KanbanOverlay.vue
<script setup lang="ts">
/**
* Порт ReUI KanbanOverlay. См. SortableOverlay.vue — то же устройство
* (Teleport в `body`, позиционирование через общий `dragOverlayStyle` из
* внутреннего контекста), с добавлением `variant`: оригинал передаёт в
* render-prop `{ value, variant: "column" | "item" }`, чтобы потребитель
* мог отрисовать нужный вид карточки/колонки под курсором. Vue-эквивалент
* — скоуп-слот `#default="{ value, variant }"`.
*/
import type { HTMLAttributes } from "vue"
import { computed, defineComponent, onMounted, provide, ref } from "vue"
import { cn } from "@/lib/utils"
import { IsOverlayContextKey, useKanbanInternalContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const internal = useKanbanInternalContext()
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
const activeId = computed(() => internal?.activeId.value ?? null)
const variant = computed<"column" | "item">(() =>
activeId.value && internal?.isColumn(activeId.value) ? "column" : "item"
)
const dragOverlayStyle = computed(() => internal?.dragOverlayStyle.value ?? null)
const OverlayHost = defineComponent({
name: "KanbanOverlayHost",
setup(_, { slots }) {
provide(IsOverlayContextKey, true)
return () => slots.default?.()
},
})
</script>
<template>
<Teleport v-if="mounted" to="body">
<div
:class="cn('z-50', activeId && 'cursor-grabbing', props.class)"
:style="dragOverlayStyle ?? undefined"
>
<OverlayHost v-if="activeId">
<slot :value="activeId" :variant="variant" />
</OverlayHost>
</div>
</Teleport>
</template>
src/reui/kanban/context.ts
import type { ComputedRef, InjectionKey, Ref } from "vue"
import { inject } from "vue"
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
/**
* Порт ReUI Kanban (registry-reui/bases/radix/reui/kanban.tsx, MIT).
*
* ЭТО НЕ ПОРТ DND-ДВИЖКА, А ПЕРЕПИСЫВАНИЕ (см. docs/adr/002-drag-and-drop.md
* и подробный разбор в packages/ui/src/reui/sortable/Sortable.vue — тот же
* подход: per-item `draggable()` + `dropTargetForElements()`, один
* `monitorForElements()` на весь `<Kanban>`, курсор-оверлей через Teleport,
* рукописная клавиатурная машина состояний общая с Sortable
* (`../../lib/dnd/keyboard-drag.ts`)).
*
* Kanban сложнее Sortable в двух местах:
*
* 1. Перемещения МЕЖДУ контейнерами (несколько колонок, общие данные), а
* не только переупорядочивание внутри одного списка — `moveBetween-
* Containers` в `../../lib/dnd/reorder.ts` обобщает `reorderArray` на
* `Record<string, T[]>`.
*
* 2. Реальный индикатор места вставки между карточками —
* `attachClosestEdge`/`extractClosestEdge` из
* `@atlaskit/pragmatic-drag-and-drop-hitbox` вешаются на dropTarget
* каждой карточки (allowedEdges: top/bottom) и колонки при
* перетаскивании колонок (allowedEdges: left/right). У Sortable это не
* требовалось: там `over` (см. комментарий в Sortable.vue) достаточно
* само по себе, потому что нет вопроса "до или после какой половины".
*
* Осознанное упрощение относительно оригинала (в пределах ADR-002 — граница
* эквивалентности требует совпадения ИТОГОВОГО состояния доски, а не
* визуального поведения во время наведения): оригинал на dnd-kit
* пересчитывает и перерисовывает колонки уже во время `dragOver` (живой
* предпросмотр "карточка уже переехала", пока курсор ещё не отпущен) —
* нативный HTML5 DnD, на котором построен Pragmatic, не даёт дёшево менять
* DOM-порядок под чужим системным жестом без разрывов трека перетаскивания.
* Здесь вместо этого: во время наведения обновляется только визуальный
* индикатор вставки (полоса от `attachClosestEdge`), а фактическая
* перестановка данных происходит один раз, в `onDrop` — итоговый результат
* (какая карточка в какой колонке и в каком порядке) идентичен, разница
* только в том, что колонки не "едут" ещё до отпускания кнопки мыши.
*/
export type KanbanColumns<T> = Record<string, T[]>
/** Синтетическое событие вместо dnd-kit `DragStartEvent`/`DragEndEvent`/`DragCancelEvent`. */
export interface KanbanDragEvent {
active: { id: string }
over: { id: string } | null
}
export interface KanbanMoveEvent {
event: KanbanDragEvent
activeContainer: string
activeIndex: number
overContainer: string
overIndex: number
}
export interface KanbanCommitMeta<T> {
kind: "item" | "column"
event: KanbanDragEvent
activeContainer: string
activeIndex: number
overContainer: string
overIndex: number
previousValue: KanbanColumns<T>
}
/** Индикатор места вставки, рисуемый на карточке/колонке при наведении. */
export interface KanbanDropIndicator {
kind: "item" | "column"
value: string
edge: Edge
}
export interface KanbanInternalContextValue {
columns: ComputedRef<KanbanColumns<unknown>>
getItemId: (item: unknown) => string
columnIds: ComputedRef<string[]>
activeId: Ref<string | null>
/** Уникальный id инстанса — ограничивает monitor/дроп своим `<Kanban>`. */
instanceId: symbol
isColumn: (id: string) => boolean
findContainer: (id: string) => string | undefined
dropIndicator: Ref<KanbanDropIndicator | null>
/** Клавиатурное перемещение карточки на шаг внутри её текущей колонки. */
moveItemByKeyboard: (itemValue: string, direction: -1 | 1) => void
/** Клавиатурное перемещение колонки на шаг среди колонок доски. */
moveColumnByKeyboard: (columnValue: string, direction: -1 | 1) => void
getSnapshot: () => unknown
restoreSnapshot: (snapshot: unknown) => void
announce: (message: string) => void
describedById: string
dragOverlayStyle: ComputedRef<Record<string, string | number> | null>
}
export interface KanbanColumnContextValue {
listeners:
| {
onKeydown: (event: KeyboardEvent) => void
}
| undefined
isDragging?: boolean
disabled?: boolean
registerHandle: (element: HTMLElement | null) => void
}
export interface KanbanItemContextValue {
listeners:
| {
onKeydown: (event: KeyboardEvent) => void
}
| undefined
isDragging?: boolean
disabled?: boolean
registerHandle: (element: HTMLElement | null) => void
}
export const KanbanInternalContextKey: InjectionKey<KanbanInternalContextValue> =
Symbol("KanbanInternalContext")
export const KanbanColumnContextKey: InjectionKey<KanbanColumnContextValue> =
Symbol("KanbanColumnContext")
export const KanbanItemContextKey: InjectionKey<KanbanItemContextValue> =
Symbol("KanbanItemContext")
export const IsOverlayContextKey: InjectionKey<boolean> = Symbol("IsOverlayContext")
/**
* Id колонки, в которую вложен текущий `KanbanItem` — предоставляется
* `KanbanColumnContent` (у которого он уже есть явным пропом `value`) и
* читается `KanbanItem`, чтобы не пересчитывать `findContainer()` на
* каждый рендер и не требовать от потребителя дублировать имя колонки на
* каждой карточке. Прямого аналога в оригинале нет: там принадлежность
* карточки колонке всегда выводится через `findContainer(id)` по данным
* `columns`, а не через React-контекст, но результат идентичен.
*/
export const KanbanColumnIdContextKey: InjectionKey<string> = Symbol("KanbanColumnId")
const defaultColumnContext: KanbanColumnContextValue = {
listeners: undefined,
isDragging: false,
disabled: false,
registerHandle: () => {},
}
const defaultItemContext: KanbanItemContextValue = {
listeners: undefined,
isDragging: false,
disabled: false,
registerHandle: () => {},
}
/** Соответствует `useContext(ColumnContext)` в оригинале — не бросает. */
export function useKanbanColumnContext(): KanbanColumnContextValue {
return inject(KanbanColumnContextKey, defaultColumnContext)
}
/** Соответствует `useContext(ItemContext)` в оригинале — не бросает. */
export function useKanbanItemContext(): KanbanItemContextValue {
return inject(KanbanItemContextKey, defaultItemContext)
}
/** Соответствует `useContext(IsOverlayContext)` — дефолт `false`. */
export function useIsOverlay(): boolean {
return inject(IsOverlayContextKey, false)
}
/** Соответствует `useContext(KanbanContext)` — используется KanbanOverlay/KanbanBoard/KanbanColumnContent. */
export function useKanbanInternalContext(): KanbanInternalContextValue | undefined {
return inject(KanbanInternalContextKey, undefined)
}
/** Бросает, если `<Kanban>`-предок не найден — используется там, где контекст обязателен. */
export function useRequiredKanbanInternalContext(): KanbanInternalContextValue {
const ctx = inject(KanbanInternalContextKey, undefined)
if (!ctx) {
throw new Error("Kanban subcomponents must be used within <Kanban>.")
}
return ctx
}
src/reui/kanban/index.ts
// Примечание: useIsOverlay и IsOverlayContextKey намеренно НЕ реэкспортируются
// отсюда — это общие для dnd символы, их единственный источник — reui/sortable.
// Иначе `export *` из обоих барелей даёт коллизию имён в публичном API.
export { default as Kanban } from "./Kanban.vue"
export { default as KanbanBoard } from "./KanbanBoard.vue"
export { default as KanbanColumn } from "./KanbanColumn.vue"
export { default as KanbanColumnHandle } from "./KanbanColumnHandle.vue"
export { default as KanbanColumnContent } from "./KanbanColumnContent.vue"
export { default as KanbanItem } from "./KanbanItem.vue"
export { default as KanbanItemHandle } from "./KanbanItemHandle.vue"
export { default as KanbanOverlay } from "./KanbanOverlay.vue"
export {
useKanbanColumnContext,
useKanbanInternalContext,
useKanbanItemContext,
KanbanColumnIdContextKey,
KanbanInternalContextKey,
KanbanColumnContextKey,
KanbanItemContextKey,
type KanbanColumnContextValue,
type KanbanColumns,
type KanbanCommitMeta,
type KanbanDragEvent,
type KanbanDropIndicator,
type KanbanInternalContextValue,
type KanbanItemContextValue,
type KanbanMoveEvent,
} from "./context"
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/kanban.jsonЗависимости реестра
npm-зависимости
- @atlaskit/pragmatic-drag-and-drop
- @atlaskit/pragmatic-drag-and-drop-auto-scroll
- @atlaskit/pragmatic-drag-and-drop-hitbox
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)