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:
@@ -117,6 +117,7 @@ defineExpose({ form, errors, submit })
|
|||||||
icon="tabler-x"
|
icon="tabler-x"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label="Sluiten"
|
||||||
@click="emit('update:modelValue', false)"
|
@click="emit('update:modelValue', false)"
|
||||||
/>
|
/>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ function close(): void {
|
|||||||
</div>
|
</div>
|
||||||
<VProgressLinear
|
<VProgressLinear
|
||||||
:model-value="advancing.pct"
|
:model-value="advancing.pct"
|
||||||
|
:aria-label="`Advancing voortgang: ${advancing.done} van ${advancing.total} secties afgerond (${advancing.pct}%)`"
|
||||||
color="primary"
|
color="primary"
|
||||||
height="6"
|
height="6"
|
||||||
rounded
|
rounded
|
||||||
|
|||||||
161
apps/app/tests/a11y/axe.test.ts
Normal file
161
apps/app/tests/a11y/axe.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user