+ );
+}
+
+describe('useFocusTrap', () => {
+ it('focuses the container itself when it has no focusable children', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('trigger'));
+
+ const panel = screen.getByTestId('panel');
+ expect(panel).toHaveAttribute('tabindex', '-1');
+ expect(document.activeElement).toBe(panel);
+ });
+
+ it('wraps Shift+Tab from the first focusable back to the last', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('trigger'));
+
+ const first = screen.getByTestId('first');
+ const last = screen.getByTestId('last');
+ expect(document.activeElement).toBe(first);
+
+ await user.tab({ shift: true });
+
+ expect(document.activeElement).toBe(last);
+ });
+
+ it('does not throw when Tab is pressed while there are no focusable children', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('trigger'));
+ const panel = screen.getByTestId('panel');
+ expect(document.activeElement).toBe(panel);
+
+ // Should hit the early-return branch in the keydown handler rather
+ // than throwing on `current[0]` / `current[current.length - 1]`.
+ await expect(user.keyboard('{Tab}')).resolves.not.toThrow();
+ });
+
+ it('does not intercept Tab when moving between non-boundary elements', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByTestId('trigger'));
+ const middle = screen.getByTestId('middle');
+
+ middle.focus();
+ await user.tab();
+
+ // Moving forward from the middle item is not a boundary case — the
+ // trap should let it proceed to "last" without any special handling.
+ expect(document.activeElement).toBe(screen.getByTestId('last'));
+ });
+});
diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts
new file mode 100644
index 0000000..6361bf5
--- /dev/null
+++ b/src/hooks/useFocusTrap.ts
@@ -0,0 +1,78 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * Traps Tab/Shift+Tab focus inside a container while it's open, moves focus
+ * to the first focusable element on open, and returns focus to whatever was
+ * focused before open (normally the trigger button) when it closes —
+ * whether closed via Escape, outside click, or an explicit Apply/Close action.
+ *
+ * Usage:
+ * const panelRef = useFocusTrap(openPopover === 'date');
+ *
...
+ */
+const FOCUSABLE_SELECTOR =
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export function useFocusTrap(isOpen: boolean) {
+ const containerRef = useRef(null);
+ const previouslyFocusedRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ // Remember whatever had focus before this container opened (the trigger).
+ previouslyFocusedRef.current = document.activeElement as HTMLElement;
+
+ const container = containerRef.current;
+ /* v8 ignore next 2 -- defensive guard; React attaches the ref before
+ this effect runs for any mounted panel, so this branch is unreachable
+ in normal operation and is not worth a contrived test. */
+ if (!container) return;
+
+ // NOTE: deliberately not filtering by `offsetParent` here — jsdom (and
+ // some other non-layout test environments) never populates it, which
+ // silently breaks the trap under test even though it works visually in
+ // a real browser. Every open popover/sheet in this component is fully
+ // visible while mounted, so a plain selector match is sufficient and
+ // test-environment-safe.
+ const getFocusables = () =>
+ Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
+
+ const focusables = getFocusables();
+ const first = focusables[0];
+
+ if (first) {
+ first.focus();
+ } else {
+ container.setAttribute('tabindex', '-1');
+ container.focus();
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'Tab') return;
+
+ const current = getFocusables();
+ if (current.length === 0) return;
+ const firstEl = current[0];
+ const lastEl = current[current.length - 1];
+
+ if (e.shiftKey && document.activeElement === firstEl) {
+ e.preventDefault();
+ lastEl.focus();
+ } else if (!e.shiftKey && document.activeElement === lastEl) {
+ e.preventDefault();
+ firstEl.focus();
+ }
+ };
+
+ container.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ container.removeEventListener('keydown', handleKeyDown);
+ // Return focus to the trigger that opened this container.
+ previouslyFocusedRef.current?.focus?.();
+ };
+ }, [isOpen]);
+
+ return containerRef;
+}
diff --git a/src/pages/DistributionDashboard.tsx b/src/pages/DistributionDashboard.tsx
index 7b92fd9..6f4fd0f 100644
--- a/src/pages/DistributionDashboard.tsx
+++ b/src/pages/DistributionDashboard.tsx
@@ -8,7 +8,9 @@ import { EmptyState } from '../components/designSystem/EmptyState';
import { KycResubmissionTimeline } from '../components/KycResubmissionTimeline';
import { GovernanceResults } from '../components/designSystem/GovernanceResults';
import { DocumentUploadStatus } from '../components/DocumentUploadStatus';
+import { DistributionFilterToolbar } from '../components/DistributionFilterToolbar/DistributionFilterToolbar';
import type { DistributionFilterState } from '../components/DistributionFilterToolbar/DistributionFilterToolbar.types';
+import { PayoutDrillDownPanel } from '../components/PayoutDrillDownPanel/PayoutDrillDownPanel';
import type { PayoutDetail, RecipientItem, RetryEvent } from '../components/PayoutDrillDownPanel/PayoutDrillDownPanel.types';
import { ErrorRateSparklineTile } from '../components/ErrorRateSparklineTile/ErrorRateSparklineTile';
import type { ErrorRateDataPoint } from '../components/ErrorRateSparklineTile/ErrorRateSparklineTile';
@@ -18,6 +20,10 @@ import { BlacklistBulkRemoveConfirm, BlacklistEntry } from '../components/Blackl
import { GovernanceProposalDetail, type ProposalData } from '../components/designSystem/GovernanceProposalDetail';
import { UploadQueue } from '../components/UploadQueue/UploadQueue';
import { useUploadQueue, type Uploader } from '../hooks/useUploadQueue';
+import { PreOpenBanner } from '../components/PreOpenBanner';
+import { TokenSupplyBlock } from '../components/TokenSupplyBlock/TokenSupplyBlock';
+import { FinancialTermsForm } from '../components/FinancialTermsForm/FinancialTermsForm';
+import type { FinancialTermsField } from '../utils/financialTermsValidation';
interface ExtendedPayoutDetail extends PayoutDetail {
region: string;
@@ -242,7 +248,56 @@ export const DistributionDashboard: React.FC = () => {
};
});
+ const [payoutsList, setPayoutsList] = useState(MOCK_PAYOUTS);
+ const [selectedPayoutId, setSelectedPayoutId] = useState(
+ searchParams.get('payoutId') || null
+ );
+ const {
+ queue,
+ addFiles,
+ removeFile,
+ retryFile,
+ uploadFiles,
+ clearComplete,
+ totalCount,
+ successCount,
+ errorCount,
+ uploadingCount,
+ overallProgress,
+ } = useUploadQueue();
+
+ // Keeps filterState and the URL query params in sync (so filter/segment/
+ // compare selections are shareable and survive a refresh).
+ const updateFiltersAndUrl = useCallback(
+ (next: DistributionFilterState) => {
+ setFilterState(next);
+
+ const newParams = new URLSearchParams(searchParams);
+ const setOrDelete = (key: string, value: string, defaultValue: string) => {
+ if (value && value !== defaultValue) {
+ newParams.set(key, value);
+ } else {
+ newParams.delete(key);
+ }
+ };
+
+ setOrDelete('search', next.searchQuery, '');
+ setOrDelete('date', next.dateRange, 'all');
+ setOrDelete('issuer', next.issuer, 'all');
+ setOrDelete('region', next.region, 'all');
+ setOrDelete('status', next.status, 'all');
+ setOrDelete('segment', next.segmentBy, 'none');
+ if (next.compareMode) {
+ newParams.set('compare', 'true');
+ } else {
+ newParams.delete('compare');
+ }
+
+ setSearchParams(newParams);
+ },
+ [searchParams, setSearchParams]
+ );
const handleUploadAll = useCallback(() => {
uploadFiles(mockUploader);
@@ -442,7 +497,6 @@ export const DistributionDashboard: React.FC = () => {
console.log('Dismissed incident:', id);
}}
/>
-
{!bannerDismissed && (
{
- Track each document’s progress, retry failures, and remove completed or cancelled files.
+ Track each document's progress, retry failures, and remove completed or cancelled files.
{
+ {/* ── Segmented / Compare Breakdown ──
+ Renders `segmentedData`, which is computed from the toolbar's
+ segmentBy / compareMode selections. Previously this data was
+ computed but never rendered anywhere, so picking a segment or
+ toggling Compare had no visible effect (issue #437). */}
+ {segmentedData && (
+
+