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(() => {
{ if (event.key === 'Enter' || event.key === ' ') event.preventDefault(); }"
>Served by: {{ cacheAnalysis.servedBy.source }}
{ if (event.key === 'Enter' || event.key === ' ') event.preventDefault(); }"
>CDN node(s): {{ cacheAnalysis.servedBy.cdnNodes }}
@@ -83,9 +95,9 @@ onUnmounted(() => {
-
- 🎬 Request from client
+ 🎬Start: Request from client
- ↓
+ ↓
@@ -101,8 +113,11 @@ onUnmounted(() => {
-
{ if (event.key === 'Enter' || event.key === ' ') event.preventDefault(); }"
>
↳ {{ 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 ? "✅" : "❌" }}
+ {{ parameters.hit ? "Yes" : "No" }}
-
Forwarded because
@@ -161,13 +183,16 @@ onUnmounted(() => {
-
Forwarded status
@@ -186,13 +211,16 @@ onUnmounted(() => {
-
TTL
@@ -218,13 +246,16 @@ onUnmounted(() => {
-
Stored the response
@@ -236,20 +267,24 @@ onUnmounted(() => {
'value-different': isKeyHovered(`Stored the response-${cacheIndex}`) && !isValueMatching(parameters.stored),
}"
>
- {{ parameters.stored ? "✅" : "❌" }}
+ {{ parameters.stored ? "✅" : "❌" }}
+ {{ parameters.stored ? "Yes" : "No" }}
-
Collapsed w/ other reqs
@@ -261,20 +296,24 @@ onUnmounted(() => {
'value-different': isKeyHovered(`Collapsed w/ other reqs-${cacheIndex}`) && !isValueMatching(parameters.collapsed),
}"
>
- {{ parameters.collapsed ? "✅" : "❌" }}
+ {{ parameters.collapsed ? "✅" : "❌" }}
+ {{ parameters.collapsed ? "Yes" : "No" }}
-
Cache key
@@ -293,13 +332,16 @@ onUnmounted(() => {
-
Extra details
@@ -318,22 +360,25 @@ onUnmounted(() => {
-
- ↓
+ ↓
- 🏁 Response to client
+ 🏁End: Response to client
-
Cacheable
@@ -345,19 +390,23 @@ onUnmounted(() => {
'value-different': isKeyHovered('Cacheable') && !isValueMatching(cacheAnalysis.cacheControl.isCacheable),
}"
>
- {{ cacheAnalysis.cacheControl.isCacheable ? "✅" : "❌" }}
+ {{ cacheAnalysis.cacheControl.isCacheable ? "✅" : "❌" }}
+ {{ cacheAnalysis.cacheControl.isCacheable ? "Yes" : "No" }}
-
Age
@@ -383,13 +432,16 @@ onUnmounted(() => {
-
Date
@@ -414,13 +466,16 @@ onUnmounted(() => {
-
ETag
@@ -439,13 +494,16 @@ onUnmounted(() => {
-
Expires at
@@ -470,13 +528,16 @@ onUnmounted(() => {
-
TTL{{
cacheAnalysis.cacheControl.netlifyCdnTtl
@@ -507,13 +568,16 @@ onUnmounted(() => {
-
TTL ({{
cacheAnalysis.cacheControl.netlifyCdnTtl
@@ -543,13 +607,16 @@ onUnmounted(() => {
-
TTL (Netlify CDN)
@@ -575,13 +642,16 @@ onUnmounted(() => {
-
Vary
@@ -600,13 +670,16 @@ onUnmounted(() => {
-
Netlify-Vary
@@ -625,13 +698,16 @@ onUnmounted(() => {
-
Revalidation
@@ -655,6 +731,18 @@ onUnmounted(() => {
font-size: 0.9em;
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
hr {
margin-top: 0.5em;
}
diff --git a/app/components/RequestForm.a11y.test.ts b/app/components/RequestForm.a11y.test.ts
new file mode 100644
index 0000000..927f56f
--- /dev/null
+++ b/app/components/RequestForm.a11y.test.ts
@@ -0,0 +1,103 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import RequestForm from './RequestForm.vue'
+
+describe('RequestForm - Accessibility', () => {
+ it('has properly associated label with input', () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: false,
+ },
+ })
+
+ const label = wrapper.find('label')
+ const input = wrapper.find('input')
+
+ expect(label.exists()).toBe(true)
+ expect(input.exists()).toBe(true)
+
+ // Check that label has 'for' attribute
+ expect(label.attributes('for')).toBe('url-input')
+
+ // Check that input has matching 'id' attribute
+ expect(input.attributes('id')).toBe('url-input')
+ })
+
+ it('input has proper type attribute for URL', () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: false,
+ },
+ })
+
+ const input = wrapper.find('input')
+ expect(input.attributes('type')).toBe('url')
+ })
+
+ it('button has descriptive text', () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: false,
+ },
+ })
+
+ const button = wrapper.find('button')
+ expect(button.text()).toBe('Inspect')
+ })
+
+ it('button text changes when loading', () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: true,
+ },
+ })
+
+ const button = wrapper.find('button')
+ expect(button.text()).toBe('Inspecting...')
+ })
+
+ it('button is disabled during loading', () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: true,
+ },
+ })
+
+ const button = wrapper.find('button')
+ expect(button.attributes('disabled')).toBeDefined()
+ })
+
+ it('form elements are keyboard accessible', () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: false,
+ },
+ })
+
+ const input = wrapper.find('input')
+ const button = wrapper.find('button')
+
+ // Both should be in the tab order (not have negative tabindex)
+ expect(input.attributes('tabindex')).not.toBe('-1')
+ expect(button.attributes('tabindex')).not.toBe('-1')
+ })
+
+ it('supports Enter key to submit', async () => {
+ const wrapper = mount(RequestForm, {
+ props: {
+ loading: false,
+ },
+ })
+
+ const input = wrapper.find('input')
+
+ // Simulate Enter key
+ await input.trigger('keyup.enter')
+
+ // Should emit submit event
+ expect(wrapper.emitted('submit')).toBeTruthy()
+ })
+})
diff --git a/app/components/RequestForm.vue b/app/components/RequestForm.vue
index 4edfb3f..dae48e4 100644
--- a/app/components/RequestForm.vue
+++ b/app/components/RequestForm.vue
@@ -23,10 +23,15 @@ const handleSubmit = () => {
-