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,763 changes: 2,045 additions & 6,718 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions src/components/NetworkSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,19 @@ export async function checkRpcHealth(
}
} catch (err) {
if (timedOut) {
throw new Error(
const error = new Error(
`RPC endpoint did not respond within ${Math.round(timeoutMs / 1000)}s.`,
);
(error as Error & { cause?: unknown }).cause = err;
throw error;
}
if (isAbortError(err)) throw err;
if (err instanceof TypeError) {
throw new Error(
const error = new Error(
"Could not reach the RPC endpoint. It may be offline or may not allow requests from this origin (CORS).",
);
(error as Error & { cause?: unknown }).cause = err;
throw error;
}
throw err instanceof Error ? err : new Error("RPC health check failed.");
} finally {
Expand Down
8 changes: 3 additions & 5 deletions src/components/TransactionPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe("TransactionPanel", () => {

// Type valid address
fireEvent.change(destInput, { target: { value: VALID_DEST } });
expect(screen.getByText("Stellar address must be 56 characters")).toHaveClass("opacity-0");
expect(screen.queryByText("Stellar address must be 56 characters")).not.toBeInTheDocument();
expect(submitBtn).not.toBeDisabled();
});

Expand Down Expand Up @@ -369,9 +369,7 @@ describe("TransactionPanel", () => {

// Type valid amount
fireEvent.change(amountInput, { target: { value: "0.0000001" } });
expect(screen.getByText("Minimum amount is 0.0000001 XLM")).toHaveClass(
"opacity-0",
);
expect(screen.queryByText("Minimum amount is 0.0000001 XLM")).not.toBeInTheDocument();
expect(submitBtn).not.toBeDisabled();
});

Expand All @@ -398,7 +396,7 @@ describe("TransactionPanel", () => {

// Type amount within balance
fireEvent.change(amountInput, { target: { value: "5" } });
expect(screen.getByText("Insufficient balance")).toHaveClass("opacity-0");
expect(screen.queryByText("Insufficient balance")).not.toBeInTheDocument();
expect(submitBtn).not.toBeDisabled();
});

Expand Down
97 changes: 97 additions & 0 deletions src/components/WalletConnectButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,5 +395,102 @@ describe("WalletConnectButton", () => {
"Connection rejected by user",
);
});

it("stops click event bubbling to parent containers when clicked in disconnected state (#887)", () => {
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokit({
isConnected: false,
}),
);

const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<WalletConnectButton />
</div>,
);

fireEvent.click(screen.getByRole("button", { name: "Connect Wallet" }));
expect(parentClick).not.toHaveBeenCalled();
});

it("stops click event bubbling to parent containers when clicked in connected state (#887)", () => {
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokit({
isConnected: true,
address: "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ",
}),
);

const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<WalletConnectButton />
</div>,
);

fireEvent.click(
screen.getByRole("button", { name: /wallet connected/i }),
);
expect(parentClick).not.toHaveBeenCalled();
});

it("stops click event bubbling when onOpenModal is provided (#887)", () => {
const mockOnOpen = vi.fn();
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokit({
isConnected: true,
address: "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ",
}),
);

const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<WalletConnectButton onOpenModal={mockOnOpen} />
</div>,
);

fireEvent.click(
screen.getByRole("button", { name: /wallet connected/i }),
);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
expect(parentClick).not.toHaveBeenCalled();
});

it("has accessible attributes aria-haspopup='dialog' and aria-expanded when disconnected (#887)", () => {
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokit({
isConnected: false,
}),
);

render(<WalletConnectButton />);
const btn = screen.getByRole("button", { name: "Connect Wallet" });
expect(btn).toHaveAttribute("aria-haspopup", "dialog");
expect(btn).toHaveAttribute("aria-expanded", "false");

fireEvent.click(btn);
expect(btn).toHaveAttribute("aria-expanded", "true");
});

it("renders compact address on mobile (<360px) and standard address on larger viewports (#887)", () => {
const fullAddr = "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ";
vi.mocked(useSorokit).mockReturnValue(
mockUseSorokit({
isConnected: true,
address: fullAddr,
}),
);

const { container } = render(<WalletConnectButton />);
const compactSpan = container.querySelector("[data-address-compact]");
const standardSpan = container.querySelector("[data-address]");

expect(compactSpan).toBeInTheDocument();
expect(compactSpan).toHaveTextContent("GAB...XYZ");
expect(standardSpan).toBeInTheDocument();
expect(standardSpan).toHaveTextContent("GABC12...WXYZ");
});
});
});
44 changes: 35 additions & 9 deletions src/components/WalletConnectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { Button } from "@/components/ui/Button";
import { useSorokit } from "@/context/useSorokit";
import { useOutsideClick } from "@/hooks/useOutsideClick";
import { truncateAddress } from "@/lib/utils";
import { truncateAddress, truncateAddressResponsive } from "@/lib/utils";

