import { nextTick, ref, type Ref } from 'vue' /** Inline style for a visually hidden `aria-live` region (no global utility class in the SDK). */ export const REORDER_ANNOUNCER_STYLE = Object.freeze({ position: 'absolute', width: '1px', height: '1px', padding: '0', margin: '-1px', overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', border: '0', }) export interface UseReorderAnnouncerReturn { /** Bind to a polite live region's text content. */ announcement: Ref /** Publishes a message to the live region. */ announce: (message: string) => void /** Bind to the live region's `style` so it stays visually hidden. */ announcerStyle: typeof REORDER_ANNOUNCER_STYLE } /** * Screen-reader announcements for drag/keyboard move interactions. Bind * `announcement` to a `role="status" aria-live="polite"` element styled with * `announcerStyle`. */ export function useReorderAnnouncer(): UseReorderAnnouncerReturn { const announcement = ref('') function announce(message: string) { // Re-announce identical consecutive messages by clearing first. if (announcement.value === message) announcement.value = '' void nextTick(() => { announcement.value = message }) } return { announcement, announce, announcerStyle: REORDER_ANNOUNCER_STYLE } } export interface ListReorderMove { /** Index the item moved from, within `items()`. */ fromIndex: number /** Index the item moved to, within `items()`. */ toIndex: number /** The reordered array (a copy — `items()` is never mutated). */ items: T[] /** Whether the move came from the keyboard or a pointer drag. */ source: 'keyboard' | 'drag' } export interface UseListReorderOptions { /** The list being reordered, in its canonical (not merely rendered) order. */ items: () => T[] /** Stable id for an item; falls back to the render index when ids repeat. */ getId: (item: T, index: number) => string /** Applies the move. The composable never mutates the source array. */ onReorder: (move: ListReorderMove) => void /** Human-readable name used in announcements. Defaults to the item's id. */ getLabel?: (item: T, index: number) => string /** Blocks every reorder while it returns `true` (readonly / non-sortable lists). */ disabled?: () => boolean /** Resolves the focusable element for an id so keyboard moves keep focus on the item. */ getElement?: (id: string) => HTMLElement | null | undefined /** Noun used in announcements, e.g. `'Reagent'`. Default `'Item'`. */ itemNoun?: string } export interface UseListReorderReturn { /** Id of the item currently being dragged, or `null`. */ draggingId: Ref /** Id of the item the pointer is hovering as a drop target, or `null`. */ dragOverId: Ref isDragging: (id: string) => boolean isDragOver: (id: string) => boolean announcement: Ref announcerStyle: typeof REORDER_ANNOUNCER_STYLE announce: (message: string) => void /** Bind to the draggable element's `@dragstart`. */ onDragStart: (event: DragEvent, item: T, index: number) => void /** Bind to `@dragover` — calls `preventDefault()` so the drop is accepted. */ onDragOver: (event: DragEvent, item: T, index: number) => void /** Bind to `@dragleave`. */ onDragLeave: () => void /** Bind to `@drop`. */ onDrop: (event: DragEvent, item: T, index: number) => void /** Bind to `@dragend`. */ onDragEnd: () => void /** * Bind to the item's (or its drag handle's) `@keydown`: Alt+ArrowUp / Alt+ArrowLeft * moves the item one position earlier, Alt+ArrowDown / Alt+ArrowRight one later. */ onKeydown: (event: KeyboardEvent, item: T, index: number) => void /** Moves an item between two indices of `items()` programmatically. */ move: (fromIndex: number, toIndex: number, source?: 'keyboard' | 'drag') => void } /** Pure helper: a copy of `items` with the entry at `from` moved to `to`. */ export function reorderList(items: T[], from: number, to: number): T[] { const next = [...items] const [moved] = next.splice(from, 1) next.splice(to, 0, moved) return next } /** * Keyboard-accessible list reordering shared by the SDK's drag-to-reorder lists. * It owns the HTML5 drag state, the drop-index maths, and an Alt+Arrow keyboard * alternative that announces every move through a polite live region — so a * reorder never depends on a pointer. */ export function useListReorder(options: UseListReorderOptions): UseListReorderReturn { const draggingId = ref(null) const dragOverId = ref(null) const { announcement, announce, announcerStyle } = useReorderAnnouncer() const noun = options.itemNoun ?? 'Item' function isDisabled(): boolean { return options.disabled?.() === true } function idFor(item: T, index: number): string { return options.getId(item, index) } /** Index within `items()`, resolved by id so filtered/sorted renders still map back. */ function indexOf(item: T, renderIndex: number): number { const id = idFor(item, renderIndex) const found = options.items().findIndex((candidate, index) => idFor(candidate, index) === id) return found === -1 ? renderIndex : found } function labelFor(index: number): string { const item = options.items()[index] if (item === undefined) return noun return options.getLabel?.(item, index) ?? idFor(item, index) } function clearDragState() { draggingId.value = null dragOverId.value = null } function move(fromIndex: number, toIndex: number, source: 'keyboard' | 'drag' = 'keyboard') { if (isDisabled()) return const items = options.items() if ( fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length || toIndex >= items.length ) return const movedId = idFor(items[fromIndex], fromIndex) const label = labelFor(fromIndex) options.onReorder({ fromIndex, toIndex, items: reorderList(items, fromIndex, toIndex), source, }) announce(`${label} moved to position ${toIndex + 1} of ${items.length}.`) void nextTick(() => options.getElement?.(movedId)?.focus()) } function onDragStart(event: DragEvent, item: T, index: number) { if (isDisabled()) return const id = idFor(item, index) draggingId.value = id if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData('text/plain', id) } } function onDragOver(event: DragEvent, item: T, index: number) { if (isDisabled() || draggingId.value === null) return event.preventDefault() if (event.dataTransfer) event.dataTransfer.dropEffect = 'move' const id = idFor(item, index) dragOverId.value = id === draggingId.value ? null : id } function onDragLeave() { dragOverId.value = null } function onDrop(event: DragEvent, item: T, index: number) { if (isDisabled() || draggingId.value === null) { clearDragState() return } event.preventDefault() const items = options.items() const fromIndex = items.findIndex((candidate, i) => idFor(candidate, i) === draggingId.value) const toIndex = indexOf(item, index) clearDragState() if (fromIndex === -1) return move(fromIndex, toIndex, 'drag') } function onDragEnd() { clearDragState() } function onKeydown(event: KeyboardEvent, item: T, index: number) { if (isDisabled() || !event.altKey) return const direction = event.key === 'ArrowUp' || event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowDown' || event.key === 'ArrowRight' ? 1 : 0 if (direction === 0) return event.preventDefault() const fromIndex = indexOf(item, index) move(fromIndex, fromIndex + direction, 'keyboard') } return { draggingId, dragOverId, isDragging: (id: string) => draggingId.value === id, isDragOver: (id: string) => dragOverId.value === id, announcement, announcerStyle, announce, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd, onKeydown, move, } }