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
17 changes: 17 additions & 0 deletions dashboard/STORYBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,20 @@ npm run build-storybook
- `WebhookSummaryCards` — loading and filled metrics

Stories live next to components as `*.stories.tsx` and share fixtures under `src/stories/fixtures/`.

## Keyboard Shortcuts

The dashboard supports keyboard shortcuts for faster navigation. Press `?` at any time to open the shortcuts help overlay.

| Shortcut | Action |
|----------|--------|
| `1`-`9` | Switch to the corresponding tab (Event Explorer, Delivery Timeline, etc.) |
| `T` | Toggle between dark and light theme |
| `R` | Refresh events |
| `?` | Show/hide keyboard shortcuts help |
| `Esc` | Close dialogs and overlays |

**Accessibility notes:**
- Shortcuts are disabled while typing in input fields, textareas, or editable elements.
- Modifier keys (Ctrl, Alt, Meta) are respected to avoid conflicts with browser and OS shortcuts.
- The shortcuts help overlay follows ARIA dialog patterns with focus management.
300 changes: 130 additions & 170 deletions dashboard/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"dependencies": {
"@creit.tech/stellar-wallets-kit": "^2.3.0",
"@stellar/stellar-sdk": "^13.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"zustand": "^5.0.6"
Expand Down Expand Up @@ -44,8 +45,8 @@
"jest-axe": "^10.0.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^26.1.0",
"storybook": "^8.6.14",
"prettier": "3.3.3",
"storybook": "^8.6.14",
"ts-jest": "^29.2.5",
"typescript": "^5.8.3",
"vite": "^6.3.5"
Expand Down
97 changes: 97 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
/**
* 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
* - #505 Keyboard Shortcuts: number keys for tabs, T for theme, ? for help
import { useState, useCallback } from 'react';
import { EventExplorerPage } from './pages/EventExplorerPage';
import { NotificationTimelineView } from './components/NotificationTimelineView';
Expand All @@ -16,6 +26,8 @@ import { ExportHistoryPage } from './pages/ExportHistoryPage';
import { NotificationSearchPage } from './pages/NotificationSearchPage';
import { NotificationPreferencesPage } from './pages/NotificationPreferencesPage';
import { TemplatesPage } from './pages/TemplatesPage';
import { ChannelDetailsPage } from './pages/ChannelDetailsPage';
import { RpcBenchmarkPage } from './pages/RpcBenchmarkPage';
import { ThemeToggle } from './components/ThemeToggle';
import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer';
import { ToastProvider } from './context/ToastContext';
Expand All @@ -24,6 +36,13 @@ import { useIsMobileNav } from './hooks/useMediaQuery';
import { DeliveryHeatmap } from './components/DeliveryHeatmap';
import { useEventStore } from './store/eventStore';
import { SyncStatus } from './components/SyncStatus';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp';

export function App() {
const [tab, setTab] = useState<Tab>('explorer');
const [drawerOpen, setDrawerOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
import { DashboardLayout } from './layouts/DashboardLayout';

export function App() {
Expand All @@ -41,6 +60,23 @@ export function App() {
const tabListRef = useRef<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(null);

// ── Keyboard shortcuts (#505) ───────────────────────────────────────────
useKeyboardShortcuts({
activeTab: tab,
onTabChange: setTab,
onToggleTheme: toggleTheme,
helpOpen,
onToggleHelp: useCallback(() => setHelpOpen((prev) => !prev), []),
onCloseHelp: useCallback(() => setHelpOpen(false), []),
});

// ── 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);
// Sync URL hash with tab state
useEffect(() => {
window.location.hash = tab;
Expand Down Expand Up @@ -103,6 +139,44 @@ export function App() {

return (
<ToastProvider>
{/* Skip-to-content link (#394) */}
<a href="#main-content" className="skip-link">
Skip to main content
</a>

{/* Keyboard shortcuts help overlay (#505) */}
<KeyboardShortcutsHelp isOpen={helpOpen} onClose={() => setHelpOpen(false)} />

<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">
<SyncStatus />
<ThemeToggle theme={theme} onToggle={toggleTheme} />
</div>
</div>
</header>