import { WalletConnectModal } from "./WalletConnectModal";

Expand Down Expand Up @@ -88,18 +88,27 @@ export function WalletConnectButton({ onOpenModal }: WalletConnectButtonProps =
return (
<button
type="button"
onClick={onOpenModal}
className="inline-flex items-center gap-1.5 sm:gap-2 h-8 px-2 sm:px-3.5 rounded-lg bg-surface-2 border border-line hover:border-line-2 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
onClick={(e) => {
e.stopPropagation();
onOpenModal();
}}
className="inline-flex items-center gap-1.5 sm:gap-2 h-8 px-2 sm:px-3.5 rounded-lg bg-surface-2 border border-line hover:border-line-2 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand max-w-full"
aria-label={`Wallet connected: ${address}. Click to manage.`}
>
<span className="w-2 h-2 rounded-full bg-green shrink-0" />
<span
data-address-compact
className="inline sm:hidden font-mono text-[11px] truncate"
>
{truncateAddressResponsive(address, { compact: true })}
</span>
<span data-address className="hidden sm:inline">
{truncateAddress(address)}
</span>
{isHardwareWallet && (
<span
data-testid="hardware-badge"
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 border border-purple-500/30"
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 border border-purple-500/30 shrink-0"
>
{walletName && walletName.toLowerCase().includes("ledger")
? "Ledger"
Expand All @@ -115,20 +124,29 @@ export function WalletConnectButton({ onOpenModal }: WalletConnectButtonProps =
<DropdownMenu.Trigger asChild>
<button
type="button"
onClick={() => setDropdownOpen((prev) => !prev)}
className="inline-flex items-center gap-1.5 sm:gap-2 h-8 px-2 sm:px-3.5 rounded-lg bg-surface-2 border border-line hover:border-line-2 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen((prev) => !prev);
}}
className="inline-flex items-center gap-1.5 sm:gap-2 h-8 px-2 sm:px-3.5 rounded-lg bg-surface-2 border border-line hover:border-line-2 transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand max-w-full"
aria-label={`Wallet connected: ${address}. Click to manage.`}
aria-haspopup="menu"
aria-expanded={dropdownOpen}
>
<span className="w-2 h-2 rounded-full bg-green shrink-0" />
<span
data-address-compact
className="inline sm:hidden font-mono text-[11px] truncate"
>
{truncateAddressResponsive(address, { compact: true })}
</span>
<span data-address className="hidden sm:inline">
{truncateAddress(address)}
</span>
{isHardwareWallet && (
<span
data-testid="hardware-badge"
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 border border-purple-500/30"
className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-400 border border-purple-500/30 shrink-0"
>
{walletName && walletName.toLowerCase().includes("ledger")
? "Ledger"
Expand Down Expand Up @@ -197,9 +215,14 @@ export function WalletConnectButton({ onOpenModal }: WalletConnectButtonProps =
<Button
size="md"
loading={isConnecting}
onClick={() => setConnectModalOpen(true)}
onClick={(e) => {
e.stopPropagation();
setConnectModalOpen(true);
}}
className="px-2.5 sm:px-4"
aria-label={isConnecting ? "Connecting…" : "Connect Wallet"}
aria-haspopup="dialog"
aria-expanded={connectModalOpen}
>
<span className="hidden sm:inline">
{isConnecting ? "Connecting…" : "Connect Wallet"}
Expand All @@ -214,7 +237,10 @@ export function WalletConnectButton({ onOpenModal }: WalletConnectButtonProps =
>
<span>{error}</span>
<button
onClick={clearError}
onClick={(e) => {
e.stopPropagation();
clearError();
}}
className="text-red opacity-50 hover:opacity-100 transition-opacity cursor-pointer flex items-center justify-center shrink-0"
aria-label="Clear error"
>
Expand Down
17 changes: 2 additions & 15 deletions src/components/ui/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
.join(" ") || undefined;
const ariaInvalid = error ? true : ariaInvalidProp ?? false;

const [lastError, setLastError] = useState<string | undefined>(error);
const [lastHint, setLastHint] = useState<string | undefined>(hint);
const [showPassword, setShowPassword] = useState(false);

const isPassword = props.type === "password";
Expand Down Expand Up @@ -127,17 +125,6 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
}
}, [onChange, props.readOnly, props.value]);

useEffect(() => {
if (error) {
setLastError(error);
}
}, [error]);

useEffect(() => {
if (hint) {
setLastHint(hint);
}
}, [hint]);

return (
<div className="flex flex-col gap-1.5">
Expand Down Expand Up @@ -256,7 +243,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
error ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
{error || lastError || ""}
{error || ""}
</p>
<p
id={`${inputId}-hint`}
Expand All @@ -266,7 +253,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
!error && hint ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
{!error && hint ? hint : lastHint || ""}
{hint || ""}
</p>
</div>
</div>
Expand Down
7 changes: 4 additions & 3 deletions src/hooks/useOutsideClick.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ describe("useOutsideClick", () => {
rerender({ enabled: false });

// Verify cleanup was called
expect(removeSpy).toHaveBeenCalledWith("pointerdown", expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith("touchstart", expect.any(Function));
expect(removeSpy).toHaveBeenCalledWith("mousedown", expect.any(Function));
const expectedOptions = { capture: true, passive: true };
expect(removeSpy).toHaveBeenCalledWith("pointerdown", expect.any(Function), expectedOptions);
expect(removeSpy).toHaveBeenCalledWith("touchstart", expect.any(Function), expectedOptions);
expect(removeSpy).toHaveBeenCalledWith("mousedown", expect.any(Function), expectedOptions);

// Verify handler is not called after disabled
handler.mockClear();
Expand Down
28 changes: 28 additions & 0 deletions src/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isMuxedAccount,
toXLM,
truncateAddress,
truncateAddressResponsive,
truncateToUtf8ByteLength,
utf8ByteLength,
validateStellarAddress,
Expand Down Expand Up @@ -54,6 +55,33 @@ describe("truncateAddress", () => {
});
});

describe("truncateAddressResponsive (#887)", () => {
const STELLAR_ADDRESS_56 =
"GABCDEF123456789012345678901234567890123456789012345WXYZ";

it("truncates with compact bounds (3, 3) for narrow mobile viewports", () => {
const compactResult = truncateAddressResponsive(STELLAR_ADDRESS_56, { compact: true });
expect(compactResult).toBe("GAB...XYZ");
expect(compactResult.length).toBe(9);
});

it("defaults to standard bounds (6, 4) when compact is omitted or false", () => {
const standardResult = truncateAddressResponsive(STELLAR_ADDRESS_56);
expect(standardResult).toBe("GABCDE...WXYZ");
});

it("respects custom start and end bounds in responsive mode", () => {
const customResult = truncateAddressResponsive(STELLAR_ADDRESS_56, { compact: true, start: 4, end: 4 });
expect(customResult).toBe("GABC...WXYZ");
});

it("handles null, undefined, and empty string safely", () => {
expect(truncateAddressResponsive(null)).toBe("");
expect(truncateAddressResponsive(undefined)).toBe("");
expect(truncateAddressResponsive("")).toBe("");
});
});

describe("validateStellarAddress (#535)", () => {
// A well-known, publicly documented Stellar Development Foundation
// address — a real StrKey-encoded ED25519 public key, not a synthetic
Expand Down
17 changes: 17 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ export function truncateAddress(address: string | null | undefined, start = 6, e
return `${chars.slice(0, start).join("")}...${chars.slice(-end).join("")}`;
}

/**
* Truncate an address adaptively for compact or mobile viewports.
* @param address Stellar address, contract ID, or tx hash
* @param options Configuration for compact / responsive truncation.
* `compact: true` uses (3, 3) e.g. "GAB...WXYZ" for narrow/mobile containers (<360px).
*/
export function truncateAddressResponsive(
address: string | null | undefined,
options?: { compact?: boolean; start?: number; end?: number },
): string {
if (!address) return "";
if (options?.compact) {
return truncateAddress(address, options.start ?? 3, options.end ?? 3);
}
return truncateAddress(address, options?.start ?? 6, options?.end ?? 4);
}

export function safeFormat(balance: string): string {
const n = parseFloat(balance);
if (!balance || isNaN(n)) return "0.00";
Expand Down
Loading