Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions frontend/e2e/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* accessibility.spec.ts — Issue #1146
*
* Automated axe-core accessibility tests that verify WCAG 2.1 AA compliance
* across interactive simulators, modals, and slide-out menus.
*
* Uses axe-core injected via page.evaluate() to avoid an extra npm dependency
* that may conflict with the existing jest-axe setup.
*/

import { test, expect } from '../fixtures/web3.fixture';

/**
* Inject axe-core from a CDN and run an audit against the current page.
* Returns violations for assertion.
*/
async function runAxeAudit(page: import('@playwright/test').Page) {
// Inject axe-core script
await page.addScriptTag({
url: 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.8.4/axe.min.js',
});

// Wait for axe to be available
await page.waitForFunction(() => typeof (window as any).axe !== 'undefined');

// Run axe and collect results
const results = await page.evaluate(async () => {
const axe = (window as any).axe;
const results = await axe.run();
return {
violations: results.violations.map((v: any) => ({
id: v.id,
impact: v.impact,
description: v.description,
help: v.help,
helpUrl: v.helpUrl,
nodes: v.nodes.length,
tags: v.tags.filter((t: string) => t.startsWith('wcag')),
})),
};
});

return results;
}

// Suppress the render warning modal that blocks viewport on fresh sessions
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.sessionStorage.setItem('render_warning_seen', 'true');
});
});

test.describe('WCAG 2.1 AA Accessibility', () => {
test('home page has no critical or serious axe violations', async ({ page }) => {
await page.goto('/');
// Allow page to settle
await page.waitForTimeout(2000);

const results = await runAxeAudit(page);

const criticalOrSerious = results.violations.filter(
(v: any) => v.impact === 'critical' || v.impact === 'serious',
);

expect(criticalOrSerious).toEqual([]);
});

test('simulator page has no critical or serious axe violations', async ({ page }) => {
// Seed wallet and role to pass guards
await page.addInitScript(() => {
window.localStorage.setItem('stellar_wallet', 'true');
window.localStorage.setItem('token', 'mock-jwt-token');
window.localStorage.setItem('user', JSON.stringify({ role: 'student' }));
});

await page.goto('/simulator');
// Wait for simulator to initialize and live data to start
await page.waitForTimeout(3000);

const results = await runAxeAudit(page);

const criticalOrSerious = results.violations.filter(
(v: any) => v.impact === 'critical' || v.impact === 'serious',
);

expect(criticalOrSerious).toEqual([]);
});

test('notification sidebar has no axe violations when open', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('stellar_wallet', 'true');
window.localStorage.setItem('token', 'mock-jwt-token');
window.localStorage.setItem('user', JSON.stringify({ role: 'student' }));
});

await page.goto('/simulator');
await page.waitForTimeout(2000);

// Open notification sidebar via bell icon if present
const bellButton = page.locator('button[aria-label*="notification"], button[aria-label*="Notification"]').first();
if (await bellButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await bellButton.click();
await page.waitForTimeout(500);

const results = await runAxeAudit(page);

const criticalOrSerious = results.violations.filter(
(v: any) => v.impact === 'critical' || v.impact === 'serious',
);

expect(criticalOrSerious).toEqual([]);
}
});

test('keyboard navigation: Tab moves focus through interactive elements', async ({ page }) => {
await page.goto('/');
await page.waitForTimeout(2000);

// Tab from body and verify focus moves
await page.keyboard.press('Tab');
const firstFocused = await page.evaluate(() => {
const el = document.activeElement;
return el?.tagName + (el?.getAttribute('role') || '') + (el?.getAttribute('aria-label') || '');
});

// Focus should have moved to an interactive element
expect(firstFocused).not.toBe('BODY');
});

test('all interactive elements have minimum 44x44 touch target', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('stellar_wallet', 'true');
window.localStorage.setItem('token', 'mock-jwt-token');
window.localStorage.setItem('user', JSON.stringify({ role: 'student' }));
});

