diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e2f46c..9499228 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import type { Proposal, ProposalState } from './types'; import { fetchAllProposals, fetchTokenBalance, fetchTokenDecimals } from './api'; import { ProposalCard } from './components/ProposalCard'; @@ -16,6 +16,7 @@ export default function App() { const [search, setSearch] = useState(''); const [stateFilter, setStateFilter] = useState('All'); const [selected, setSelected] = useState(null); + const triggerRef = useRef(null); const [walletAddress, setWalletAddress] = useState(null); const [tokenBalance, setTokenBalance] = useState(null); const [decimals, setDecimals] = useState(0); @@ -118,7 +119,10 @@ export default function App() {

No proposals found.

)} {!loading && filtered.map(p => ( - setSelected(p)} /> + { + triggerRef.current = e?.currentTarget as HTMLElement ?? null; + setSelected(p); + }} /> ))} @@ -129,6 +133,7 @@ export default function App() { decimals={decimals} walletAddress={walletAddress} onClose={() => setSelected(null)} + triggerRef={triggerRef} /> )} diff --git a/frontend/src/components/ProposalCard.tsx b/frontend/src/components/ProposalCard.tsx index b2ba30b..86f251e 100644 --- a/frontend/src/components/ProposalCard.tsx +++ b/frontend/src/components/ProposalCard.tsx @@ -12,7 +12,7 @@ const STATE_COLORS: Record = { interface Props { proposal: Proposal; decimals: number; - onClick: () => void; + onClick: (e?: React.MouseEvent | React.KeyboardEvent) => void; } function formatDate(ts: bigint): string { @@ -35,7 +35,7 @@ export function ProposalCard({ proposal: p, decimals, onClick }: Props) { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - onClick(); + onClick(e); } }; diff --git a/frontend/src/components/ProposalDetail.tsx b/frontend/src/components/ProposalDetail.tsx index 51b1ccf..caaa6f0 100644 --- a/frontend/src/components/ProposalDetail.tsx +++ b/frontend/src/components/ProposalDetail.tsx @@ -1,6 +1,6 @@ import type { Proposal } from '../types'; import { fetchHasVoted, fetchVoteRecord } from '../api'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { formatTokenAmount } from '../utils'; interface Props { @@ -8,15 +8,18 @@ interface Props { decimals: number; walletAddress: string | null; onClose: () => void; + triggerRef?: React.RefObject; } function formatDate(ts: bigint): string { return new Date(Number(ts) * 1000).toLocaleString(); } -export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }: Props) { +export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose, triggerRef }: Props) { const [hasVoted, setHasVoted] = useState(null); const [voteRecord, setVoteRecord] = useState<{ vote: string; weight: bigint } | null>(null); + const dialogRef = useRef(null); + const closeButtonRef = useRef(null); useEffect(() => { if (!walletAddress) return; @@ -24,22 +27,82 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose } fetchVoteRecord(Number(p.id), walletAddress).then(setVoteRecord); }, [p.id, walletAddress]); + // Focus the close button on open + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + // Return focus to trigger on unmount + useEffect(() => { + return () => { + triggerRef?.current?.focus(); + }; + }, [triggerRef]); + + // Focus trap + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + const focusable = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + return; + } + if (e.key !== 'Tab') return; + + const elements = Array.from(dialog.querySelectorAll(focusable)); + if (elements.length === 0) return; + + const first = elements[0]; + const last = elements[elements.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + const total = p.votes_yes + p.votes_no + p.votes_abstain; return ( -