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:
2026-05-09 01:58:56 +02:00
parent 288aebcd69
commit 43572a7812
7 changed files with 868 additions and 30 deletions

View 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> }
}