Skip to content
Open
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
9 changes: 7 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,6 +16,7 @@ export default function App() {
const [search, setSearch] = useState('');
const [stateFilter, setStateFilter] = useState<ProposalState | 'All'>('All');
const [selected, setSelected] = useState<Proposal | null>(null);
const triggerRef = useRef<HTMLElement>(null);
const [walletAddress, setWalletAddress] = useState<string | null>(null);
const [tokenBalance, setTokenBalance] = useState<bigint | null>(null);
const [decimals, setDecimals] = useState<number>(0);
Expand Down Expand Up @@ -118,7 +119,10 @@ export default function App() {
<p style={{ textAlign: 'center', color: '#888' }}>No proposals found.</p>
)}
{!loading && filtered.map(p => (
<ProposalCard key={String(p.id)} proposal={p} onClick={() => setSelected(p)} />
<ProposalCard key={String(p.id)} proposal={p} onClick={(e) => {
triggerRef.current = e?.currentTarget as HTMLElement ?? null;
setSelected(p);
}} />
))}
</div>
</main>
Expand All @@ -129,6 +133,7 @@ export default function App() {
decimals={decimals}
walletAddress={walletAddress}
onClose={() => setSelected(null)}
triggerRef={triggerRef}
/>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/ProposalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const STATE_COLORS: Record<ProposalState, string> = {
interface Props {
proposal: Proposal;
decimals: number;
onClick: () => void;
onClick: (e?: React.MouseEvent | React.KeyboardEvent) => void;
}

function formatDate(ts: bigint): string {
Expand All @@ -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);
}
};

Expand Down
79 changes: 71 additions & 8 deletions frontend/src/components/ProposalDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,108 @@
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 {
proposal: Proposal;
decimals: number;
walletAddress: string | null;
onClose: () => void;
triggerRef?: React.RefObject<HTMLElement>;
}

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<boolean | null>(null);
const [voteRecord, setVoteRecord] = useState<{ vote: string; weight: bigint } | null>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
if (!walletAddress) return;
fetchHasVoted(Number(p.id), walletAddress).then(setHasVoted);
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<HTMLElement>(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 (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
}}
<div
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100,
}}
onClick={onClose}
aria-hidden="true"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="proposal-dialog-title"
style={{ background: '#fff', borderRadius: 12, padding: '2rem', maxWidth: 600, width: '90%', maxHeight: '80vh', overflowY: 'auto' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>Proposal #{String(p.id)}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer' }}>×</button>
<h2 id="proposal-dialog-title" style={{ margin: 0 }}>Proposal #{String(p.id)}</h2>
<button
ref={closeButtonRef}
onClick={onClose}
aria-label="Close dialog"
style={{ background: 'none', border: 'none', fontSize: '1.5rem', cursor: 'pointer' }}
>×</button>
</div>

<h3 style={{ margin: '0 0 0.5rem' }}>{p.title}</h3>
Expand Down