diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9d90ad..4b909a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx index 1b9f2d41..a6c6fc03 100644 --- a/frontend/src/components/RecentTips.jsx +++ b/frontend/src/components/RecentTips.jsx @@ -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'; @@ -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. @@ -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); @@ -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) { @@ -312,9 +388,14 @@ export default function RecentTips({ addToast }) { {/* Tip-back modal */} {tipBackTarget && (
Send a tip to the original sender of tip #{tipBackTarget.tipId}