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
29 changes: 28 additions & 1 deletion Dechat/dex_with_fiat_frontend/src/components/CCIPBridgeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertCircle,
CheckCircle,
Expand Down Expand Up @@ -57,6 +57,29 @@ export default function CCIPBridgeModal({
const [errorMessage, setErrorMessage] = useState('');
const [networkChangedWhileActive, setNetworkChangedWhileActive] = useState(false);

// #1179: single sr-only live region announcing bridge state transitions.
// The visible per-state sections below aren't in an aria-live container,
// so a screen reader user starting a transfer would hear nothing as it
// moves through optimistic -> initiating -> polling -> success/error.
const bridgeAnnouncement = useMemo(() => {
switch (bridgeState) {
case 'optimistic':
return 'Transfer initiated. Preparing transaction.';
case 'initiating':
return 'Starting CCIP transfer.';
case 'polling':
return latestStatus
? `Waiting for CCIP confirmation. Latest status: ${latestStatus}.`
: 'Waiting for CCIP confirmation.';
case 'success':
return `CCIP transfer confirmed. Status: ${latestStatus || 'SUCCESS'}.`;
case 'error':
return `CCIP transfer error. ${errorMessage}`;
default:
return '';
}
}, [bridgeState, latestStatus, errorMessage]);

// Keep ref in sync with state.
// Keep ref in sync with state.
useEffect(() => {
Expand Down Expand Up @@ -291,6 +314,10 @@ export default function CCIPBridgeModal({
</button>
</div>

<div role="status" aria-live="polite" className="sr-only">
{bridgeAnnouncement}
</div>

{networkChangedWhileActive && (
<div
role="alert"
Expand Down
45 changes: 40 additions & 5 deletions Dechat/dex_with_fiat_frontend/src/components/ChatSearchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,15 @@ export default function ChatSearchPanel({
const [filters, setFilters] = useState<SearchFilters>(EMPTY_FILTERS);
const [results, setResults] = useState<MessageMatch[]>([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);

// #1183: keep the active-result index in range whenever the result set
// changes (e.g. a new keystroke narrows/reorders matches).
useEffect(() => {
setActiveIndex(results.length > 0 ? 0 : -1);
}, [results]);

// Reset all search state whenever the panel is closed so stale results
// are never shown the next time the panel is opened (#947).
const handleClose = () => {
Expand Down Expand Up @@ -111,8 +118,27 @@ export default function ChatSearchPanel({
inputRef.current?.focus();
}, []);

// #1183: ArrowDown/ArrowUp move the active result, Enter selects it,
// Escape still closes the panel.
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') {
onClose();
return;
}
if (results.length === 0) {
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => (i + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i - 1 + results.length) % results.length);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
const match = results[activeIndex];
if (match) onSelectResult(match.sessionId, match.message.id);
}
};

const hasAnyFilter =
Expand Down Expand Up @@ -236,12 +262,21 @@ export default function ChatSearchPanel({
<p className="text-sm">No messages found</p>
</div>
) : (
<ul className="divide-y" aria-label="Search results">
{results.map((match) => (
<li key={`${match.sessionId}-${match.message.id}`}>
<ul className="divide-y" role="listbox" aria-label="Search results">
{results.map((match, index) => (
<li key={`${match.sessionId}-${match.message.id}`} role="option" aria-selected={index === activeIndex}>
<button
onClick={() => onSelectResult(match.sessionId, match.message.id)}
className={`w-full text-left px-4 py-3 transition-colors ${isDarkMode ? 'hover:bg-gray-800' : 'hover:bg-gray-50'}`}
onMouseEnter={() => setActiveIndex(index)}
className={`w-full text-left px-4 py-3 transition-colors ${
index === activeIndex
? isDarkMode
? 'bg-gray-800'
: 'bg-gray-50'
: isDarkMode
? 'hover:bg-gray-800'
: 'hover:bg-gray-50'
}`}
>
<p className={`text-xs font-semibold truncate ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}>
{match.sessionTitle}
Expand Down
72 changes: 64 additions & 8 deletions Dechat/dex_with_fiat_frontend/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@ export default class ErrorBoundary extends React.Component<
return { hasError: true };
}

// #1184: "r" / "Enter" retries the same way the button's onClick does,
// for keyboard users who'd rather not reach for the mouse after a crash.
// Only acts while the fallback is actually showing, and ignores the
// shortcut while focus is in a form field so it can't hijack typing.
private handleKeyDown = (event: KeyboardEvent) => {
if (!this.state.hasError) {
return;
}
const target = event.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) {
return;
}
if (event.key === 'Enter' || event.key === 'r' || event.key === 'R') {
event.preventDefault();
this.retry();
}
};

private retry = () => {
if (this.props.onRetry) {
this.setState({ hasError: false });
this.props.onRetry();
return;
}
window.location.reload();
};

public componentDidMount() {
window.addEventListener('keydown', this.handleKeyDown);
}

public componentWillUnmount() {
window.removeEventListener('keydown', this.handleKeyDown);
}

public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Chat UI crashed:', error, errorInfo);

Expand Down Expand Up @@ -91,18 +127,38 @@ export default class ErrorBoundary extends React.Component<
</p>
<button
type="button"
onClick={() => {
if (this.props.onRetry) {
this.setState({ hasError: false });
this.props.onRetry();
return;
}
window.location.reload();
}}
onClick={this.retry}
className="mt-5 inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700"
>
{this.props.retryLabel ?? 'Reload'}
</button>
<p
className={`mt-3 text-xs ${
isDarkMode ? 'text-gray-400' : 'text-gray-500'
}`}
>
Press{' '}
<kbd
className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${
isDarkMode
? 'border-gray-600 bg-gray-700 text-gray-200'
: 'border-gray-300 bg-gray-100 text-gray-700'
}`}
>
Enter
</kbd>{' '}
or{' '}
<kbd
className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${
isDarkMode
? 'border-gray-600 bg-gray-700 text-gray-200'
: 'border-gray-300 bg-gray-100 text-gray-700'
}`}
>
R
</kbd>{' '}
to retry
</p>
</div>
</div>
);
Expand Down
24 changes: 24 additions & 0 deletions Dechat/dex_with_fiat_frontend/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,30 @@ export default function LandingPage() {
router.push('/chat');
};

// #1185: "g" launches the app (same as the Get Started CTA), "d" toggles
// the theme. Ignored while focus is in the email field (or any other
// form control) so typing "g"/"d" there isn't hijacked.
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) {
return;
}
if (event.key === 'g' || event.key === 'G') {
event.preventDefault();
handleGetStarted();
} else if (event.key === 'd' || event.key === 'D') {
event.preventDefault();
toggleDarkMode();
}
};

window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);

const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitted(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ describe('CCIPBridgeModal', () => {

expect(screen.getByText('CCIP transfer error')).toBeInTheDocument();
expect(
screen.getByText(/timed out after 10 minutes/i),
screen.getAllByText(/timed out after 10 minutes/i)[0],
).toBeInTheDocument();
});

Expand Down Expand Up @@ -584,7 +584,7 @@ describe('CCIPBridgeModal', () => {

expect(screen.getByText('CCIP transfer error')).toBeInTheDocument();
expect(
screen.getByText(/timed out after 10 minutes/i),
screen.getAllByText(/timed out after 10 minutes/i)[0],
).toBeInTheDocument();
});
});
Expand Down Expand Up @@ -677,7 +677,7 @@ describe('CCIPBridgeModal', () => {
await screen.findByText('CCIP transfer error'),
).toBeInTheDocument();
expect(
screen.getByText(/CCIP transfer failed with status "FAILED"/),
screen.getAllByText(/CCIP transfer failed with status "FAILED"/)[0],
).toBeInTheDocument();
});

Expand Down Expand Up @@ -992,4 +992,46 @@ describe('CCIPBridgeModal', () => {
expect(screen.getByText('CCIP transfer confirmed')).toBeInTheDocument();
});
});

describe('accessibility: live region announcements (#1179)', () => {
it('announces confirmation and status through a polite live region while polling', async () => {
const fetchTransferStatus = vi
.fn()
.mockResolvedValueOnce({ status: 'PENDING' });

render(
<CCIPBridgeModal
{...defaultProps}
fetchTransferStatus={fetchTransferStatus}
/>,
);

fireEvent.click(screen.getByText('Start CCIP Transfer'));

await screen.findByText('Waiting for CCIP confirmation…');

const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion.textContent).toMatch(/Waiting for CCIP confirmation/);
expect(liveRegion.textContent).toMatch(/PENDING/);
});

it('announces transfer errors through the live region', async () => {
const onStartTransfer = vi
.fn()
.mockRejectedValue(new Error('Insufficient allowance'));

render(
<CCIPBridgeModal {...defaultProps} onStartTransfer={onStartTransfer} />,
);

fireEvent.click(screen.getByText('Start CCIP Transfer'));

await screen.findByText('CCIP transfer error');

const liveRegion = screen.getByRole('status');
expect(liveRegion.textContent).toMatch(/CCIP transfer error/);
expect(liveRegion.textContent).toMatch(/Insufficient allowance/);
});
});
});
Loading
Loading