{/* Desktop tab navigation (#394, #396) */}
<nav className="app-tabs" aria-label="Main navigation">
<DashboardLayout
activeTab={tab}
onSelectTab={setTab}
Expand All @@ -128,6 +202,25 @@ export function App() {
hidden={tab !== item.id}
className="app__panel"
>
{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)}
title={index < 9 ? `Press ${index + 1} to switch` : undefined}
>
{index < 9 && (
<kbd className="app-tabs__shortcut">{index + 1}</kbd>
)}
{item.label}
</button>
))}
{tab === item.id && renderPanel(item.id, events)}
</div>
))}
Expand Down Expand Up @@ -174,6 +267,10 @@ function renderPanel(tab: Tab, events: any[]) {
return <NotificationPreferencesPage />;
case 'templates':
return <TemplatesPage />;
case 'channels':
return <ChannelDetailsPage />;
case 'rpc-benchmark':
return <RpcBenchmarkPage />;
default:
return null;
}
Expand Down
177 changes: 177 additions & 0 deletions dashboard/src/components/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Badge styling standardization tests (#504)
*
* Verifies that all badge variants use consistent design tokens.
* Since jsdom doesn't compute CSS custom properties, we validate
* the token definitions exist and that components apply the correct
* badge class names.
*/
import * as fs from 'fs';
import * as path from 'path';
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { EventCard } from './EventCard';
import type { BlockchainEvent } from '../types/event';

const mockEvent: BlockchainEvent = {
eventId: 'evt-1',
type: 'TaskCreated',
eventName: 'TaskCreated',
ledger: 12345,
contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12',
receivedAt: Date.now(),
value: '100',
txHash: 'abcdef1234567890',
topic: [],
} as BlockchainEvent;

function readCssContent(): string {
return fs.readFileSync(
path.resolve(__dirname, '../index.css'),
'utf-8',
);
}

// ─── Token definition tests ─────────────────────────────────────────────────

describe('Badge design tokens defined (#504)', () => {
it('badge design tokens are defined in CSS', () => {
const cssContent = readCssContent();

// Verify core badge tokens exist
expect(cssContent).toContain('--badge-padding-y:');
expect(cssContent).toContain('--badge-padding-x:');
expect(cssContent).toContain('--badge-radius:');
expect(cssContent).toContain('--badge-font-size:');
expect(cssContent).toContain('--badge-font-weight:');
expect(cssContent).toContain('--badge-line-height:');

// Verify color tokens exist
expect(cssContent).toContain('--badge-green-bg:');
expect(cssContent).toContain('--badge-green-fg:');
expect(cssContent).toContain('--badge-blue-bg:');
expect(cssContent).toContain('--badge-blue-fg:');
expect(cssContent).toContain('--badge-red-bg:');
expect(cssContent).toContain('--badge-red-fg:');
expect(cssContent).toContain('--badge-yellow-bg:');
expect(cssContent).toContain('--badge-yellow-fg:');
expect(cssContent).toContain('--badge-purple-bg:');
expect(cssContent).toContain('--badge-purple-fg:');
expect(cssContent).toContain('--badge-orange-bg:');
expect(cssContent).toContain('--badge-orange-fg:');
expect(cssContent).toContain('--badge-neutral-bg:');
expect(cssContent).toContain('--badge-neutral-fg:');
});

it('all badge classes use var() references to tokens', () => {
const cssContent = readCssContent();

// Verify key badge classes reference tokens
expect(cssContent).toMatch(/\.event-card__badge\s*\{[^}]*var\(--badge-/);
expect(cssContent).toMatch(/\.event-card__badge--green\s*\{[^}]*var\(--badge-green-bg\)/);
expect(cssContent).toMatch(/\.event-card__badge--blue\s*\{[^}]*var\(--badge-blue-bg\)/);
expect(cssContent).toMatch(/\.event-card__badge--red\s*\{[^}]*var\(--badge-red-bg\)/);
expect(cssContent).toMatch(/\.event-card__badge--yellow\s*\{[^}]*var\(--badge-yellow-bg\)/);
expect(cssContent).toMatch(/\.event-card__badge--purple\s*\{[^}]*var\(--badge-purple-bg\)/);
expect(cssContent).toMatch(/\.event-card__badge--orange\s*\{[^}]*var\(--badge-orange-bg\)/);
expect(cssContent).toMatch(/\.event-card__badge--default\s*\{[^}]*var\(--badge-neutral-bg\)/);
});

it('status badges use token references', () => {
const cssContent = readCssContent();

expect(cssContent).toMatch(/\.status-badge\s*\{[^}]*var\(--badge-/);
expect(cssContent).toMatch(/\.status-badge--completed\s*\{[^}]*var\(--badge-green-bg\)/);
expect(cssContent).toMatch(/\.status-badge--processing\s*\{[^}]*var\(--badge-blue-bg\)/);
expect(cssContent).toMatch(/\.status-badge--failed\s*\{[^}]*var\(--badge-red-bg\)/);
});

it('notif result badges use token references', () => {
const cssContent = readCssContent();

expect(cssContent).toMatch(/\.notif-result-card__status--completed[^{]*\{[^}]*var\(--badge-green-bg\)/);
expect(cssContent).toMatch(/\.notif-result-card__status--pending[^{]*\{[^}]*var\(--badge-yellow-bg\)/);
expect(cssContent).toMatch(/\.notif-result-card__status--failed[^{]*\{[^}]*var\(--badge-red-bg\)/);
expect(cssContent).toMatch(/\.notif-result-card__status--cancelled[^{]*\{[^}]*var\(--badge-neutral-bg\)/);
});

it('webhook badge classes use token references', () => {
const cssContent = readCssContent();

expect(cssContent).toMatch(/\.webhook-failed-table__code--4xx[^{]*\{[^}]*var\(--badge-orange-bg\)/);
expect(cssContent).toMatch(/\.webhook-failed-table__code--5xx[^{]*\{[^}]*var\(--badge-red-bg\)/);
expect(cssContent).toMatch(/\.webhook-failed-table__category--4xx[^{]*\{[^}]*var\(--badge-orange-bg\)/);
});
});

// ─── Component badge class tests ────────────────────────────────────────────

describe('EventCard badge classes (#504)', () => {
const EVENT_TYPE_COLORS: Record<string, string> = {
TaskCreated: 'event-card__badge--green',
WorkSubmitted: 'event-card__badge--blue',
SubmissionApproved: 'event-card__badge--green',
SubmissionRejected: 'event-card__badge--red',
TaskCancelled: 'event-card__badge--red',
DisputeRaised: 'event-card__badge--yellow',
AutoshareCreated: 'event-card__badge--purple',
Withdrawal: 'event-card__badge--orange',
};

it.each(Object.entries(EVENT_TYPE_COLORS))(
'event type "%s" maps to badge class "%s"',
(eventType, expectedClass) => {
const { container } = render(
<EventCard event={{ ...mockEvent, eventName: eventType, type: eventType }} />,
);
const badge = container.querySelector('.event-card__badge');
expect(badge).toHaveClass(expectedClass);
},
);

it('unknown event type uses default badge', () => {
const { container } = render(
<EventCard event={{ ...mockEvent, eventName: 'UnknownEvent', type: 'UnknownEvent' }} />,
);
const badge = container.querySelector('.event-card__badge');
expect(badge).toHaveClass('event-card__badge--default');
});

it('null eventName uses default badge', () => {
const { container } = render(
<EventCard event={{ ...mockEvent, eventName: null }} />,
);
const badge = container.querySelector('.event-card__badge');
expect(badge).toHaveClass('event-card__badge--default');
});
});

// ─── Accessibility contrast checks ──────────────────────────────────────────

describe('Badge accessibility contrast (#504)', () => {
it('all badge foreground colors are defined as hex values', () => {
const cssContent = readCssContent();

// All foreground colors should be hex values (ensures they're not transparent)
const fgTokenPattern = /--badge-[a-z]+-fg:\s*(#[0-9a-fA-F]{3,8})/g;
const matches = [...cssContent.matchAll(fgTokenPattern)];
expect(matches.length).toBeGreaterThanOrEqual(7);

matches.forEach((match) => {
expect(match[1]).toMatch(/^#[0-9a-fA-F]{3,8}$/);
});
});

it('light theme badge colors are defined', () => {
const cssContent = readCssContent();

// Light theme should override badge colors
const lightSection = cssContent.substring(
cssContent.indexOf('[data-theme="light"]'),
);
expect(lightSection).toContain('--badge-green-bg:');
expect(lightSection).toContain('--badge-green-fg:');
expect(lightSection).toContain('--badge-blue-bg:');
expect(lightSection).toContain('--badge-red-bg:');
});
});
Loading