import { computed, nextTick, ref, shallowRef } from 'vue' import { t } from '@/i18n' import { moveItem } from '@/lib/arrayMove' interface KeyboardReorderOptions { items: () => readonly T[] name: (item: T) => string commit: (next: T[]) => void root: () => HTMLElement | null } export function useKeyboardReorder(options: KeyboardReorderOptions) { const working = shallowRef(null) const grabbed = ref(null) const origin = ref(0) const announcement = ref('') const shown = computed(() => working.value ?? options.items()) const active = computed(() => grabbed.value !== null) function focusHandle(index: number) { nextTick(() => options.root()?.querySelector(`[data-handle="${index}"]`)?.focus(), ) } function movingText(item: T, position: number, total: number) { return t( 'Moving {0}. Position {1} of {2}. Press Up or Down to move, Space to drop, Esc to cancel.', [options.name(item), position, total], ) } function grab(index: number) { const items = [...options.items()] const item = items[index] if (item === undefined) { return } working.value = items grabbed.value = index origin.value = index announcement.value = movingText(item, index + 1, items.length) } function move(delta: number) { const current = grabbed.value const items = working.value if (current === null || !items) { return } const target = current + delta if (target < 0 || target >= items.length) { return } working.value = moveItem(items, current, target) grabbed.value = target announcement.value = movingText(items[current]!, target + 1, items.length) focusHandle(target) } function release() { working.value = null grabbed.value = null } function drop() { const current = grabbed.value const items = working.value if (current === null || !items) { return } announcement.value = t('{0} dropped. Position {1} of {2}.', [ options.name(items[current]!), current + 1, items.length, ]) release() if (current !== origin.value) { options.commit(items) } focusHandle(current) } function cancel() { const current = grabbed.value const items = working.value if (current === null || !items) { return } const index = origin.value announcement.value = t('Reorder cancelled. {0} is back at position {1} of {2}.', [ options.name(items[current]!), index + 1, items.length, ]) release() focusHandle(index) } let settling = false function rowIndexAt(y: number) { const rows = Array.from(options.root()?.children ?? []) if (!rows.length) { return null } const found = rows.findIndex((row) => y < row.getBoundingClientRect().bottom) return found === -1 ? rows.length - 1 : found } async function onPointermove(event: PointerEvent) { const current = grabbed.value const target = rowIndexAt(event.clientY) if (settling || current === null || target === null || target === current) { return } settling = true move(target - current) await nextTick() settling = false } function stopPointer() { settling = false document.body.style.userSelect = '' window.removeEventListener('pointermove', onPointermove) window.removeEventListener('pointerup', onPointerup) window.removeEventListener('pointercancel', onPointercancel) } function onPointerup() { stopPointer() drop() } function onPointercancel() { stopPointer() cancel() } function onPointerdown(event: PointerEvent, index: number) { if (grabbed.value !== null || (event.pointerType === 'mouse' && event.button !== 0)) { return } event.preventDefault() grab(index) document.body.style.userSelect = 'none' window.addEventListener('pointermove', onPointermove) window.addEventListener('pointerup', onPointerup) window.addEventListener('pointercancel', onPointercancel) } function onKeydown(event: KeyboardEvent, index: number) { if (grabbed.value === null) { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault() grab(index) } return } const actions: Record void> = { ArrowUp: () => move(-1), ArrowDown: () => move(1), ' ': drop, Enter: drop, Escape: cancel, } const action = actions[event.key] if (action) { event.preventDefault() event.stopPropagation() action() } } return { shown, grabbed, active, announcement, onKeydown, onPointerdown } }