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
6 changes: 3 additions & 3 deletions src/components/BalanceList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useSorokit } from "@/context/useSorokit";
import { Badge } from "@/components/ui/Badge";
import { AssetBadge } from "@/components/AssetBadge";
import { SkeletonRow } from "@/components/ui/Skeleton";
import { AssetRowSkeleton } from "@/components/ui/Skeleton";
import type { Balance } from "@/lib/client";

function AssetRow({ b }: { b: Balance }) {
Expand Down Expand Up @@ -38,9 +38,9 @@ export function BalanceList() {
Connect your wallet to view assets
</p>
) : isLoadingAccount ? (
<div className="px-5 py-5 flex flex-col gap-4">
<div>
{[1, 2, 3].map((i) => (
<SkeletonRow key={i} />
<AssetRowSkeleton key={i} />
))}
</div>
) : balances.length === 0 ? (
Expand Down
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
} from "./ui/Card";
export { Badge } from "./ui/Badge";
export { Input } from "./ui/Input";
export { Skeleton, SkeletonRow, SkeletonCard, AssetRowSkeleton } from "./ui/Skeleton";
export {
Skeleton,
SkeletonRow,
Expand Down
45 changes: 45 additions & 0 deletions src/components/ui/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,50 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Skeleton, SkeletonRow, SkeletonCard, AssetRowSkeleton } from "./Skeleton";

describe("Skeleton components accessibility and structure", () => {
it("Skeleton has role='presentation'", () => {
const { container } = render(<Skeleton />);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute("role", "presentation");
});

it("SkeletonRow has role='presentation'", () => {
const { container } = render(<SkeletonRow />);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute("role", "presentation");
});

it("AssetRowSkeleton has role='presentation'", () => {
const { container } = render(<AssetRowSkeleton />);
const el = container.firstChild as HTMLElement;
expect(el).toHaveAttribute("role", "presentation");
});

it("SkeletonCard has role='status', aria-busy='true', and aria-label='Loading content'", () => {
render(<SkeletonCard />);
const el = screen.getByRole("status");
expect(el).toHaveAttribute("aria-busy", "true");
expect(el).toHaveAttribute("aria-label", "Loading content");
});

it("SkeletonCard accepts custom children override", () => {
render(
<SkeletonCard>
<div data-testid="custom-child">Custom Content</div>
</SkeletonCard>
);
expect(screen.getByTestId("custom-child")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "Loading content");
// Default header should not render
expect(screen.queryByText("Stellar account details")).not.toBeInTheDocument();
});

it("SkeletonCard accepts custom structure override", () => {
render(
<SkeletonCard structure={<div data-testid="custom-structure">Custom Structure</div>} />
);
expect(screen.getByTestId("custom-structure")).toBeInTheDocument();
import {
Skeleton,
SkeletonRow,
Expand Down
62 changes: 60 additions & 2 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@ export function Skeleton({ circle, className, ...props }: SkeletonProps) {
}

/** Pre-composed row skeleton: icon + two lines of text */
export function SkeletonRow({ className }: { className?: string }) {
export function SkeletonRow({ className, ...props }: { className?: string; [key: string]: any }) {
return (
<div
role="presentation"
className={cn("flex items-center gap-3", className)}
{...props}
>
<div role="presentation" className={cn("flex items-center gap-3", className)}>
<Skeleton circle className="w-9 h-9" />
<div className="flex-1 flex flex-col gap-2">
Expand All @@ -32,6 +37,10 @@ export function SkeletonRow({ className }: { className?: string }) {
);
}

interface SkeletonCardProps extends React.HTMLAttributes<HTMLDivElement> {
rows?: number;
structure?: React.ReactNode;
children?: React.ReactNode;
/**
* Pre-composed asset-row skeleton mirroring AssetRow's layout: an icon + two
* text lines on the left, and a right-aligned balance/amount placeholder.
Expand All @@ -58,9 +67,57 @@ export function AssetRowSkeleton({ className }: { className?: string }) {
}

/** Pre-composed card skeleton: header + body lines */
export function SkeletonCard({ rows = 3 }: { rows?: number }) {
export function SkeletonCard({
rows = 3,
structure,
children,
className,
...props
}: SkeletonCardProps) {
return (
<div
role="status"
aria-busy="true"
aria-label="Loading content"
className={cn("rounded-xl border border-line bg-surface overflow-hidden", className)}
{...props}
>
{structure || children ? (
structure || children
) : (
<>
<div className="px-5 py-4 border-b border-line flex flex-col gap-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<div className="px-5 py-5 flex flex-col gap-4">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
</>
)}
</div>
);
}

/** Pre-composed row skeleton matching AssetRow layout exactly */
export function AssetRowSkeleton({ className, ...props }: { className?: string; [key: string]: any }) {
return (
<div
role="presentation"
className={cn(
"flex items-center justify-between px-5 py-4 border-b border-line last:border-0",
className,
)}
{...props}
>
<div className="flex items-center gap-2.5">
<Skeleton circle className="w-8 h-8" />
<div className="flex flex-col gap-1 min-w-0">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-2.5 w-24" />
</div>
aria-busy="true"
className="rounded-xl border border-line bg-surface overflow-hidden"
>
Expand All @@ -73,6 +130,7 @@ export function SkeletonCard({ rows = 3 }: { rows?: number }) {
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
<Skeleton className="h-3.5 w-16" />
</div>
);
}
Loading