reui
Sortable
Sortable — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/sortable/Sortable.vue
<script setup lang="ts" generic="T">
/**
* Порт ReUI Sortable (registry-reui/bases/radix/reui/sortable.tsx, MIT).
* См. context.ts для общего описания замены dnd-kit -> Pragmatic
* Drag and Drop (ADR-002) и итоговый отчёт порта для полной таблицы
* соответствий понятий.
*
* Устройство (коротко):
* - каждый SortableItem регистрирует себя как `draggable()` (источник,
* ручка — DOM-узел SortableItemHandle, если он есть — иначе элемент
* не перетаскивается мышью/пальцем, как и в оригинале, где `listeners`
* попадают только на Handle) и как `dropTargetForElements()` (цель).
* - ОДИН `monitorForElements()` на весь `<Sortable>` играет роль
* `DndContext` оригинала: именно здесь, а не в самом Item, решается,
* что произошло при отпускании (совпадает по архитектуре с оригиналом,
* где `handleDragEnd` живёт в `Sortable`, а не в `SortableItem`).
* - `over` в оригинале — результат коллизии dnd-kit (по умолчанию
* `rectIntersection`): элемент, чей прямоугольник сейчас перекрывает
* перетаскиваемый. У Pragmatic это ровно то же самое естественным
* образом: `location.current.dropTargets[0]` — самая "верхняя" цель
* под курсором в момент отпускания. Поэтому конкретная половина/грань
* цели (`@atlaskit/pragmatic-drag-and-drop-hitbox`) для порядка
* элементов не нужна — она понадобится в Kanban/Gantt для рисования
* полосы-индикатора между карточками, но не меняет итоговый индекс.
* - `arrayMove` -> `reorderArray` ниже: идентичная семантика (тот же
* алгоритм, что и `@atlaskit/pragmatic-drag-and-drop/reorder`).
* - `DragOverlay` (плавающая копия под курсором) у dnd-kit — чисто
* JS-позиционирование, независимое от нативного D&D. Нативный HTML5
* Drag and Drop, на котором работает Pragmatic, использует свою
* OS-овую "картинку перетаскивания": её нельзя перерисовывать по
* кадрам как произвольный Vue-компонент. Здесь это воспроизведено
* так: нативная картинка гасится (`nativeSetDragImage` на прозрачный
* пиксель), а вместо неё показывается обычный `position: fixed`
* Teleport-оверлей, следующий за курсором по координатам из
* `onDrag`/`location.current.input` — визуально то же самое, что и
* `DragOverlay`, но координаты обновляются per-native-`drag`-event, а
* не per-pointer-move, так что курсор может ощущаться чуть менее
* "рессорным". Это попадает в допустимое ADR-002 расхождение
* ("кривые и физика инерции").
* - Клавиатурная доступность не завязана на нативный D&D (у него нет
* клавиатурного эквивалента) и реализована отдельно, см.
* `moveByKeyboard` и SortableItemHandle.vue: `Space`/`Enter` — взять
* элемент, стрелки — подвинуть на шаг (с сохранением того же
* итогового результата, что и у dnd-kit `sortableKeyboardCoordinates`,
* но фиксацией на каждый шаг, а не отложенно до отпускания —
* итоговый порядок совпадает, см. отчёт порта), `Escape` — отмена с
* восстановлением снимка, `Space`/`Enter` повторно — подтверждение.
*/
import type { HTMLAttributes, VNode } from "vue"
import {
computed,
defineComponent,
onBeforeUnmount,
onMounted,
provide,
ref,
Teleport,
useSlots,
} 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 { cn } from "@/lib/utils"
import { reorderArray } from "@/lib/dnd/reorder"
import { transparentPixel } from "@/lib/dnd/transparent-pixel"
import {
IsOverlayContextKey,
SortableInternalContextKey,
type SortableCommitMeta,
type SortableDragEvent,
type SortableMoveEvent,
type SortableStrategy,
} from "./context"
defineOptions({ inheritAttrs: true })
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
getItemValue: (item: T) => string
strategy?: SortableStrategy
asChild?: boolean
onMove?: (event: SortableMoveEvent) => void
onValueCommit?: (value: T[], meta: SortableCommitMeta<T>) => void
onDragStart?: (event: SortableDragEvent) => void
onDragEnd?: (event: SortableDragEvent) => void
onDragCancel?: (event: SortableDragEvent) => void
}>(),
{
strategy: "vertical",
asChild: false,
}
)
const value = defineModel<T[]>("value", { required: true })
const slots = useSlots()
const { forwardRef, currentElement } = useForwardExpose()
const instanceId = Symbol("sortable-instance")
const activeId = ref<string | null>(null)
const strategy = computed(() => props.strategy)
const mounted = ref(false)
const liveMessage = ref("")
const describedById = `sortable-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)
onMounted(() => {
mounted.value = true
})
// --- itemIds + предупреждение о дублях (как в оригинале, dev-only) --------
const itemIds = computed(() => {
const ids = value.value.map(props.getItemValue)
if (import.meta.env?.DEV) {
const seen = new Set<string>()
for (const id of ids) {
if (seen.has(id)) {
console.warn(
`[Sortable] Duplicate item id "${id}". Item ids must be unique, or drag and drop will misbehave.`
)
break
}
seen.add(id)
}
}
return ids
})
void itemIds
function announce(message: string) {
liveMessage.value = message
}
// --- регистрация элементов (draggable + dropTarget) ------------------------
const itemElements = new Map<string, HTMLElement>()
function registerItemElement(itemValue: string, element: HTMLElement) {
itemElements.set(itemValue, element)
}
function unregisterItemElement(itemValue: string) {
itemElements.delete(itemValue)
}
// --- центральный обработчик отпускания (аналог handleDragEnd в оригинале) -
function commitDrop(activeValue: string, overValue: string | null) {
const wasActive = activeId.value === activeValue
activeId.value = null
dragVisual.value = null
const syntheticEvent: SortableDragEvent = {
active: { id: activeValue },
over: overValue ? { id: overValue } : null,
}
if (wasActive) props.onDragEnd?.(syntheticEvent)
if (!overValue) return
const currentValue = value.value
const activeIndex = currentValue.findIndex(
(item) => props.getItemValue(item) === activeValue
)
const overIndex = currentValue.findIndex(
(item) => props.getItemValue(item) === overValue
)
if (activeIndex === -1 || overIndex === -1) return
if (activeIndex !== overIndex) {
if (props.onMove) {
props.onMove({ event: syntheticEvent, activeIndex, overIndex })
} else {
const newValue = reorderArray(currentValue, activeIndex, overIndex)
value.value = newValue
props.onValueCommit?.(newValue, {
event: syntheticEvent,
activeIndex,
overIndex,
previousValue: currentValue,
})
}
announce(`Sortable item was moved.`)
}
}
// --- клавиатурное перемещение -----------------------------------------------
function moveByKeyboard(activeValue: string, direction: -1 | 1) {
const currentValue = value.value
const activeIndex = currentValue.findIndex(
(item) => props.getItemValue(item) === activeValue
)
if (activeIndex === -1) return
const overIndex = activeIndex + direction
if (overIndex < 0 || overIndex >= currentValue.length) return
const overValue = props.getItemValue(currentValue[overIndex] as T)
if (props.onMove) {
props.onMove({
event: { active: { id: activeValue }, over: { id: overValue } },
activeIndex,
overIndex,
})
} else {
const newValue = reorderArray(currentValue, activeIndex, overIndex)
value.value = newValue
props.onValueCommit?.(newValue, {
event: { active: { id: activeValue }, over: { id: overValue } },
activeIndex,
overIndex,
previousValue: currentValue,
})
}
announce(`Sortable item moved to position ${overIndex + 1} of ${currentValue.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)`,
}
})
function getSnapshot(): unknown[] {
return value.value
}
function restoreSnapshot(items: unknown[]) {
value.value = items as T[]
announce("Sorting cancelled.")
}
provide(SortableInternalContextKey, {
activeId,
strategy,
instanceId,
registerItemElement,
unregisterItemElement,
moveByKeyboard,
announce,
describedById,
dragOverlayStyle,
getSnapshot,
restoreSnapshot,
})
provide(IsOverlayContextKey, false)
// --- monitor (аналог DndContext) + автоскролл ------------------------------
let stopEngine: (() => void) | undefined
onMounted(() => {
const el = currentElement.value
const teardown = [
monitorForElements({
canMonitor({ source }) {
return source.data.sortableInstance === instanceId
},
onGenerateDragPreview({ nativeSetDragImage }) {
// Гасим нативную картинку перетаскивания — вместо неё показываем
// собственный position:fixed оверлей (см. комментарий выше файла).
nativeSetDragImage?.(transparentPixel(), 0, 0)
},
onDragStart({ source, location }) {
const initialValue = source.data.sortableValue as string
const offset = source.data.sortableGrabOffset 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 = initialValue
props.onDragStart?.({ active: { id: initialValue }, over: null })
announce(`Picked up sortable item.`)
},
onDrag({ location }) {
if (!dragVisual.value) return
dragVisual.value = {
...dragVisual.value,
x: location.current.input.clientX,
y: location.current.input.clientY,
}
},
onDrop({ source, location }) {
const activeValue = source.data.sortableValue as string
const dropTargets = location.current.dropTargets.filter(
(target) => target.data.sortableInstance === instanceId
)
const overValue =
(dropTargets[0]?.data.sortableValue as string | undefined) ?? null
commitDrop(activeValue, overValue)
},
}),
el
? autoScrollForElements({
element: el,
canScroll: ({ source }) => source.data.sortableInstance === instanceId,
})
: () => {},
]
stopEngine = combine(...teardown)
})
onBeforeUnmount(() => {
stopEngine?.()
})
// --- контент оверлея по умолчанию: клонируем ребёнка с value === activeId -
const OverlaySlotHost = defineComponent({
name: "SortableOverlaySlotHost",
props: { nodes: { type: Array as () => VNode[], required: true } },
setup(hostProps) {
provide(IsOverlayContextKey, true)
return () => hostProps.nodes
},
})
const overlayContent = computed<VNode[] | null>(() => {
const id = activeId.value
if (!id) return null
const children = slots.default?.() ?? []
const match = children.find(
(child) => (child.props as Record<string, unknown> | null)?.value === id
)
if (!match) return null
return [match]
})
</script>
<template>
<Primitive
:ref="forwardRef"
:as="'div'"
:as-child="asChild"
data-slot="sortable"
: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 sortable item, press space or enter. While dragging, use the
arrow keys to move the item. Press space or enter again to drop the item
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>
<Teleport v-if="mounted" to="body">
<div
:class="cn('z-50', activeId && 'cursor-grabbing')"
:style="dragOverlayStyle ?? undefined"
>
<OverlaySlotHost v-if="overlayContent" :nodes="overlayContent" />
</div>
</Teleport>
</template>
src/reui/sortable/SortableItem.vue
<script setup lang="ts">
/**
* Порт ReUI SortableItem. См. Sortable.vue и context.ts — общее описание
* замены dnd-kit `useSortable()` на `@atlaskit/pragmatic-drag-and-drop`
* `draggable()` + `dropTargetForElements()`.
*
* Как и в оригинале, сам Item не перетаскивается мышью/тачем, если внутри
* него нет `SortableItemHandle` — `listeners` (здесь: DOM-узел ручки для
* `dragHandle`) существуют только через хендл. Item всегда остаётся целью
* дропа (`dropTargetForElements`), независимо от наличия ручки.
*
* Осознанное расхождение с оригиналом: в dnd-kit-версии `role`/`tabIndex`/
* `aria-*` (`attributes`) оседают на самом Item, а обработчики клавиатуры
* (`listeners`) — только на Handle; при отсутствии у Handle собственного
* tabIndex это два разных DOM-узла, и Tab уводит фокус на Item, где
* keydown никто не слушает — клавиатурное перетаскивание технически не
* гарантировано без ручной доработки потребителем. Поскольку ADR-002
* требует, чтобы клавиатурное перетаскивание РАБОТАЛО, а не просто
* "выглядело" как в оригинале, здесь всё интерактивное (role, tabIndex,
* aria-roledescription/pressed/describedby, keydown) сведено на Handle —
* единственный узел, который и получает фокус, и обрабатывает клавиши.
* Item несёт только структурные data-* атрибуты.
*/
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 { cn } from "@/lib/utils"
import { createKeyboardDragHandler } from "@/lib/dnd/keyboard-drag"
import {
SortableInternalContextKey,
SortableItemContextKey,
useIsOverlay,
} from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
value: string
disabled?: boolean
asChild?: boolean
}>(),
{
asChild: false,
}
)
const isOverlay = useIsOverlay()
const internal = inject(SortableInternalContextKey, undefined)
const { forwardRef, currentElement } = useForwardExpose()
const isDragging = computed(
() => !isOverlay && internal?.activeId.value === props.value
)
// --- регистрация draggable/dropTarget (пропускается для оверлей-клона) ----
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
if (!element || !internal || props.disabled) return
const stop = combine(
draggable({
element,
dragHandle: handleElement.value ?? undefined,
getInitialData: ({ input }) => {
const rect = element.getBoundingClientRect()
return {
sortableInstance: internal.instanceId,
sortableValue: props.value,
sortableGrabOffset: {
x: input.clientX - rect.left,
y: input.clientY - rect.top,
width: rect.width,
height: rect.height,
},
}
},
}),
dropTargetForElements({
element,
getData: () => ({
sortableInstance: internal.instanceId,
sortableValue: props.value,
}),
})
)
internal.registerItemElement(props.value, element)
onCleanup(() => {
internal.unregisterItemElement(props.value)
stop()
})
})
}
// --- клавиатурная доступность (см. SortableItemHandle.vue) -----------------
// Каждая стрелка коммитит перестановку немедленно (см. Sortable.vue), а не
// откладывает её до отпускания, как dnd-kit — итоговый результат совпадает
// (см. отчёт порта), но `Escape` поэтому реализован как восстановление
// снимка `value`, снятого в момент захвата, а не "отмена последнего шага".
const grabbed = ref(false)
let snapshotOnGrab: unknown[] = []
const dispatchKeydown = createKeyboardDragHandler({
isGrabbed: () => grabbed.value,
onGrab: () => {
grabbed.value = true
snapshotOnGrab = internal!.getSnapshot()
internal!.activeId.value = props.value
internal!.announce("Picked up sortable item.")
},
onMove: (direction) => {
internal!.moveByKeyboard(props.value, direction)
},
onDrop: () => {
grabbed.value = false
internal!.activeId.value = null
internal!.announce("Sortable 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(SortableItemContextKey, {
listeners: isOverlay ? undefined : { onKeydown: handleKeydown },
isDragging: isOverlay ? true : isDragging.value,
disabled: isOverlay ? false : props.disabled,
registerHandle,
})
onBeforeUnmount(() => {
grabbed.value = false
})
</script>
<template>
<Primitive
v-if="isOverlay"
:as="'div'"
:as-child="asChild"
data-slot="sortable-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="sortable-item"
:data-value="value"
:data-dragging="isDragging"
:data-disabled="disabled"
:class="cn(isDragging && 'z-50 opacity-50', disabled && 'opacity-50', props.class)"
>
<slot />
</Primitive>
</template>
src/reui/sortable/SortableItemHandle.vue
<script setup lang="ts">
/**
* Порт ReUI SortableItemHandle.
*
* Держатель клавиатурной доступности порта (см. комментарий в верхней
* части SortableItem.vue): здесь, а не на Item, — `role="button"`,
* `tabindex`, `aria-roledescription="sortable"`, `aria-pressed`,
* `aria-disabled`, `aria-describedby` и обработчик `keydown`
* (`SortableItemContext.listeners` из оригинала). Мышиное/тачевое
* перетаскивание по-прежнему обеспечивает `draggable()` в SortableItem.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 { useSortableItemContext, useSortableInternalContext } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
cursor?: boolean
asChild?: boolean
}>(),
{
cursor: true,
asChild: false,
}
)
const { listeners, isDragging, disabled, registerHandle } = useSortableItemContext()
const internal = useSortableInternalContext()
const { forwardRef, currentElement } = useForwardExpose()
// Сообщаем DOM-узел ручки родительскому SortableItem — он использует его
// как `dragHandle` для `draggable()` (мышь/тач стартуют перетаскивание
// только с ручки, как и в оригинале, где `listeners` есть только здесь).
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="sortable-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/sortable/SortableOverlay.vue
<script setup lang="ts">
/**
* Порт ReUI SortableOverlay.
*
* Оригинал принимает `children` либо напрямую, либо как функцию
* `({ value }) => ReactNode`. Vue не различает эти два случая — обычный
* `<slot :value="activeId" />` покрывает оба: если потребитель не
* использует параметр слота, это просто содержимое; если использует
* `#default="{ value }"`, это render-prop.
*
* Позиционирование под курсором — тот же `dragOverlayStyle` из
* `SortableInternalContext`, что и у автогенерируемого оверлея в
* Sortable.vue (см. комментарий в начале Sortable.vue про нативный D&D
* вместо трансформов dnd-kit `DragOverlay`).
*/
import type { HTMLAttributes } from "vue"
import { computed, defineComponent, onMounted, provide, ref } from "vue"
import { cn } from "@/lib/utils"
import { IsOverlayContextKey, useSortableInternalContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const internal = useSortableInternalContext()
const mounted = ref(false)
onMounted(() => {
mounted.value = true
})
const activeId = computed(() => internal?.activeId.value ?? null)
const dragOverlayStyle = computed(() => internal?.dragOverlayStyle.value ?? null)
const OverlayHost = defineComponent({
name: "SortableOverlayHost",
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" />
</OverlayHost>
</div>
</Teleport>
</template>
src/reui/sortable/context.ts
import type { ComputedRef, InjectionKey, Ref } from "vue"
import { inject } from "vue"
/**
* Порт ReUI Sortable (registry-reui/bases/radix/reui/sortable.tsx, MIT).
*
* ЭТО НЕ ПОРТ DND-ДВИЖКА, А ПЕРЕПИСЫВАНИЕ (см. docs/adr/002-drag-and-drop.md).
* Оригинал построен на `@dnd-kit/core` + `@dnd-kit/sortable`: сенсоры
* (Mouse/Touch/KeyboardSensor), `DndContext`, `SortableContext` со
* стратегией коллизий, `useSortable()`, `DragOverlay` и `arrayMove`.
* Vue-порта dnd-kit нет, поэтому вся механика здесь построена заново на
* `@atlaskit/pragmatic-drag-and-drop` (нативный HTML5 Drag and Drop:
* `draggable()` + `dropTargetForElements()` + один `monitorForElements()`
* на весь `<Sortable>`, вместо сенсоров и `DndContext`) и
* `@atlaskit/pragmatic-drag-and-drop-auto-scroll` (замена автоскролла,
* который у dnd-kit встроен в сенсоры). Соответствие понятий подробно
* описано в комментариях Sortable.vue и в итоговом отчёте порта.
*
* Контексты здесь — прямой аналог `SortableItemContext` /
* `IsOverlayContext` / внутреннего контекста оригинала (`createContext`
* с дефолтами, не бросающий hook, как `useStepper()`/`useTree()`).
*/
export type SortableStrategy = "horizontal" | "vertical" | "grid"
/** Синтетическое событие вместо dnd-kit `DragStartEvent`/`DragEndEvent`/`DragCancelEvent`. */
export interface SortableDragEvent {
active: { id: string }
over: { id: string } | null
}
export interface SortableMoveEvent {
event: SortableDragEvent
activeIndex: number
overIndex: number
}
export interface SortableCommitMeta<T> {
event: SortableDragEvent
activeIndex: number
overIndex: number
previousValue: T[]
}
export interface SortableInternalContextValue {
activeId: Ref<string | null>
strategy: ComputedRef<SortableStrategy>
/** Уникальный id инстанса — ограничивает monitor/дроп своим `<Sortable>`. */
instanceId: symbol
registerItemElement: (value: string, element: HTMLElement) => void
unregisterItemElement: (value: string) => void
/** Клавиатурное перемещение на один шаг (аналог `sortableKeyboardCoordinates`). */
moveByKeyboard: (activeValue: string, direction: -1 | 1) => void
/**
* Снимок текущего `value` и его восстановление — используется
* `Escape`-отменой захвата (см. SortableItem.vue): каждое нажатие
* стрелки коммитит перестановку немедленно (см. Sortable.vue), поэтому
* отмена возвращает исходный массив целиком, а не "откатывает на шаг".
*/
getSnapshot: () => unknown[]
restoreSnapshot: (items: unknown[]) => void
/** Живая область объявлений для скринридера (аналог accessibility.announcements dnd-kit). */
announce: (message: string) => void
/** id элемента с инструкциями для `aria-describedby` (аналог `DndDescribedBy`). */
describedById: string
/**
* CSS для плавающего оверлея, следующего за курсором мыши (аналог
* трансформа `DragOverlay`). `null`, когда перетаскивания нет или оно
* инициировано с клавиатуры (там нет курсора, за которым можно
* следовать — см. Sortable.vue).
*/
dragOverlayStyle: ComputedRef<Record<string, string | number> | null>
}
export interface SortableItemContextValue {
listeners:
| {
onKeydown: (event: KeyboardEvent) => void
}
| undefined
isDragging?: boolean
disabled?: boolean
/** Регистрирует DOM-узел хендла у родительского SortableItem (для dragHandle). */
registerHandle: (element: HTMLElement | null) => void
}
export const SortableInternalContextKey: InjectionKey<SortableInternalContextValue> =
Symbol("SortableInternalContext")
export const SortableItemContextKey: InjectionKey<SortableItemContextValue> =
Symbol("SortableItemContext")
export const IsOverlayContextKey: InjectionKey<boolean> = Symbol("IsOverlayContext")
const defaultItemContext: SortableItemContextValue = {
listeners: undefined,
isDragging: false,
disabled: false,
registerHandle: () => {},
}
/** Соответствует `useContext(SortableItemContext)` в оригинале — не бросает. */
export function useSortableItemContext(): SortableItemContextValue {
return inject(SortableItemContextKey, defaultItemContext)
}
/** Соответствует `useContext(IsOverlayContext)` в оригинале — дефолт `false`. */
export function useIsOverlay(): boolean {
return inject(IsOverlayContextKey, false)
}
/** Соответствует `useContext(SortableInternalContext)` — используется SortableOverlay. */
export function useSortableInternalContext(): SortableInternalContextValue | undefined {
return inject(SortableInternalContextKey, undefined)
}
src/reui/sortable/index.ts
export { default as Sortable } from "./Sortable.vue"
export { default as SortableItem } from "./SortableItem.vue"
export { default as SortableItemHandle } from "./SortableItemHandle.vue"
export { default as SortableOverlay } from "./SortableOverlay.vue"
export {
useIsOverlay,
useSortableInternalContext,
useSortableItemContext,
IsOverlayContextKey,
SortableInternalContextKey,
SortableItemContextKey,
type SortableCommitMeta,
type SortableDragEvent,
type SortableInternalContextValue,
type SortableItemContextValue,
type SortableMoveEvent,
type SortableStrategy,
} from "./context"
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/sortable.jsonЗависимости реестра
npm-зависимости
- @atlaskit/pragmatic-drag-and-drop
- @atlaskit/pragmatic-drag-and-drop-auto-scroll
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)