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
2 changes: 1 addition & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ function App() {
apiReachable={healthy}
/>

<main id="main-content" className="flex-1">
<main id="main-content" tabIndex={-1} className="flex-1">
{userData ? (
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 animate-fade-in-up">
{/* Navigation */}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default function Header({ userData, onAuth, authLoading, notifications, u
const isOnline = useOnlineStatus();

const networkLabel = NETWORK_NAME.charAt(0).toUpperCase() + NETWORK_NAME.slice(1);
const apiStatusText = apiReachable === null ? 'Checking' : apiReachable ? 'Online' : 'Offline';

// When the OfflineBanner is visible it occupies layout space above the
// header. Shift the header down by the banner's height so the two sticky
Expand Down Expand Up @@ -56,6 +57,7 @@ export default function Header({ userData, onAuth, authLoading, notifications, u
aria-hidden="true"
/>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">{networkLabel}</span>
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">{apiStatusText}</span>
<span className="sr-only">
{apiReachable === null ? 'Checking connection' : apiReachable ? 'API connected' : 'API disconnected'}
</span>
Expand All @@ -70,6 +72,7 @@ export default function Header({ userData, onAuth, authLoading, notifications, u
onClick={toggleTheme}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-white/10 transition-colors"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
aria-pressed={theme === 'dark'}
>
{theme === 'dark' ? (
<Sun className="w-4 h-4" aria-hidden="true" />
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/NotificationBell.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Bell } from 'lucide-react';
export default function NotificationBell({ notifications, unreadCount, onMarkRead, loading, lastSeenTimestamp }) {
const [open, setOpen] = useState(false);
const dropdownRef = useRef(null);
const panelId = 'notifications-panel';

useEffect(() => {
const handleClickOutside = (e) => {
Expand Down Expand Up @@ -36,13 +37,17 @@ export default function NotificationBell({ notifications, unreadCount, onMarkRea
onClick={handleToggle}
className="relative p-2 rounded-lg text-gray-300 hover:text-white hover:bg-white/10 transition-colors min-h-[44px] min-w-[44px] flex items-center justify-center"
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
aria-expanded={open}
aria-controls={panelId}
>
<Bell className="w-5 h-5" aria-hidden="true" />
<span className="sr-only" aria-live="polite" aria-atomic="true">
{unreadCount > 0 ? `${unreadCount} unread notifications` : 'No unread notifications'}
</span>
{unreadCount > 0 && (
<span
className="absolute -top-0.5 -right-0.5 h-5 w-5 flex items-center justify-center text-[10px] font-bold text-white bg-red-500 rounded-full ring-2 ring-gray-900"
aria-live="polite"
aria-atomic="true"
aria-hidden="true"
>
{unreadCount > 9 ? '9+' : unreadCount}
</span>
Expand All @@ -51,6 +56,7 @@ export default function NotificationBell({ notifications, unreadCount, onMarkRea

{open && (
<div
id={panelId}
className="absolute right-0 top-full mt-2 w-80 bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 z-50 overflow-hidden"
role="region"
aria-label="Notifications"
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/components/RecentTips.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,14 +287,18 @@ export default function RecentTips({ addToast }) {
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none placeholder-gray-400 dark:placeholder-gray-500"
placeholder="Search by address or message..." />
</div>
<button onClick={() => setShowFilters(!showFilters)}
<button
type="button"
onClick={() => setShowFilters(!showFilters)}
aria-expanded={showFilters}
aria-controls="feed-filters"
className={`px-3 py-2 text-xs font-semibold rounded-xl border transition-colors ${showFilters ? 'bg-gray-900 dark:bg-amber-500 text-white dark:text-black border-gray-900 dark:border-amber-500' : 'bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'}`}>
Filters
</button>
{hasActiveFilters && <button onClick={clearFilters} className="px-2 py-2 text-xs text-red-500 hover:text-red-600 font-semibold">Clear</button>}
{hasActiveFilters && <button type="button" onClick={clearFilters} className="px-2 py-2 text-xs text-red-500 hover:text-red-600 font-semibold">Clear</button>}
</div>
{showFilters && (
<div className="flex flex-wrap gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-700">
<div id="feed-filters" className="flex flex-wrap gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl border border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2">
<label htmlFor="feed-filter-min" className="text-xs font-medium text-gray-500 dark:text-gray-400">Min STX</label>
<input id="feed-filter-min" type="number" value={minAmount} onChange={(e) => { setMinAmount(e.target.value); setOffset(0); }}
Expand Down Expand Up @@ -326,7 +330,7 @@ export default function RecentTips({ addToast }) {
<p className="text-gray-400">{hasActiveFilters ? 'No tips match your filters' : 'No tips in the stream yet. Be the first!'}</p>
</div>
) : (
<div className="space-y-2">
<div className="space-y-2" aria-live="polite" aria-relevant="additions text">
{paginatedTips.map((tip, i) => (
<div key={tip.tipId || i} className="group flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 bg-gray-50 dark:bg-gray-800/50 hover:bg-white dark:hover:bg-gray-800 rounded-xl border border-transparent hover:border-gray-200 dark:hover:border-gray-700 transition-all">
<div className="flex items-center gap-3">
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/SkipNav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export default function SkipNav() {
return (
<a
href="#main-content"
onClick={() => {
const el = document.getElementById('main-content');
if (el && typeof el.focus === 'function') {
window.setTimeout(() => el.focus(), 0);
}
}}
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:rounded-md focus:bg-orange-500 focus:text-white focus:text-sm focus:font-medium focus:outline-none focus:ring-2 focus:ring-orange-300"
>
Skip to content
Expand Down
54 changes: 45 additions & 9 deletions frontend/src/components/ui/confirm-dialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,43 @@ export default function ConfirmDialog({ open, title, children, onConfirm, onCanc
const dialogRef = useRef(null);
const previousFocusRef = useRef(null);

const getFocusableElements = useCallback(() => {
const dialog = dialogRef.current;
if (!dialog) return [];

return Array.from(
dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => {
if (!el || typeof el.focus !== 'function') return false;
if (el.hasAttribute('disabled')) return false;
if (el.getAttribute('aria-hidden') === 'true') return false;
return true;
});
}, []);

useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement;
dialogRef.current?.focus();
const timer = window.setTimeout(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
return;
}
dialogRef.current?.focus();
}, 0);

return () => {
window.clearTimeout(timer);
};
} else if (previousFocusRef.current) {
previousFocusRef.current.focus();
previousFocusRef.current = null;
}
}, [open]);
return undefined;
}, [getFocusableElements, open]);

const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
Expand All @@ -24,27 +52,32 @@ export default function ConfirmDialog({ open, title, children, onConfirm, onCanc
const dialog = dialogRef.current;
if (!dialog) return;

const focusable = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const focusable = getFocusableElements();
if (focusable.length === 0) return;

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

if (!active || !dialog.contains(active)) {
e.preventDefault();
first.focus();
return;
}

if (e.shiftKey) {
if (document.activeElement === first || document.activeElement === dialog) {
if (active === first || active === dialog) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
if (active === last) {
e.preventDefault();
first.focus();
}
}
}
}, [onCancel]);
}, [getFocusableElements, onCancel]);

if (!open) return null;

Expand All @@ -57,19 +90,22 @@ export default function ConfirmDialog({ open, title, children, onConfirm, onCanc
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
onKeyDown={handleKeyDown}
className="relative bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-100 dark:border-gray-700 p-6 max-w-md w-full mx-4 animate-in fade-in zoom-in-95"
>
<h3 id="confirm-dialog-title" className="text-lg font-bold text-gray-900 dark:text-gray-100 mb-3">{title}</h3>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-6">{children}</div>
<div id="confirm-dialog-description" className="text-sm text-gray-600 dark:text-gray-400 mb-6">{children}</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-gray-900 dark:bg-amber-500 dark:text-black hover:bg-black dark:hover:bg-amber-400 rounded-lg transition-colors"
>
Expand Down
88 changes: 88 additions & 0 deletions frontend/src/test/ConfirmDialog.a11y.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { useState } from 'react';
import ConfirmDialog from '../components/ui/confirm-dialog';

afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

function Wrapper() {
const [open, setOpen] = useState(false);

return (
<div>
<button type="button" onClick={() => setOpen(true)}>
Open dialog
</button>
<ConfirmDialog
open={open}
title="Confirm Action"
onCancel={() => setOpen(false)}
onConfirm={() => setOpen(false)}
confirmLabel="Confirm"
cancelLabel="Cancel"
>
Are you sure?
</ConfirmDialog>
</div>
);
}

describe('ConfirmDialog accessibility', () => {
it('moves focus into the dialog on open', async () => {
render(<Wrapper />);

fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }));

const dialog = screen.getByRole('dialog', { name: 'Confirm Action' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby', 'confirm-dialog-title');
expect(dialog).toHaveAttribute('aria-describedby', 'confirm-dialog-description');

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();
});
});

it('traps Tab focus inside the dialog', async () => {
render(<Wrapper />);

fireEvent.click(screen.getByRole('button', { name: 'Open dialog' }));

const cancel = await screen.findByRole('button', { name: 'Cancel' });
const confirm = screen.getByRole('button', { name: 'Confirm' });

confirm.focus();
fireEvent.keyDown(confirm, { key: 'Tab' });

await waitFor(() => {
expect(cancel).toHaveFocus();
});

cancel.focus();
fireEvent.keyDown(cancel, { key: 'Tab', shiftKey: true });

await waitFor(() => {
expect(confirm).toHaveFocus();
});
});

it('restores focus to the trigger when closing', async () => {
render(<Wrapper />);

const trigger = screen.getByRole('button', { name: 'Open dialog' });
trigger.focus();
fireEvent.click(trigger);

const cancel = await screen.findByRole('button', { name: 'Cancel' });
fireEvent.click(cancel);

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

expect(trigger).toHaveFocus();
});
});
2 changes: 2 additions & 0 deletions frontend/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,5 +96,7 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.js',
exclude: ['e2e/**', '**/node_modules/**', '**/dist/**'],
testTimeout: 10000,
}
})
Loading