feat(timetable): keyboard a11y composable + page entry — Session 4 step 11 + ship
useTimetableKeyboard (RFC v0.2 D20): - Arrow ←/→ nudges by SNAP_MIN; Shift+Arrow = ±60min - Arrow ↑/↓ shifts lane; Shift+Arrow ↑/↓ = ±1 stage - [/] cycles stages preserving time + lane - Space starts a "keyboard drag" (announced via aria-live), arrows accumulate the offset, Enter commits, Esc cancels - Enter on a focused block opens the popover; Delete confirms+removes - Pure orchestration — the actual mutation goes through useTimetableMutations so keyboard moves inherit optimistic update + 409 rollback pages/events/[id]/timetable/index.vue: - definePage with organizer context + navActiveLink=events - ?day query param ↔ store.activeDayId in both directions - Composes EventTabsNav, TimeAxis, GridBg, StageHeaderCell, StageRow, Wachtrij, PerformancePopover, AddPerformanceDialog, StageEditor, LineupMatrix, EmptyDayState - Conflict pill in toolbar (header total) per prototype audit §4.8 - Status filter chips applied to canvas blocks via store.isStatusVisible - usePointerDrag + useDragOrClick wires drag to a single move() call; on success flashes pulseSet on cascaded[] for 1.5s (D18 + D21 keyframe) - aria-live region echoes keyboard-drag announcements Tweaks for boundary/lint cleanliness: - Dialog props switched from Ref<T> to T + toRef inside (Vue templates auto-unwrap refs; Ref-typed props clashed with template usage) - Wachtrij counts shadow + sonarjs cleanup - no-void watcher Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
apps/app/src/composables/timetable/useTimetableKeyboard.ts
Normal file
184
apps/app/src/composables/timetable/useTimetableKeyboard.ts
Normal file
@@ -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<void>
|
||||
|
||||
/** Open the popover for the focused block. */
|
||||
openPopover: (perf: Performance, anchor: HTMLElement) => void
|
||||
|
||||
/** Confirm + delete. */
|
||||
remove: (perf: Performance) => Promise<void>
|
||||
}
|
||||
|
||||
export interface UseTimetableKeyboardArgs {
|
||||
rootEl: Ref<HTMLElement | null>
|
||||
|
||||
/** Pinia store reactive ref to the selected performance id. */
|
||||
selectedId: Ref<string | null>
|
||||
|
||||
/** Resolver: id → performance object (uses TanStack cache). */
|
||||
resolvePerformance: (id: string) => Performance | null
|
||||
|
||||
/** Sorted stage list (so [/] navigates left/right). */
|
||||
stages: Ref<Stage[]>
|
||||
callbacks: KeyboardMoveCallbacks
|
||||
}
|
||||
|
||||
export function useTimetableKeyboard(args: UseTimetableKeyboardArgs): { announce: Ref<string> } {
|
||||
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<HTMLElement>(`[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<HTMLElement>(`[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<string> }
|
||||
}
|
||||
Reference in New Issue
Block a user