Skip to content
Merged
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
8 changes: 4 additions & 4 deletions fix.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down
Binary file modified frontend/package-lock.json
Binary file not shown.
193 changes: 193 additions & 0 deletions frontend/src/components/MultisigApprovalModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
<MultisigProvider networkPassphrase="Test Network">
<MultisigApprovalModal
isOpen={true}
onClose={mockOnClose}
networkPassphrase="Test Network"
transaction={mockTransaction}
/>
</MultisigProvider>
);

expect(screen.getByText("Multi-Signature Approval")).toBeInTheDocument();

rerender(
<MultisigProvider networkPassphrase="Test Network">
<MultisigApprovalModal
isOpen={false}
onClose={mockOnClose}
networkPassphrase="Test Network"
transaction={mockTransaction}
/>
</MultisigProvider>
);

expect(screen.queryByText("Multi-Signature Approval")).not.toBeInTheDocument();

rerender(
<MultisigProvider networkPassphrase="Test Network">
<MultisigApprovalModal
isOpen={true}
onClose={mockOnClose}
networkPassphrase="Test Network"
transaction={mockTransaction}
/>
</MultisigProvider>
);

expect(screen.getByText("Multi-Signature Approval")).toBeInTheDocument();
});
});
});
48 changes: 46 additions & 2 deletions frontend/src/components/MultisigApprovalModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(
'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
Expand Down Expand Up @@ -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"
>
Expand Down Expand Up @@ -453,6 +482,21 @@ export default function MultisigApprovalModal({
</motion.button>
</div>

{/* Screen reader announcements */}
<div
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{currentStep === "processing"
? "Processing your transaction. Please wait."
: currentStep === "confirm"
? "Transaction approved successfully."
: currentStep === "error"
? "Transaction failed. See error details below."
: ""}
</div>

{/* Content */}
<motion.div
className="p-6 max-h-[70vh] overflow-y-auto"
Expand Down
Loading