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

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, ref, toRef, watch } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import { createPerformancePayloadSchema } from '@/schemas/timetable'
@@ -9,9 +8,9 @@ import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
modelValue: boolean
orgId: Ref<string>
eventId: Ref<string>
dayId: Ref<string | null>
orgId: string
eventId: string
dayId: string | null
stages: Stage[]
/** Engagements available to schedule (filtered to this event). */
engagements: ArtistEngagement[]
@@ -24,6 +23,10 @@ const emit = defineEmits<{
'created': []
}>()
const orgIdRef = toRef(props, 'orgId')
const eventIdRef = toRef(props, 'eventId')
const dayIdRef = toRef(props, 'dayId')
const refForm = ref<VForm>()
const errors = ref<Record<string, string>>({})
@@ -50,7 +53,7 @@ watch(() => props.modelValue, open => {
}
})
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
const isPending = computed(() => mutations.create.isPending.value)
const engagementOptions = computed(() =>
@@ -64,7 +67,7 @@ const engagementOptions = computed(() =>
async function submit(): Promise<void> {
errors.value = {}
const eventIdValue = props.dayId.value ?? props.eventId.value
const eventIdValue = props.dayId ?? props.eventId
const payload = {
engagement_id: form.value.engagement_id,

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, ref, toRef, watch } from 'vue'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import type { Stage } from '@/types/timetable'
@@ -12,9 +11,9 @@ interface SubEvent {
const props = defineProps<{
modelValue: boolean
orgId: Ref<string>
eventId: Ref<string>
dayId: Ref<string | null>
orgId: string
eventId: string
dayId: string | null
stages: Stage[]
subEvents: SubEvent[]
}>()
@@ -24,6 +23,10 @@ const emit = defineEmits<{
'saved': []
}>()
const orgIdRef = toRef(props, 'orgId')
const eventIdRef = toRef(props, 'eventId')
const dayIdRef = toRef(props, 'dayId')
const matrix = ref<Record<string, Set<string>>>({})
const errors = ref<Record<string, string>>({})
@@ -48,7 +51,7 @@ function isOn(stageId: string, eventId: string): boolean {
return matrix.value[stageId]?.has(eventId) ?? false
}
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
const isPending = ref(false)
const dirtyStages = computed(() =>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, toRef } from 'vue'
import { useEngagement } from '@/composables/api/useTimetable'
import { ArtistEngagementStatus, type Performance } from '@/types/timetable'
@@ -8,7 +8,7 @@ const props = defineProps<{
/** Anchor element rect (from PerformanceBlock click). */
anchorRect: DOMRect | null
performance: Performance | null
orgId: import('vue').Ref<string>
orgId: string
}>()
const emit = defineEmits<{
@@ -17,9 +17,11 @@ const emit = defineEmits<{
'delete': [perf: Performance]
}>()
const orgIdRef = toRef(props, 'orgId')
const engagementId = computed(() => props.performance?.engagement_id ?? null)
const engagementIdRef = computed<string | null>(() => engagementId.value)
const { data: engagement, isLoading } = useEngagement(props.orgId, engagementIdRef)
const { data: engagement, isLoading } = useEngagement(orgIdRef, engagementIdRef)
const advancing = computed(() => {
const e = engagement.value ?? props.performance?.engagement

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { computed, ref, toRef, watch } from 'vue'
import { VForm } from 'vuetify/components/VForm'
import { useTimetableMutations } from '@/composables/api/useTimetableMutations'
import { createStagePayloadSchema } from '@/schemas/timetable'
@@ -9,9 +8,9 @@ import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
modelValue: boolean
orgId: Ref<string>
eventId: Ref<string>
dayId: Ref<string | null>
orgId: string
eventId: string
dayId: string | null
/** Pass an existing stage to edit; null = create. */
stage: Stage | null
}>()
@@ -22,6 +21,10 @@ const emit = defineEmits<{
'deleted': [stage: Stage]
}>()
const orgIdRef = toRef(props, 'orgId')
const eventIdRef = toRef(props, 'eventId')
const dayIdRef = toRef(props, 'dayId')
const STAGE_PALETTE = [
'#e85d75',
'#d9a93c',
@@ -57,7 +60,7 @@ watch(() => props.modelValue, open => {
}
})
const mutations = useTimetableMutations({ orgId: props.orgId, eventId: props.eventId, dayId: props.dayId })
const mutations = useTimetableMutations({ orgId: orgIdRef, eventId: eventIdRef, dayId: dayIdRef })
const isPending = computed(() => mutations.createStage.isPending.value || mutations.updateStage.isPending.value)
const isDeleting = computed(() => mutations.deleteStage.isPending.value)

View File

@@ -37,25 +37,23 @@ const filtered = computed(() => {
return props.performances.filter(p => {
const status = p.engagement?.booking_status?.value
if (!store.isStatusVisible(status))
return false
if (search && !(p.engagement?.artist?.name ?? '').toLowerCase().includes(search))
return false
const visible = store.isStatusVisible(status)
const matchesSearch = !search || (p.engagement?.artist?.name ?? '').toLowerCase().includes(search)
return true
return visible && matchesSearch
})
})
const counts = computed(() => {
const counts = new Map<ArtistEngagementStatusType, number>()
const out = new Map<ArtistEngagementStatusType, number>()
for (const p of props.performances) {
const s = p.engagement?.booking_status?.value
if (!s)
continue
counts.set(s, (counts.get(s) ?? 0) + 1)
out.set(s, (out.get(s) ?? 0) + 1)
}
return counts
return out
})
</script>