diff --git a/apps/app/tests/a11y/keyboard.test.ts b/apps/app/tests/a11y/keyboard.test.ts new file mode 100644 index 00000000..4e2adff5 --- /dev/null +++ b/apps/app/tests/a11y/keyboard.test.ts @@ -0,0 +1,236 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { useTimetableKeyboard } from '@/composables/timetable/useTimetableKeyboard' +import { ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable' + +/** + * Keyboard a11y end-to-end (RFC v0.2 D20). Tests the composable directly + * with a host component that owns a focusable canvas root, mirrors the + * page wiring, and exposes the callback spies + announce ref so we can + * assert without driving the whole timetable page. + */ + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle District', + color: '#e85d75', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +function perf(id: string): Performance { + return { + id, + engagement_id: `e_${id}`, + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: '2026-07-10T18:00:00.000Z', + end_at: '2026-07-10T19:00:00.000Z', + version: 1, + notes: null, + warnings: [], + engagement: { + booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, + } as Performance['engagement'], + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +interface HostExposed { + announce: () => string + setSelected: (id: string | null) => void + rootEl: () => HTMLElement | null + callbacks: { + nudge: ReturnType + openPopover: ReturnType + remove: ReturnType + } +} + +function mountKeyboardHost(performances: Performance[]) { + const callbacks = { + nudge: vi.fn().mockResolvedValue(undefined), + openPopover: vi.fn(), + remove: vi.fn().mockResolvedValue(undefined), + } + + const selectedId = ref(performances[0]?.id ?? null) + + const Host = defineComponent({ + setup(_, { expose }) { + const rootEl = ref(null) + const stagesRef = ref([stage]) + + const { announce } = useTimetableKeyboard({ + rootEl, + selectedId, + resolvePerformance: (id: string) => performances.find(p => p.id === id) ?? null, + stages: stagesRef, + callbacks, + }) + + expose({ + announce: () => announce.value, + setSelected: (id: string | null) => { selectedId.value = id }, + rootEl: () => rootEl.value, + callbacks, + }) + + return () => h('div', { 'ref': rootEl, 'tabindex': '0', 'data-test': 'canvas' }, [ + ...performances.map(p => + h('div', { 'data-perf-id': p.id, 'tabindex': '0' }, p.id), + ), + ]) + }, + }) + + const wrapper = mount(Host, { attachTo: document.body }) + + return { wrapper, callbacks, selectedId } +} + +function getExposed(wrapper: { vm: object }): HostExposed { + return (wrapper.vm as { $: { exposed: HostExposed } }).$.exposed +} + +function dispatch(rootEl: HTMLElement | null, key: string, opts: KeyboardEventInit = {}): void { + if (!rootEl) + throw new Error('rootEl is null') + rootEl.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts })) +} + +describe('useTimetableKeyboard — RFC D20 a11y model', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.useRealTimers()) + + it('Arrow Left nudges the selected performance by -SNAP_MIN', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowLeft') + + expect(callbacks.nudge).toHaveBeenCalledTimes(1) + expect(callbacks.nudge.mock.calls[0][1]).toBe(-5) // -SNAP_MIN + }) + + it('Arrow Right nudges by +SNAP_MIN', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowRight') + + expect(callbacks.nudge.mock.calls[0][1]).toBe(5) + }) + + it('Shift+Arrow Right nudges by +60 min (12 × SNAP_MIN)', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowRight', { shiftKey: true }) + + expect(callbacks.nudge.mock.calls[0][1]).toBe(60) + }) + + it('Arrow Down shifts lane (+1)', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowDown') + + expect(callbacks.nudge.mock.calls[0][2]).toBe(1) // deltaLane + }) + + it('Shift+Arrow Down shifts stage (+1)', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'ArrowDown', { shiftKey: true }) + + expect(callbacks.nudge.mock.calls[0][3]).toBe(1) // deltaStageIdx + }) + + it('] cycles to next stage preserving time + lane', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), ']') + + expect(callbacks.nudge.mock.calls[0][1]).toBe(0) // deltaMin + expect(callbacks.nudge.mock.calls[0][2]).toBe(0) // deltaLane + expect(callbacks.nudge.mock.calls[0][3]).toBe(1) // deltaStageIdx + }) + + it('Enter on a focused block opens the popover', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'Enter') + + expect(callbacks.openPopover).toHaveBeenCalledTimes(1) + expect(callbacks.openPopover.mock.calls[0][0]).toMatchObject({ id: 'p1' }) + }) + + it('Delete invokes the remove callback', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), 'Delete') + + expect(callbacks.remove).toHaveBeenCalledTimes(1) + expect(callbacks.remove.mock.calls[0][0]).toMatchObject({ id: 'p1' }) + }) + + it('Space enters drag mode + announces; Arrow accumulates; Enter commits', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), ' ') + expect(exposed.announce()).toMatch(/Toetsenbord-verplaatsing/i) + expect(callbacks.nudge).not.toHaveBeenCalled() + + dispatch(exposed.rootEl(), 'ArrowRight') + dispatch(exposed.rootEl(), 'ArrowRight') + expect(exposed.announce()).toMatch(/Voorlopig.*\+10/) // 2 × SNAP_MIN + + // Commit + dispatch(exposed.rootEl(), 'Enter') + expect(callbacks.nudge).toHaveBeenCalledTimes(1) + expect(callbacks.nudge.mock.calls[0][1]).toBe(10) + expect(exposed.announce()).toMatch(/bevestigd/i) + }) + + it('Esc cancels keyboard drag without invoking the mutation', () => { + const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + dispatch(exposed.rootEl(), ' ') + dispatch(exposed.rootEl(), 'ArrowRight') + dispatch(exposed.rootEl(), 'Escape') + + expect(callbacks.nudge).not.toHaveBeenCalled() + expect(exposed.announce()).toMatch(/geannuleerd/i) + }) + + it('keys do nothing when no performance is selected', () => { + const { wrapper, callbacks, selectedId } = mountKeyboardHost([perf('p1')]) + const exposed = getExposed(wrapper) + + selectedId.value = null + + dispatch(exposed.rootEl(), 'ArrowLeft') + dispatch(exposed.rootEl(), 'Enter') + dispatch(exposed.rootEl(), 'Delete') + + expect(callbacks.nudge).not.toHaveBeenCalled() + expect(callbacks.openPopover).not.toHaveBeenCalled() + expect(callbacks.remove).not.toHaveBeenCalled() + }) +})