179 lines
4.4 KiB
TypeScript
179 lines
4.4 KiB
TypeScript
import { computed, nextTick, ref, shallowRef } from 'vue'
|
|
import { t } from '@/i18n'
|
|
import { moveItem } from '@/lib/arrayMove'
|
|
|
|
interface KeyboardReorderOptions<T> {
|
|
items: () => readonly T[]
|
|
name: (item: T) => string
|
|
commit: (next: T[]) => void
|
|
root: () => HTMLElement | null
|
|
}
|
|
|
|
export function useKeyboardReorder<T>(options: KeyboardReorderOptions<T>) {
|
|
const working = shallowRef<T[] | null>(null)
|
|
const grabbed = ref<number | null>(null)
|
|
const origin = ref(0)
|
|
const announcement = ref('')
|
|
|
|
const shown = computed<readonly T[]>(() => working.value ?? options.items())
|
|
const active = computed(() => grabbed.value !== null)
|
|
|
|
function focusHandle(index: number) {
|
|
nextTick(() =>
|
|
options.root()?.querySelector<HTMLElement>(`[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<string, () => 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 }
|
|
}
|