diff --git a/app/components/CacheAnalysis.a11y.test.ts b/app/components/CacheAnalysis.a11y.test.ts new file mode 100644 index 0000000..4006ef4 --- /dev/null +++ b/app/components/CacheAnalysis.a11y.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import CacheAnalysis from './CacheAnalysis.vue' + +describe('CacheAnalysis - Accessibility', () => { + const mockProps = { + cacheHeaders: { + 'cache-control': 'public, max-age=3600, s-maxage=7200', + 'cache-status': '"Netlify Edge"; hit', + 'x-nf-request-id': 'test-id', + 'age': '100', + 'etag': '"abc123"', + 'vary': 'Accept-Encoding', + }, + enableDiffOnHover: false, + } + + beforeEach(() => { + vi.clearAllTimers() + }) + + it('has proper ARIA roles for interactive elements', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Check that all dt elements with tabindex have role="button" + const interactiveDts = wrapper.findAll('dt[tabindex="0"]') + interactiveDts.forEach((dt) => { + expect(dt.attributes('role')).toBe('button') + }) + }) + + it('has proper aria-labels for all interactive elements', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Check that all dt elements with role="button" have aria-label + const buttons = wrapper.findAll('dt[role="button"]') + buttons.forEach((button) => { + expect(button.attributes('aria-label')).toBeTruthy() + expect(button.attributes('aria-label')).not.toBe('') + }) + }) + + it('hides decorative emojis from screen readers', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Find all spans with aria-hidden="true" + const hiddenElements = wrapper.findAll('[aria-hidden="true"]') + + // Should have at least the emoji decorations (arrows, emojis in headings, etc.) + expect(hiddenElements.length).toBeGreaterThan(0) + + // Check that checkmarks and X marks are hidden - they're inside dd elements + const booleanValues = wrapper.findAll('dd span[aria-hidden="true"]') + expect(booleanValues.length).toBeGreaterThan(0) + }) + + it('provides screen reader text for boolean values', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Find screen reader only text for boolean values + const srOnlyTexts = wrapper.findAll('.sr-only') + + // Should have at least some screen reader text + expect(srOnlyTexts.length).toBeGreaterThan(0) + }) + + it('has proper keyboard navigation support', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Check that interactive elements can be tabbed to + const tabbableElements = wrapper.findAll('[tabindex="0"]') + expect(tabbableElements.length).toBeGreaterThan(0) + + // Each should be a button or have proper role + tabbableElements.forEach((element) => { + const role = element.attributes('role') + expect(role).toBeTruthy() + }) + }) + + it('has tooltip information accessible via aria-label', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Tooltip triggers should have aria-labels + const tooltipTriggers = wrapper.findAll('.tooltip-trigger') + tooltipTriggers.forEach((trigger) => { + expect(trigger.attributes('aria-label')).toBeTruthy() + expect(trigger.attributes('role')).toBe('button') + }) + }) + + it('maintains semantic HTML structure with dl/dt/dd', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Should have proper description list structure + const dl = wrapper.find('dl') + expect(dl.exists()).toBe(true) + + const dts = wrapper.findAll('dt') + const dds = wrapper.findAll('dd') + + expect(dts.length).toBeGreaterThan(0) + expect(dds.length).toBeGreaterThan(0) + }) + + it('provides meaningful focus indicators', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Check that data-key elements have proper classes for focus states + const dataKeys = wrapper.findAll('.data-key') + dataKeys.forEach((key) => { + // Element should be focusable + expect(key.attributes('tabindex')).toBe('0') + expect(key.attributes('role')).toBe('button') + }) + }) + + it('uses semantic headings for cache sections', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + // Should have h4 elements for sections + const headings = wrapper.findAll('h4') + expect(headings.length).toBeGreaterThan(0) + + // Each heading should have meaningful content + headings.forEach((heading) => { + expect(heading.text().trim()).not.toBe('') + }) + }) + + it('screen reader text is visually hidden but accessible', () => { + const wrapper = mount(CacheAnalysis, { + props: mockProps, + }) + + const srOnly = wrapper.find('.sr-only') + if (srOnly.exists()) { + // Check that sr-only class exists and would be applied + expect(srOnly.classes()).toContain('sr-only') + } + }) +}) diff --git a/app/components/CacheAnalysis.vue b/app/components/CacheAnalysis.vue index 54c753f..051a1c3 100644 --- a/app/components/CacheAnalysis.vue +++ b/app/components/CacheAnalysis.vue @@ -46,6 +46,14 @@ const handleDataKeyLeave = () => { clearHover() } +const handleKeyDown = (event: KeyboardEvent, dataKey: string, rawValue: boolean | number | string | Date | null | undefined) => { + // Handle Enter and Space keys for keyboard accessibility + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + handleDataKeyHover(dataKey, rawValue) + } +} + let timerId: NodeJS.Timeout | null = null onMounted(() => { @@ -64,17 +72,21 @@ onUnmounted(() => {
Served by: {{ cacheAnalysis.servedBy.source }}
CDN node(s): {{ cacheAnalysis.servedBy.cdnNodes }}
@@ -83,9 +95,9 @@ onUnmounted(() => {

- 🎬 Request from client + Start: Request from client
- ↓ +

@@ -101,8 +113,11 @@ onUnmounted(() => {

{{ cacheName }} cache

@@ -111,13 +126,16 @@ onUnmounted(() => {
Hit
@@ -129,19 +147,23 @@ onUnmounted(() => { 'value-different': isKeyHovered(`Hit-${cacheIndex}`) && !isValueMatching(parameters.hit), }" > - {{ parameters.hit ? "✅" : "❌" }} + + {{ parameters.hit ? "Yes" : "No" }}