diff --git a/apps/app/src/components/timetable/AddPerformanceDialog.vue b/apps/app/src/components/timetable/AddPerformanceDialog.vue index 1e36aea6..84c0b1de 100644 --- a/apps/app/src/components/timetable/AddPerformanceDialog.vue +++ b/apps/app/src/components/timetable/AddPerformanceDialog.vue @@ -1,6 +1,5 @@ diff --git a/apps/app/src/composables/timetable/useTimetableKeyboard.ts b/apps/app/src/composables/timetable/useTimetableKeyboard.ts new file mode 100644 index 00000000..902396b6 --- /dev/null +++ b/apps/app/src/composables/timetable/useTimetableKeyboard.ts @@ -0,0 +1,184 @@ +import { onBeforeUnmount, onMounted, ref } from 'vue' +import type { Ref } from 'vue' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' +import { SNAP_MIN } from '@/lib/timetable/snap' +import type { Performance, Stage } from '@/types/timetable' + +/** + * RFC v0.2 D20 — keyboard interaction model for the timetable canvas. + * + * Listens to keydown events on the canvas root once mounted. Routes + * directional / modifier keys into the same mutation composable that + * the pointer drag uses, so keyboard nudges go through the same + * server-transactional path (D18) and inherit optimistic + rollback. + */ + +export interface KeyboardMoveCallbacks { + + /** Translate a performance by ±minutes (and optionally ±lanes / ±stages). */ + nudge: (perf: Performance, deltaMin: number, deltaLane: number, deltaStageIdx: number, idempotencyKey: string) => Promise + + /** Open the popover for the focused block. */ + openPopover: (perf: Performance, anchor: HTMLElement) => void + + /** Confirm + delete. */ + remove: (perf: Performance) => Promise +} + +export interface UseTimetableKeyboardArgs { + rootEl: Ref + + /** Pinia store reactive ref to the selected performance id. */ + selectedId: Ref + + /** Resolver: id → performance object (uses TanStack cache). */ + resolvePerformance: (id: string) => Performance | null + + /** Sorted stage list (so [/] navigates left/right). */ + stages: Ref + callbacks: KeyboardMoveCallbacks +} + +export function useTimetableKeyboard(args: UseTimetableKeyboardArgs): { announce: Ref } { + const announce = ref('') + + /** True while the user is in keyboard "drag" mode (Space → arrows → Enter/Esc). */ + const dragMode = ref(false) + let pendingMove: { deltaMin: number; deltaLane: number; deltaStageIdx: number } | null = null + + function focusBlock(id: string | null): void { + if (!id || !args.rootEl.value) + return + const el = args.rootEl.value.querySelector(`[data-perf-id="${id}"]`) + if (el) { + el.focus() + el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }) + } + } + + function onKeydown(event: KeyboardEvent): void { + const id = args.selectedId.value + if (!id) + return + const perf = args.resolvePerformance(id) + if (!perf) + return + + const stageMul = event.shiftKey ? 12 : 1 // Shift+Arrow ←/→ = ±60 min when SNAP_MIN=5 + + switch (event.key) { + case 'ArrowLeft': + event.preventDefault() + if (dragMode.value) + accumulate(-SNAP_MIN * stageMul, 0, 0) + else + void args.callbacks.nudge(perf, -SNAP_MIN * stageMul, 0, 0, generateIdempotencyKey()) + break + case 'ArrowRight': + event.preventDefault() + if (dragMode.value) + accumulate(SNAP_MIN * stageMul, 0, 0) + else + void args.callbacks.nudge(perf, SNAP_MIN * stageMul, 0, 0, generateIdempotencyKey()) + break + case 'ArrowUp': + event.preventDefault() + if (event.shiftKey) { + if (dragMode.value) + accumulate(0, 0, -1) + else + void args.callbacks.nudge(perf, 0, 0, -1, generateIdempotencyKey()) + } + else if (dragMode.value) { + accumulate(0, -1, 0) + } + else { + void args.callbacks.nudge(perf, 0, -1, 0, generateIdempotencyKey()) + } + break + case 'ArrowDown': + event.preventDefault() + if (event.shiftKey) { + if (dragMode.value) + accumulate(0, 0, 1) + else + void args.callbacks.nudge(perf, 0, 0, 1, generateIdempotencyKey()) + } + else if (dragMode.value) { + accumulate(0, 1, 0) + } + else { + void args.callbacks.nudge(perf, 0, 1, 0, generateIdempotencyKey()) + } + break + case '[': + event.preventDefault() + void args.callbacks.nudge(perf, 0, 0, -1, generateIdempotencyKey()) + break + case ']': + event.preventDefault() + void args.callbacks.nudge(perf, 0, 0, 1, generateIdempotencyKey()) + break + case 'Enter': + case ' ': + if (dragMode.value && pendingMove) { + event.preventDefault() + + const { deltaMin, deltaLane, deltaStageIdx } = pendingMove + + dragMode.value = false + pendingMove = null + announce.value = 'Verplaatsing bevestigd.' + void args.callbacks.nudge(perf, deltaMin, deltaLane, deltaStageIdx, generateIdempotencyKey()) + } + else if (event.key === ' ') { + event.preventDefault() + dragMode.value = true + pendingMove = { deltaMin: 0, deltaLane: 0, deltaStageIdx: 0 } + announce.value = 'Toetsenbord-verplaatsing actief. Gebruik pijltjes, Enter bevestigt, Esc annuleert.' + } + else { + event.preventDefault() + + const el = args.rootEl.value?.querySelector(`[data-perf-id="${id}"]`) + if (el) + args.callbacks.openPopover(perf, el) + } + break + case 'Escape': + if (dragMode.value) { + event.preventDefault() + dragMode.value = false + pendingMove = null + announce.value = 'Verplaatsing geannuleerd.' + } + break + case 'Delete': + case 'Backspace': + event.preventDefault() + void args.callbacks.remove(perf) + break + } + } + + function accumulate(deltaMin: number, deltaLane: number, deltaStageIdx: number): void { + if (!pendingMove) + return + pendingMove = { + deltaMin: pendingMove.deltaMin + deltaMin, + deltaLane: pendingMove.deltaLane + deltaLane, + deltaStageIdx: pendingMove.deltaStageIdx + deltaStageIdx, + } + announce.value = `Voorlopig +${pendingMove.deltaMin} min, ${pendingMove.deltaLane} lanes, ${pendingMove.deltaStageIdx} stages.` + } + + onMounted(() => { + args.rootEl.value?.addEventListener('keydown', onKeydown) + }) + + onBeforeUnmount(() => { + args.rootEl.value?.removeEventListener('keydown', onKeydown) + }) + + return { announce, focusSelected: () => focusBlock(args.selectedId.value) } as { announce: Ref } +} diff --git a/apps/app/src/pages/events/[id]/timetable/index.vue b/apps/app/src/pages/events/[id]/timetable/index.vue new file mode 100644 index 00000000..b0772b8f --- /dev/null +++ b/apps/app/src/pages/events/[id]/timetable/index.vue @@ -0,0 +1,645 @@ + + + + +