diff --git a/fix.md b/fix.md index 0a3ed66..2ddff05 100644 --- a/fix.md +++ b/fix.md @@ -1,9 +1,9 @@ -[Frontend] Implement framer-motion animations for Multi-sig Approval Modal +[Frontend] Improve screen reader support for Multi-sig Approval Modal Repo Avatar emdevelopa/Stellar_Payment_API Description This task involves UX enhancement for the Multi-sig Approval Modal module. -The goal is to implement framer-motion animations for Multi-sig Approval Modal to improve the platform's styling, user interactions, and overall accessibility. +The goal is to improve screen reader support for Multi-sig Approval Modal to improve the platform's styling, user interactions, and overall accessibility. Requirements and context @@ -14,11 +14,11 @@ Specifically focused on frontend UX enhancement and responsiveness Suggested execution Fork the repo and create a branch -git checkout -b feature/fe-implement-framer-motion-animations-for-multi-sig-approval-modal +git checkout -b feature/fe-improve-screen-reader-support-for-multi-sig-approval-modal Implement changes Review existing component in Multi-sig Approval Modal -Apply changes: Implement framer-motion animations for Multi-sig Approval Modal +Apply changes: Improve screen reader support for Multi-sig Approval Modal Use clean CSS or tailwind variables for styling Maintain state transitions and visual feedback Test and commit diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2d6c210..4d847a4 100644 Binary files a/frontend/package-lock.json and b/frontend/package-lock.json differ diff --git a/frontend/src/components/MultisigApprovalModal.test.tsx b/frontend/src/components/MultisigApprovalModal.test.tsx index 5de37fb..45cb5da 100644 --- a/frontend/src/components/MultisigApprovalModal.test.tsx +++ b/frontend/src/components/MultisigApprovalModal.test.tsx @@ -454,6 +454,64 @@ describe("MultisigApprovalModal Component", () => { expect(decorativeElement).toBeInTheDocument(); }); }); + + it("sets aria-busy on dialog during loading", async () => { + renderWithProvider(); + + const modal = screen.getByRole("dialog"); + expect(modal).toHaveAttribute("aria-busy", "false"); + + const signButtons = screen.getAllByText("Sign"); + fireEvent.click(signButtons[0]); + + expect(modal).toHaveAttribute("aria-busy", "true"); + + await waitFor(() => { + expect(modal).toHaveAttribute("aria-busy", "false"); + }, { timeout: 2000 }); + }); + + it("has screen reader announcement region for step transitions", () => { + renderWithProvider(); + + const announcementRegion = document.querySelector('[aria-live="assertive"][aria-atomic="true"]'); + expect(announcementRegion).toBeInTheDocument(); + expect(announcementRegion).toHaveClass("sr-only"); + }); + + it("traps focus within the modal", async () => { + renderWithProvider(); + + const modal = screen.getByRole("dialog"); + const closeButton = screen.getByLabelText("Close modal"); + const signButtons = screen.getAllByText("Sign"); + const firstSignButton = signButtons[0]; + + closeButton.focus(); + + // Tab forward from close button should move to first sign button + fireEvent.keyDown(document, { key: "Tab" }); + // Wait for React to process + await waitFor(() => { + expect(document.activeElement).toBe(firstSignButton); + }); + }); + + it("traps focus in reverse direction with Shift+Tab", () => { + renderWithProvider(); + + const modal = screen.getByRole("dialog"); + const closeButton = screen.getByLabelText("Close modal"); + const signButtons = screen.getAllByText("Sign"); + const firstSignButton = signButtons[0]; + + firstSignButton.focus(); + + // Shift+Tab from first sign button should wrap to close button + fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); + + expect(document.activeElement).toBe(closeButton); + }); }); describe("Transaction Expiry", () => { @@ -520,4 +578,139 @@ describe("MultisigApprovalModal Component", () => { expect(copyButtons.length).toBeGreaterThan(0); }); }); + + describe("Edge Cases", () => { + it("handles empty signers list gracefully", () => { + const noSignersTransaction = { + ...mockTransaction, + signers: [], + }; + renderWithProvider({ transaction: noSignersTransaction }); + expect(screen.getByText("Multi-Signature Approval")).toBeInTheDocument(); + expect(screen.getByText(/Transaction must have at least one signer/i)).toBeInTheDocument(); + }); + + it("handles single signer correctly", () => { + const singleSignerTransaction = { + ...mockTransaction, + signers: [ + { id: "signer1", publicKey: "G123...", name: "Alice", weight: 1, hasSigned: false }, + ], + minSignatures: 1, + }; + renderWithProvider({ transaction: singleSignerTransaction }); + + const signButtons = screen.getAllByText("Sign"); + expect(signButtons).toHaveLength(1); + expect(screen.getByText("Signatures (0/1)")).toBeInTheDocument(); + }); + + it("shows all signers as signed when pre-signed", () => { + const allSignedTransaction = { + ...mockTransaction, + signers: [ + { id: "signer1", publicKey: "G123...", name: "Alice", weight: 1, hasSigned: true }, + { id: "signer2", publicKey: "G456...", name: "Bob", weight: 1, hasSigned: true }, + ], + }; + renderWithProvider({ transaction: allSignedTransaction }); + + const signedButtons = screen.getAllByText("Signed"); + expect(signedButtons).toHaveLength(2); + expect(screen.getByText("Submit Transaction")).toBeInTheDocument(); + }); + }); + + describe("Component Cleanup", () => { + it("restores body overflow on unmount", () => { + const { unmount } = renderWithProvider({ isOpen: true }); + expect(document.body.style.overflow).toBe("hidden"); + unmount(); + expect(document.body.style.overflow).toBe(""); + }); + }); + + describe("Loading Interaction Guards", () => { + it("disables close button during loading", async () => { + renderWithProvider(); + + const signButtons = screen.getAllByText("Sign"); + fireEvent.click(signButtons[0]); + + const closeButton = screen.getByLabelText("Close modal"); + expect(closeButton).toBeDisabled(); + + await waitFor(() => { + expect(closeButton).not.toBeDisabled(); + }, { timeout: 2000 }); + }); + + it("prevents backdrop close during loading", async () => { + const mockOnClose = jest.fn(); + renderWithProvider({ onClose: mockOnClose }); + + const signButtons = screen.getAllByText("Sign"); + fireEvent.click(signButtons[0]); + + const backdrop = screen.getByText("Multi-Signature Approval") + .closest('[role="dialog"]')?.previousSibling as HTMLElement; + if (backdrop) { + fireEvent.click(backdrop); + } + + expect(mockOnClose).not.toHaveBeenCalled(); + }); + }); + + describe("Render Boundary States", () => { + it("renders without crashing when transaction is null", () => { + renderWithProvider({ transaction: null }); + + expect(screen.getByText("Multi-Signature Approval")).toBeInTheDocument(); + expect(screen.getByText("Review Transaction")).toBeInTheDocument(); + expect(screen.queryByText("100 USDC")).not.toBeInTheDocument(); + }); + + it("reopens correctly after being closed", () => { + const mockOnClose = jest.fn(); + const { rerender } = render( + + + + ); + + expect(screen.getByText("Multi-Signature Approval")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.queryByText("Multi-Signature Approval")).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByText("Multi-Signature Approval")).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/MultisigApprovalModal.tsx b/frontend/src/components/MultisigApprovalModal.tsx index cc3901a..b696f68 100644 --- a/frontend/src/components/MultisigApprovalModal.tsx +++ b/frontend/src/components/MultisigApprovalModal.tsx @@ -100,20 +100,48 @@ export default function MultisigApprovalModal({ } }, [isOpen, initialTransaction, transaction, setTransaction]); - // Handle escape key and focus management + // Handle escape key, focus trap, and focus return useEffect(() => { if (!isOpen) return; + const triggerElement = document.activeElement as HTMLElement | null; + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { handleClose(); + return; + } + + if (e.key === "Tab" && modalRef.current) { + const focusable = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.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); modalRef.current?.focus(); - return () => document.removeEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + triggerElement?.focus(); + }; }, [isOpen]); // Body scroll lock @@ -426,6 +454,7 @@ export default function MultisigApprovalModal({ tabIndex={-1} role="dialog" aria-modal="true" + aria-busy={isLoading} aria-labelledby="multisig-modal-title" aria-describedby="multisig-modal-description" > @@ -453,6 +482,21 @@ export default function MultisigApprovalModal({ + {/* Screen reader announcements */} +
+ {currentStep === "processing" + ? "Processing your transaction. Please wait." + : currentStep === "confirm" + ? "Transaction approved successfully." + : currentStep === "error" + ? "Transaction failed. See error details below." + : ""} +
+ {/* Content */}