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
292 changes: 171 additions & 121 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { useState } from 'react';
/**
* App.tsx
*
* Integrates:
* - #394 Accessibility: role="tablist", aria-selected, keyboard nav (arrow keys),
* focus-visible rings, skip-to-content link
* - #396 Navigation Redesign: grouped tabs, active-route highlighting,
* mobile hamburger + off-canvas drawer
* - #397 Toast: ToastProvider wraps the whole app
*/

import { useState, useRef, useCallback, type KeyboardEvent } from 'react';
import { EventExplorerPage } from './pages/EventExplorerPage';
import { NotificationTimelineView } from './components/NotificationTimelineView';
import { ActivityFeed } from './components/ActivityFeed';
Expand All @@ -10,140 +21,179 @@ import { NotificationSearchPage } from './pages/NotificationSearchPage';
import { NotificationPreferencesPage } from './pages/NotificationPreferencesPage';
import { TemplatesPage } from './pages/TemplatesPage';
import { ThemeToggle } from './components/ThemeToggle';
import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer';
import { ToastProvider } from './context/ToastContext';
import { useTheme } from './hooks/useTheme';
import { DeliveryHeatmap } from './components/DeliveryHeatmap';
import { useEventStore } from './store/eventStore';

type Tab =
| 'explorer'
| 'timeline'
| 'activity'
| 'user-activity'
| 'retry-stats'
| 'webhooks'
| 'export-history'
| 'search'
| 'preferences'
| 'templates';

export function App() {
const [tab, setTab] = useState<Tab>('explorer');
const [drawerOpen, setDrawerOpen] = useState(false);
const { theme, toggleTheme } = useTheme();
const events = useEventStore((state) => state.events);
const tabListRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);

// ── Keyboard navigation inside tablist (arrow keys) ──────────────────────
const handleTabKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
const tabs = Array.from(
tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]') ?? [],
);
const current = tabs.findIndex((el) => el === document.activeElement);

let next = current;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
next = (current + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
next = (current - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
e.preventDefault();
next = 0;
} else if (e.key === 'End') {
e.preventDefault();
next = tabs.length - 1;
}

if (next !== current) {
tabs[next].focus();
// Activate on arrow-key navigation (roving tabindex pattern)
const navItem = NAV_ITEMS[next];
if (navItem) setTab(navItem.id);
}
},
[],
);

const handleDrawerOpen = useCallback(() => setDrawerOpen(true), []);
const handleDrawerClose = useCallback(() => setDrawerOpen(false), []);

