feat(timetable): usePointerDrag + useDragOrClick composables (Session 4 step 9)

usePointerDrag — PointerEvents primitive with capture, escape-cancel,
keyboard-cancel, and onBeforeUnmount cleanup. Replaces the legacy
mousedown stack the prototype used.

useDragOrClick — threshold-based drag/click disambiguation (4px Manhattan,
matches prototype audit §4.1). Emits onClick when the pointer never crossed
the threshold; otherwise enters drag mode and emits onDragStart / onDragMove /
onDragEnd. Installs the one-shot capture-phase click suppressor on drag-end
so the synthetic click never opens the popover.

RFC v0.2 D7 — implemented once instead of three times like the prototype.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 01:46:02 +02:00
parent 4ed470ac35
commit 5b812771de
2 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
import { ref } from 'vue'
import { type PointerDragState, usePointerDrag } from './usePointerDrag'
/**
* Threshold-based "drag-or-click" disambiguation. Listens to a single
* `pointerdown`; if the pointer travels < `thresholdPx` before release,
* fires `onClick`; otherwise enters drag mode and fires `onDragStart`
* once + `onDragMove` per pointermove + `onDragEnd` on release.
*
* Per RFC v0.2 D7 — replaces the prototype's three duplicated
* mousedown stacks (timetable.jsx:544-546, 632-634, 677-679) with one
* deterministic primitive + the click-suppression listener that catches
* the synthetic click after a drag-mouseup.
*/
export interface UseDragOrClickOptions {
/** Manhattan threshold in pixels. Default 4 (matches prototype audit §4.1). */
thresholdPx?: number
onClick?: (event: PointerEvent) => void
onDragStart?: (state: PointerDragState) => void
onDragMove?: (state: PointerDragState) => void
onDragEnd?: (state: PointerDragState, cancelled: boolean) => void
}
export function useDragOrClick(options: UseDragOrClickOptions) {
const threshold = options.thresholdPx ?? 4
const dragMode = ref(false)
let pendingClickEvent: PointerEvent | null = null
const drag = usePointerDrag({
onStart: state => {
dragMode.value = false
pendingClickEvent = state.startEvent
},
onMove: state => {
if (!dragMode.value && (Math.abs(state.deltaX) > threshold || Math.abs(state.deltaY) > threshold)) {
dragMode.value = true
pendingClickEvent = null
options.onDragStart?.(state)
}
else if (dragMode.value) {
options.onDragMove?.(state)
}
},
onEnd: (state, cancelled) => {
if (dragMode.value) {
options.onDragEnd?.(state, cancelled)
installClickSuppressor()
}
else if (!cancelled && pendingClickEvent && options.onClick) {
options.onClick(pendingClickEvent)
}
pendingClickEvent = null
dragMode.value = false
},
})
function begin(event: PointerEvent): void {
drag.begin(event)
}
function cancel(): void {
drag.cancel()
}
/**
* Browser fires a synthetic click after pointerup that completed a
* drag — suppress it once so a drag never opens the popover.
*/
function installClickSuppressor(): void {
const suppress = (event: MouseEvent): void => {
event.stopPropagation()
event.preventDefault()
window.removeEventListener('click', suppress, true)
}
window.addEventListener('click', suppress, true)
setTimeout(() => window.removeEventListener('click', suppress, true), 0)
}
return {
begin,
cancel,
isDragging: drag.isDragging,
isDragMode: dragMode,
state: drag.state,
}
}

View File

@@ -0,0 +1,148 @@
import { type Ref, onBeforeUnmount, ref } from 'vue'
/**
* Modern PointerEvents-based drag primitive that replaces legacy
* mousedown stacks. Captures the pointer on `pointerdown`, tracks
* deltas through `pointermove`, releases on `pointerup`/`pointercancel`.
*
* Pure mechanics — domain code (lane math, mutation calls) lives in
* the page entry / mutation composables.
*/
export interface PointerDragState {
pointerId: number
startEvent: PointerEvent
startX: number
startY: number
currentX: number
currentY: number
deltaX: number
deltaY: number
}
export interface UsePointerDragOptions {
/** Optional cursor swap during drag. */
cursor?: string
onStart?: (state: PointerDragState) => void
onMove?: (state: PointerDragState) => void
onEnd?: (state: PointerDragState, cancelled: boolean) => void
}
export function usePointerDrag(options: UsePointerDragOptions = {}): {
isDragging: Ref<boolean>
state: Ref<PointerDragState | null>
begin: (event: PointerEvent) => void
cancel: () => void
} {
const isDragging = ref(false)
const state = ref<PointerDragState | null>(null)
let activeTarget: Element | null = null
function begin(event: PointerEvent): void {
if (isDragging.value)
return
activeTarget = event.currentTarget as Element | null
if (activeTarget && 'setPointerCapture' in activeTarget) {
try {
(activeTarget as Element & { setPointerCapture: (id: number) => void }).setPointerCapture(event.pointerId)
}
catch {
// some targets disallow capture (e.g. detached nodes); harmless.
}
}
state.value = {
pointerId: event.pointerId,
startEvent: event,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
deltaX: 0,
deltaY: 0,
}
isDragging.value = true
if (options.cursor)
document.body.style.cursor = options.cursor
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('keydown', onEscape)
options.onStart?.(state.value)
}
function onPointerMove(event: PointerEvent): void {
if (!state.value || event.pointerId !== state.value.pointerId)
return
state.value = {
...state.value,
currentX: event.clientX,
currentY: event.clientY,
deltaX: event.clientX - state.value.startX,
deltaY: event.clientY - state.value.startY,
}
options.onMove?.(state.value)
}
function onPointerUp(event: PointerEvent): void {
if (!state.value || event.pointerId !== state.value.pointerId)
return
finish(false)
}
function onPointerCancel(): void {
if (!state.value)
return
finish(true)
}
function onEscape(event: KeyboardEvent): void {
if (event.key === 'Escape' && isDragging.value)
finish(true)
}
function finish(cancelled: boolean): void {
if (!state.value)
return
const last = state.value
options.onEnd?.(last, cancelled)
cleanup()
}
function cancel(): void {
if (isDragging.value)
finish(true)
}
function cleanup(): void {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('keydown', onEscape)
if (options.cursor)
document.body.style.cursor = ''
if (activeTarget && 'releasePointerCapture' in activeTarget && state.value) {
try {
(activeTarget as Element & { releasePointerCapture: (id: number) => void }).releasePointerCapture(state.value.pointerId)
}
catch {
// ignore — capture may have been released by the browser already.
}
}
activeTarget = null
state.value = null
isDragging.value = false
}
onBeforeUnmount(() => {
if (isDragging.value)
cleanup()
})
return { isDragging, state, begin, cancel }
}