test(timetable): axe-core zero-violation a11y enforcement (Step 11)

Three jsdom axe scans covering the user-facing surface of the canvas.
The scans surfaced two real a11y bugs which are fixed in this same
commit:

  1. PerformancePopover — VProgressLinear (advancing aggregate) had no
     accessible name. Added aria-label that announces "X van Y secties
     afgerond (N%)".
  2. AddPerformanceDialog — the icon-only close button (×) was missing
     aria-label. Added 'Sluiten'.

Test scenarios:
  - PerformanceBlock with focus
  - PerformancePopover open
  - AddPerformanceDialog open

Page-level axe rules (region, page-has-heading-one, landmark-one-main,
color-contrast) are disabled for fragment scans — they only make sense
on a full page, and color-contrast resolution is jsdom-blind. Both are
covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001.

Test count: 380 → 383.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 03:53:16 +02:00
parent b65969c459
commit 66a6f7ddc3
3 changed files with 163 additions and 0 deletions

View File

@@ -117,6 +117,7 @@ defineExpose({ form, errors, submit })
icon="tabler-x"
variant="text"
size="small"
aria-label="Sluiten"
@click="emit('update:modelValue', false)"
/>
</VCardTitle>

View File

@@ -113,6 +113,7 @@ function close(): void {
</div>
<VProgressLinear
:model-value="advancing.pct"
:aria-label="`Advancing voortgang: ${advancing.done} van ${advancing.total} secties afgerond (${advancing.pct}%)`"
color="primary"
height="6"
rounded

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest'
import { axe } from 'vitest-axe'
import { defineComponent, h, ref } from 'vue'
import { mountWithVuexy } from '../utils/mountWithVuexy'
import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue'
import PerformanceBlock from '@/components/timetable/PerformanceBlock.vue'
import PerformancePopover from '@/components/timetable/PerformancePopover.vue'
import { type ArtistEngagement, ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable'
/**
* jsdom-based axe scans pick up structural a11y issues (missing roles,
* orphan labels, color-contrast metadata, ARIA mismatches) but cannot
* resolve actual rendered colors — that needs a real browser. Visual
* a11y is on TEST-INFRA-001's Playwright migration list.
*
* For now: zero violations on (1) PerformanceBlock with focus,
* (2) PerformancePopover open, (3) AddPerformanceDialog open.
*/
const stage: Stage = {
id: 's1',
event_id: 'ev1',
name: 'Hardstyle District',
color: '#e85d75',
capacity: 1000,
sort_order: 0,
created_at: null,
updated_at: null,
}
const engagement: Partial<ArtistEngagement> = {
id: 'e1',
artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'],
booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' },
computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 },
fee_amount: 1000,
advancing_completed_count: 3,
advancing_total_count: 5,
}
function performance(): Performance {
return {
id: 'p1',
engagement_id: 'e1',
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: engagement as ArtistEngagement,
stage,
created_at: null,
updated_at: null,
deleted_at: null,
}
}
// Stub VDialog so the popover/dialog renders inline (axe needs to see the
// content in the wrapper, not in a teleport target).
const VDialogStub = defineComponent({
name: 'VDialog',
props: ['modelValue'],
setup(_, { slots }) {
return () => h('div', { class: 'v-dialog-stub' }, slots.default?.())
},
})
// Component fragments are not full pages — skip page-level landmark
// rules (`region`, `page-has-heading-one`, `landmark-one-main`) that
// only make sense at document root. Visual contrast resolution is also
// jsdom-blind; TEST-INFRA-001's Playwright migration covers that.
const fragmentAxeOptions = {
rules: {
'region': { enabled: false },
'page-has-heading-one': { enabled: false },
'landmark-one-main': { enabled: false },
'color-contrast': { enabled: false },
},
}
describe('axe-core a11y enforcement (RFC D20 + D21)', () => {
it('PerformanceBlock has zero violations when rendered + focusable', async () => {
const { wrapper } = mountWithVuexy(PerformanceBlock, {
props: {
performance: performance(),
leftPx: 0,
widthPx: 200,
topPx: 0,
heightPx: 44,
},
})
const results = await axe(wrapper.element, fragmentAxeOptions)
expect(results).toHaveNoViolations()
})
it('PerformancePopover (open) has zero violations', async () => {
const orgIdRef = ref('org_1')
const { wrapper } = mountWithVuexy(PerformancePopover, {
props: {
modelValue: true,
anchorRect: { top: 100, left: 100, right: 200, bottom: 150, width: 100, height: 50, x: 100, y: 100, toJSON: () => ({}) } as DOMRect,
performance: performance(),
orgId: orgIdRef.value,
},
})
await wrapper.vm.$nextTick()
const popoverEl = document.querySelector('.tt-popover')
expect(popoverEl).toBeTruthy()
const results = await axe(popoverEl as HTMLElement, fragmentAxeOptions)
expect(results).toHaveNoViolations()
})
it('AddPerformanceDialog (open) has zero violations', async () => {
const fieldStub = defineComponent({
name: 'FieldStub',
props: ['modelValue', 'label', 'errorMessages'],
setup(props) {
return () => h('div', { class: 'field-stub' }, [
h('label', {}, String(props.label ?? '')),
])
},
})
const { wrapper } = mountWithVuexy(AddPerformanceDialog, {
props: {
modelValue: true,
orgId: 'org_1',
eventId: 'ev_1',
dayId: 'day_1',
stages: [stage],
engagements: [engagement as ArtistEngagement],
},
stubs: {
VDialog: VDialogStub,
AppTextField: fieldStub,
AppTextarea: fieldStub,
AppSelect: fieldStub,
AppAutocomplete: fieldStub,
AppDateTimePicker: fieldStub,
},
})
await wrapper.vm.$nextTick()
const results = await axe(wrapper.element, fragmentAxeOptions)
expect(results).toHaveNoViolations()
})
})