return (
<div className="app">
<div className="app__theme-bar">
<ThemeToggle theme={theme} onToggle={toggleTheme} />
<ToastProvider>
{/* Skip-to-content link (#394) */}
<a href="#main-content" className="skip-link">
Skip to main content
</a>

<div className="app">
{/* Top bar */}
<header className="app__header" role="banner">
<div className="app__header-inner">
{/* Hamburger for mobile (#396) */}
<button
ref={hamburgerRef}
type="button"
className="app__hamburger"
aria-label="Open navigation menu"
aria-expanded={drawerOpen}
aria-controls="mobile-nav-drawer"
onClick={handleDrawerOpen}
>
<span className="app__hamburger-bar" aria-hidden="true" />
<span className="app__hamburger-bar" aria-hidden="true" />
<span className="app__hamburger-bar" aria-hidden="true" />
</button>

<span className="app__brand">NotifyChain</span>

<div className="app__theme-bar">
<ThemeToggle theme={theme} onToggle={toggleTheme} />
</div>
</div>
</header>

{/* Desktop tab navigation (#394, #396) */}
<nav className="app-tabs" aria-label="Main navigation">
<div
ref={tabListRef}
role="tablist"
aria-label="Dashboard sections"
className="app-tabs__list"
onKeyDown={handleTabKeyDown}
>
{NAV_ITEMS.map((item, index) => (
<button
key={item.id}
type="button"
role="tab"
id={`tab-${item.id}`}
aria-selected={tab === item.id}
aria-controls={`panel-${item.id}`}
tabIndex={tab === item.id ? 0 : -1}
className={`app-tabs__btn${tab === item.id ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
))}
</div>
</nav>

{/* Mobile off-canvas drawer (#396) */}
<MobileNavDrawer
isOpen={drawerOpen}
onClose={handleDrawerClose}
activeTab={tab}
onSelectTab={(t) => {
setTab(t);
handleDrawerClose();
}}
/>

{/* Main content area */}
<main id="main-content" className="app__content" tabIndex={-1}>
{NAV_ITEMS.map((item) => (
<div
key={item.id}
role="tabpanel"
id={`panel-${item.id}`}
aria-labelledby={`tab-${item.id}`}
hidden={tab !== item.id}
className="app__panel"
>
{tab === item.id && renderPanel(item.id, events)}
</div>
))}
</main>
</div>
<nav className="app-tabs" role="tablist" aria-label="Main navigation">
<button
type="button"
role="tab"
aria-selected={tab === 'explorer'}
className={`app-tabs__btn${tab === 'explorer' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('explorer')}
>
Event Explorer
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'timeline'}
className={`app-tabs__btn${tab === 'timeline' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('timeline')}
>
Delivery Timeline
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'activity'}
className={`app-tabs__btn${tab === 'activity' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('activity')}
>
Activity Feed
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'user-activity'}
className={`app-tabs__btn${tab === 'user-activity' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('user-activity')}
>
User Activity
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'retry-stats'}
className={`app-tabs__btn${tab === 'retry-stats' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('retry-stats')}
>
Retry Stats
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'webhooks'}
className={`app-tabs__btn${tab === 'webhooks' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('webhooks')}
>
Webhook Performance
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'export-history'}
className={`app-tabs__btn${tab === 'export-history' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('export-history')}
>
Export History
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'search'}
className={`app-tabs__btn${tab === 'search' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('search')}
>
Notification Search
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'preferences'}
className={`app-tabs__btn${tab === 'preferences' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('preferences')}
>
Preferences
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'templates'}
className={`app-tabs__btn${tab === 'templates' ? ' app-tabs__btn--active' : ''}`}
onClick={() => setTab('templates')}
>
Templates
</button>
</nav>
</ToastProvider>
);
}

{tab === 'explorer' && (
// ─── Panel renderer ───────────────────────────────────────────────────────────

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function renderPanel(tab: Tab, events: any[]) {
switch (tab) {
case 'explorer':
return (
<>
<EventExplorerPage />
<DeliveryHeatmap events={events} />
</>
)}
{tab === 'timeline' && <NotificationTimelineView />}
{tab === 'activity' && <ActivityFeed />}
{tab === 'user-activity' && <UserActivityTimeline />}
{tab === 'retry-stats' && <RetryStatisticsPanel />}
{tab === 'webhooks' && <WebhookDashboardPage />}
{tab === 'export-history' && <ExportHistoryPage />}
{tab === 'search' && <NotificationSearchPage />}
{tab === 'preferences' && <NotificationPreferencesPage />}
{tab === 'templates' && <TemplatesPage />}
</div>
);
);
case 'timeline':
return <NotificationTimelineView />;
case 'activity':
return <ActivityFeed />;
case 'user-activity':
return <UserActivityTimeline />;
case 'retry-stats':
return <RetryStatisticsPanel />;
case 'webhooks':
return <WebhookDashboardPage />;
case 'export-history':
return <ExportHistoryPage />;
case 'search':
return <NotificationSearchPage />;
case 'preferences':
return <NotificationPreferencesPage />;
case 'templates':
return <TemplatesPage />;
default:
return null;
}
}
76 changes: 76 additions & 0 deletions dashboard/src/__tests__/accessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Accessibility audit tests (#394)
* Uses jest-axe to detect WCAG 2.1 AA violations in key components.
*/
import React from 'react';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { axe, toHaveNoViolations } from 'jest-axe';
import { Modal } from '../components/Modal';
import { ToastProvider } from '../context/ToastContext';
import { MobileNavDrawer } from '../components/MobileNavDrawer';

expect.extend(toHaveNoViolations);

// ─── Modal ────────────────────────────────────────────────────────────────────
describe('Modal accessibility (#394)', () => {
it('open modal has no axe violations', async () => {
const { container } = render(
<Modal isOpen={true} onClose={() => {}} title="Test Modal">
<p>Modal content here</p>
<button type="button">Action</button>
</Modal>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('has role=dialog and aria-modal', () => {
const { getByRole } = render(
<Modal isOpen={true} onClose={() => {}} title="My Dialog">
<p>Content</p>
</Modal>,
);
const dialog = getByRole('dialog');
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title');
});

it('closed modal renders nothing', () => {
const { container } = render(
<Modal isOpen={false} onClose={() => {}} title="Hidden">
<p>Content</p>
</Modal>,
);
expect(container).toBeEmptyDOMElement();
});
});

// ─── ToastProvider ────────────────────────────────────────────────────────────
describe('ToastProvider accessibility (#394, #397)', () => {
it('empty toast viewport has no axe violations', async () => {
const { container } = render(
<ToastProvider>
<div>App content</div>
</ToastProvider>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});

// ─── MobileNavDrawer ──────────────────────────────────────────────────────────
describe('MobileNavDrawer accessibility (#394, #396)', () => {
it('open drawer has no axe violations', async () => {
const { container } = render(
<MobileNavDrawer
isOpen={true}
onClose={() => {}}
activeTab="explorer"
onSelectTab={() => {}}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Loading
Loading