await page.goto('/simulator');
await page.waitForTimeout(3000);

// Check buttons and interactive elements have minimum touch target size
const undersized = await page.evaluate(() => {
const interactive = document.querySelectorAll('button, a, input, select, [role="button"]');
const results: string[] = [];

interactive.forEach((el) => {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && (rect.width < 44 || rect.height < 44)) {
results.push(
`${el.tagName}(${el.textContent?.trim().slice(0, 20) || 'no-text'}): ${Math.round(rect.width)}x${Math.round(rect.height)}`,
);
}
});

return results;
});

// Report undersized elements but don't fail (some may be intentionally small)
if (undersized.length > 0) {
console.log(`Elements below 44x44 touch target: ${undersized.join(', ')}`);
}
});
});
14 changes: 13 additions & 1 deletion frontend/src/components/notifications/NotificationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
NotificationType,
useNotifications,
} from '@/contexts/NotificationContext';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { VirtualizedList } from './VirtualizedList';
import {
CheckCircle,
Expand Down Expand Up @@ -121,6 +122,15 @@ export function NotificationSidebar({ open, onClose }: Props) {

const groups = useMemo(() => groupNotifications(filtered), [filtered]);

const sidebarRef = useRef<HTMLElement>(null);

useFocusTrap(sidebarRef, {
enabled: open && mounted,
initialFocus: true,
returnFocusOnDeactivate: true,
onEscape: onClose,
});

if (!open || !mounted) return null;

return createPortal(
Expand All @@ -134,7 +144,9 @@ export function NotificationSidebar({ open, onClose }: Props) {

{/* Sidebar panel */}
<aside
ref={sidebarRef}
role="dialog"
aria-modal="true"
aria-label="Notification Center"
className="animate-in slide-in-from-right fixed top-0 right-0 z-50 flex h-full w-full max-w-[400px] flex-col border-l border-white/10 bg-zinc-950/90 shadow-2xl backdrop-blur-2xl"
>
Expand Down
58 changes: 9 additions & 49 deletions frontend/src/components/simulator/NodeDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,33 @@
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { Activity, ExternalLink, Shield, Wallet, X } from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import React, { useRef } from 'react';
import { NetworkNode } from '../../lib/visualization/ForceSimulation';
import { useFocusTrap } from '@/hooks/useFocusTrap';

interface NodeDetailPanelProps {
node: NetworkNode;
onClose: () => void;
}

const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

export const NodeDetailPanel: React.FC<NodeDetailPanelProps> = ({ node, onClose }) => {
const shouldReduceMotion = useReducedMotion();
const panelRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;

// Keyboard operability: move focus into the panel on open, trap Tab
// inside it, restore focus on close, and let Escape dismiss the dialog.
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;

const previouslyFocused = document.activeElement as HTMLElement | null;
const closeButton = panel.querySelector<HTMLButtonElement>('button[aria-label^="Close"]');
closeButton?.focus();

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onCloseRef.current();
return;
}

if (event.key !== 'Tab') return;

const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (focusable.length === 0) return;

const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;

if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};

panel.addEventListener('keydown', handleKeyDown);

return () => {
panel.removeEventListener('keydown', handleKeyDown);
previouslyFocused?.focus();
};
}, []);
useFocusTrap(panelRef, {
enabled: true,
initialFocus: true,
returnFocusOnDeactivate: true,
onEscape: onClose,
});

return (
<AnimatePresence>
<motion.div
ref={panelRef}
initial={shouldReduceMotion ? { opacity: 0 } : { x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={shouldReduceMotion ? { opacity: 0 } : { x: '100%', opacity: 0 }}
transition={shouldReduceMotion ? { duration: 0 } : undefined}
ref={panelRef}
className="absolute top-0 right-0 z-30 flex h-full w-full sm:w-80 flex-col gap-6 border-l border-white/10 bg-black/95 p-6 backdrop-blur-xl"
role="dialog"
aria-modal="true"
Expand Down
Loading