Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
869adf6
fix: add full keyboard-accessible tip-back modal behavior
Mosas2000 Mar 14, 2026
7fdc439
chore(issue-236): progress checkpoint 1
Mosas2000 Mar 14, 2026
37fa5b7
chore(issue-236): progress checkpoint 2
Mosas2000 Mar 14, 2026
edbe21b
chore(issue-236): progress checkpoint 3
Mosas2000 Mar 14, 2026
c2ab61d
chore(issue-236): progress checkpoint 4
Mosas2000 Mar 14, 2026
af2084d
chore(issue-236): progress checkpoint 5
Mosas2000 Mar 14, 2026
f996b1b
chore(issue-236): progress checkpoint 6
Mosas2000 Mar 14, 2026
8b5d8b2
chore(issue-236): progress checkpoint 7
Mosas2000 Mar 14, 2026
b5109fe
chore(issue-236): progress checkpoint 8
Mosas2000 Mar 14, 2026
7edb08f
chore(issue-236): progress checkpoint 9
Mosas2000 Mar 14, 2026
cd514e0
chore(issue-236): progress checkpoint 10
Mosas2000 Mar 14, 2026
ff4b766
chore(issue-236): progress checkpoint 11
Mosas2000 Mar 14, 2026
fdb1fe7
chore(issue-236): progress checkpoint 12
Mosas2000 Mar 14, 2026
13f9699
chore(issue-236): progress checkpoint 13
Mosas2000 Mar 14, 2026
57746ad
chore(issue-236): progress checkpoint 14
Mosas2000 Mar 14, 2026
0dc722d
chore(issue-236): progress checkpoint 15
Mosas2000 Mar 14, 2026
c2e19ca
chore(issue-236): progress checkpoint 16
Mosas2000 Mar 14, 2026
6e214c2
chore(issue-236): progress checkpoint 17
Mosas2000 Mar 14, 2026
606ff59
chore(issue-236): progress checkpoint 18
Mosas2000 Mar 14, 2026
fa79a18
chore(issue-236): progress checkpoint 19
Mosas2000 Mar 14, 2026
7c10945
chore(issue-236): progress checkpoint 20
Mosas2000 Mar 14, 2026
a83eeab
chore(issue-236): progress checkpoint 21
Mosas2000 Mar 14, 2026
a4c847b
chore(issue-236): progress checkpoint 22
Mosas2000 Mar 14, 2026
358dff5
chore(issue-236): progress checkpoint 23
Mosas2000 Mar 14, 2026
591d396
chore(issue-236): progress checkpoint 24
Mosas2000 Mar 14, 2026
eb5ba2b
chore(issue-236): progress checkpoint 25
Mosas2000 Mar 14, 2026
b10dde9
chore(issue-236): progress checkpoint 26
Mosas2000 Mar 14, 2026
2c77570
chore(issue-236): progress checkpoint 27
Mosas2000 Mar 14, 2026
741296f
chore(issue-236): progress checkpoint 28
Mosas2000 Mar 14, 2026
423476c
chore(issue-236): progress checkpoint 29
Mosas2000 Mar 14, 2026
d3e037c
chore(issue-236): progress checkpoint 30
Mosas2000 Mar 14, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- `RecentTips` tip-back modal now provides complete dialog keyboard support:
it traps `Tab`/`Shift+Tab` focus within the modal, closes on `Escape`,
restores focus to the previously focused trigger on close, and supports
backdrop click-to-close while preserving dialog semantics (Issue #236).

- `clearTipCache()` was executed inside automatic message-enrichment
effects in both `RecentTips` and `TipHistory`, causing each refresh
cycle to wipe shared tip-detail cache data for all mounted consumers.
Expand Down Expand Up @@ -44,6 +49,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
triggered by user `Refresh`/`Retry` actions, plus tip ID deduplication
before message enrichment.

### Added (Issue #236)

- `frontend/src/test/RecentTips.modal-a11y.test.jsx` with 4 integration
tests covering modal role semantics, initial focus placement,
`Escape` close with focus restoration, focus trapping, and backdrop
click close behavior.

- Four components (`Leaderboard`, `RecentTips`, `TipHistory`,
`useNotifications`) each polled the same Stacks API contract-events
endpoint on independent intervals, generating up to 15+ requests per
Expand Down
89 changes: 85 additions & 4 deletions frontend/src/components/RecentTips.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { openContractCall } from '@stacks/connect';
import { uintCV, stringUtf8CV } from '@stacks/transactions';
import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_TIP_A_TIP } from '../config/contracts';
Expand Down Expand Up @@ -48,6 +48,8 @@ export default function RecentTips({ addToast }) {
const [showFilters, setShowFilters] = useState(false);
const [offset, setOffset] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const tipBackModalRef = useRef(null);
const previousFocusRef = useRef(null);

// Manual refresh only: invalidate local tip-detail cache, then ask
// TipContext to refresh shared events. Keep this out of auto effects.
Expand Down Expand Up @@ -98,6 +100,80 @@ export default function RecentTips({ addToast }) {
try { await contextLoadMore(); } finally { setLoadingMore(false); }
};

const closeTipBackModal = useCallback(() => {
setTipBackTarget(null);
}, []);

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

return Array.from(
modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'),
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true');
}, []);

const handleTipBackModalKeyDown = useCallback((event) => {
if (!tipBackTarget) return;

if (event.key === 'Escape') {
event.preventDefault();
closeTipBackModal();
return;
}

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

const focusable = getFocusableElements();
if (focusable.length === 0) {
event.preventDefault();
tipBackModalRef.current?.focus();
return;
}

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

if (event.shiftKey) {
if (active === first || active === tipBackModalRef.current) {
event.preventDefault();
last.focus();
}
return;
}

if (active === last) {
event.preventDefault();
first.focus();
}
}, [closeTipBackModal, getFocusableElements, tipBackTarget]);

useEffect(() => {
if (!tipBackTarget) {
if (previousFocusRef.current && previousFocusRef.current.focus) {
previousFocusRef.current.focus();
}
previousFocusRef.current = null;
return undefined;
}

previousFocusRef.current = document.activeElement;

const timer = window.setTimeout(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
return;
}
tipBackModalRef.current?.focus();
}, 0);

return () => {
window.clearTimeout(timer);
};
}, [getFocusableElements, tipBackTarget]);

/** Handle changes to the tip-back amount input with real-time validation. */
const handleTipBackAmountChange = (value) => {
setTipBackAmount(value);
Expand Down Expand Up @@ -132,7 +208,7 @@ export default function RecentTips({ addToast }) {
functionArgs: [uintCV(parseInt(tip.tipId)), uintCV(microSTX), stringUtf8CV(tipBackMessage || 'Tipping back!')],
postConditions: [tipPostCondition(senderAddress, microSTX)],
postConditionMode: SAFE_POST_CONDITION_MODE,
onFinish: (data) => { setSending(false); setTipBackTarget(null); setTipBackMessage(''); addToast?.('Tip-a-tip sent! Tx: ' + data.txId, 'success'); },
onFinish: (data) => { setSending(false); closeTipBackModal(); setTipBackMessage(''); addToast?.('Tip-a-tip sent! Tx: ' + data.txId, 'success'); },
onCancel: () => { setSending(false); addToast?.('Tip-a-tip cancelled', 'info'); },
});
} catch (err) {
Expand Down Expand Up @@ -312,9 +388,14 @@ export default function RecentTips({ addToast }) {
{/* Tip-back modal */}
{tipBackTarget && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
onClick={closeTipBackModal}
role="dialog" aria-modal="true" aria-labelledby="tipback-modal-title"
data-testid="tipback-modal">
<div className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-gray-200 dark:border-gray-700">
<div ref={tipBackModalRef}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
onKeyDown={handleTipBackModalKeyDown}
className="bg-white dark:bg-gray-900 rounded-2xl p-6 max-w-sm w-full shadow-2xl border border-gray-200 dark:border-gray-700">
<h3 id="tipback-modal-title" className="text-lg font-bold text-gray-900 dark:text-white mb-2">Tip Back</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">Send a tip to the original sender of tip #{tipBackTarget.tipId}</p>
<div className="space-y-3 mb-4">
Expand All @@ -338,7 +419,7 @@ export default function RecentTips({ addToast }) {
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setTipBackTarget(null)}
<button onClick={closeTipBackModal}
data-testid="tipback-cancel-btn"
className="flex-1 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-semibold rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">Cancel</button>
<button onClick={() => handleTipBack(tipBackTarget)} disabled={sending || !!tipBackError}
Expand Down
123 changes: 123 additions & 0 deletions frontend/src/test/RecentTips.modal-a11y.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import RecentTips from '../components/RecentTips';
import { useTipContext } from '../context/TipContext';

const { isUserSignedIn } = vi.hoisted(() => ({
isUserSignedIn: vi.fn(() => true),
}));

vi.mock('../context/TipContext', () => ({
useTipContext: vi.fn(),
}));

vi.mock('../lib/fetchTipDetails', () => ({
fetchTipMessages: vi.fn(() => Promise.resolve(new Map())),
clearTipCache: vi.fn(),
}));

vi.mock('@stacks/connect', () => ({
openContractCall: vi.fn(),
}));

vi.mock('../utils/stacks', () => ({
network: {},
appDetails: { name: 'TipStream', icon: 'http://localhost/logo.svg' },
userSession: { isUserSignedIn, loadUserData: vi.fn(() => ({})) },
getSenderAddress: vi.fn(() => 'SP1SENDER'),
}));

describe('RecentTips tip-back modal accessibility', () => {
beforeEach(() => {
vi.clearAllMocks();
isUserSignedIn.mockReturnValue(true);

useTipContext.mockReturnValue({
events: [
{
event: 'tip-sent',
tipId: '1',
sender: 'SP1SENDER',
recipient: 'SP2RECIPIENT',
amount: '1000000',
fee: '50000',
timestamp: 1700000000,
txId: '0xabc',
},
],
eventsLoading: false,
eventsError: null,
eventsMeta: { total: 1, hasMore: false },
lastEventRefresh: null,
refreshEvents: vi.fn(),
loadMoreEvents: vi.fn(),
});
});

const openTipBackModal = async () => {
render(<RecentTips addToast={vi.fn()} />);
const trigger = await screen.findByRole('button', { name: 'Tip Back' });
trigger.focus();
fireEvent.click(trigger);

await screen.findByTestId('tipback-modal');

return trigger;
};

it('opens with dialog semantics and moves focus into the modal', async () => {
await openTipBackModal();

const dialog = screen.getByRole('dialog', { name: 'Tip Back' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveAttribute('aria-labelledby', 'tipback-modal-title');

await waitFor(() => {
expect(screen.getByTestId('tipback-amount-input')).toHaveFocus();
});
});

it('closes on Escape and restores focus to the trigger', async () => {
const trigger = await openTipBackModal();

const amountInput = screen.getByTestId('tipback-amount-input');
fireEvent.keyDown(amountInput, { key: 'Escape' });

await waitFor(() => {
expect(screen.queryByTestId('tipback-modal')).not.toBeInTheDocument();
});

expect(trigger).toHaveFocus();
});

it('traps Tab focus inside the modal', async () => {
await openTipBackModal();

const amountInput = screen.getByTestId('tipback-amount-input');
const sendButton = screen.getByTestId('tipback-send-btn');

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

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

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

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

it('closes when clicking the backdrop', async () => {
await openTipBackModal();

fireEvent.click(screen.getByTestId('tipback-modal'));

await waitFor(() => {
expect(screen.queryByTestId('tipback-modal')).not.toBeInTheDocument();
});
});
});
Loading