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
33 changes: 33 additions & 0 deletions src/components/ui/Skeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ describe("Skeleton", () => {
expect(container.firstElementChild).toHaveClass("rounded-full");
});

it("applies rounded-none when shape='square'", () => {
const { container } = render(<Skeleton shape="square" />);
expect(container.firstElementChild).toHaveClass("rounded-none");
});

it("applies rounded-full when shape='circle'", () => {
const { container } = render(<Skeleton shape="circle" />);
expect(container.firstElementChild).toHaveClass("rounded-full");
});

it("applies rounded-lg when shape='rounded' or default", () => {
const { container: c1 } = render(<Skeleton shape="rounded" />);
expect(c1.firstElementChild).toHaveClass("rounded-lg");

const { container: c2 } = render(<Skeleton />);
expect(c2.firstElementChild).toHaveClass("rounded-lg");
it("uses animate-pulse by default (no variant prop)", () => {
const { container } = render(<Skeleton />);
expect(container.firstElementChild).toHaveClass("animate-pulse");
Expand All @@ -44,6 +60,14 @@ describe("SkeletonRow", () => {
const { container } = render(<SkeletonRow />);
expect(container.firstElementChild).toHaveAttribute("role", "presentation");
});

it("renders multiple rows when count prop is provided", () => {
const { container } = render(<SkeletonRow count={5} />);
const rows = container.querySelectorAll('[role="presentation"]');
// Each SkeletonRow contains 1 wrapper div + 3 internal Skeletons = 4 presentational divs per row
// Or querying top-level children / row divs:
expect(container.children.length).toBe(5);
});
});

describe("SkeletonCard", () => {
Expand All @@ -59,6 +83,15 @@ describe("SkeletonCard", () => {
expect(placeholders.length).toBe(2 + 5);
});

it("renders custom header slot when header prop is provided", () => {
const customHeader = (
<div data-testid="custom-card-header" className="px-5 py-4 border-b border-line flex items-center justify-between">
<Skeleton shape="circle" className="w-8 h-8" />
<Skeleton shape="rounded" className="h-4 w-24" />
</div>
);
render(<SkeletonCard header={customHeader} />);
expect(screen.getByTestId("custom-card-header")).toBeInTheDocument();
it("uses stable keys that encode row count — changing rows remounts items", () => {
const { rerender, container } = render(<SkeletonCard rows={3} />);
const before = Array.from(
Expand Down
77 changes: 75 additions & 2 deletions src/components/ui/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,23 @@ import React from "react";

import { cn } from "@/lib/utils";

interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
/** Render as a circle (for avatars/icons) */
export type SkeletonShape = "rounded" | "circle" | "square";

export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
/** Render as a circle (for avatars/icons) - shorthand for shape="circle" */
circle?: boolean;
/** Shape variant: rounded (default), circle, or square */
shape?: SkeletonShape;
}

export function Skeleton({ circle, shape, className, ...props }: SkeletonProps) {
const roundedClass =
shape === "circle" || circle
? "rounded-full"
: shape === "square"
? "rounded-none"
: "rounded-lg";

/** Shape variant for non-circular placeholders. */
shape?: "rounded" | "circle" | "square";
/**
Expand All @@ -21,6 +35,8 @@ export function Skeleton({ circle, shape = "rounded", variant = "pulse", classNa
<div
role="presentation"
className={cn(
"bg-surface-2 animate-pulse shrink-0",
roundedClass,
"bg-surface-2 shrink-0",
resolvedShape === "circle"
? "rounded-full"
Expand All @@ -35,11 +51,42 @@ export function Skeleton({ circle, shape = "rounded", variant = "pulse", classNa
);
}

export interface SkeletonRowProps extends React.HTMLAttributes<HTMLDivElement> {
/** Number of skeleton rows to render */
interface SkeletonRowProps extends React.HTMLAttributes<HTMLDivElement> {
count?: number;
}

/** Pre-composed row skeleton: icon + two lines of text */
export function SkeletonRow({ count, className, ...props }: SkeletonRowProps) {
if (count !== undefined && count > 1) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
role="presentation"
className={cn("flex items-center gap-3", className)}
{...props}
>
<Skeleton circle className="w-9 h-9" />
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</>
);
}

return (
<div role="presentation" className={cn("flex items-center gap-3", className)} {...props}>
<Skeleton circle className="w-9 h-9" />
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
export function SkeletonRow({ className, count = 1, ...props }: SkeletonRowProps) {
return (
<div
Expand Down Expand Up @@ -93,6 +140,13 @@ export function AssetRowSkeleton({ className }: { className?: string }) {
);
}

export interface SkeletonCardProps extends React.HTMLAttributes<HTMLDivElement> {
/** Number of body rows to render */
rows?: number;
/** Custom header slot; defaults to standard 2-line header skeleton */
header?: React.ReactNode;
}

/** Pre-composed card skeleton: header + body lines */
export function SkeletonCard({
rows = 3,
Expand All @@ -106,6 +160,25 @@ export function SkeletonCard({
<div
role="status"
aria-busy="true"
className={cn(
"rounded-xl border border-line bg-surface overflow-hidden",
className,
)}
{...props}
>
{header !== undefined ? (
header
) : (
<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>
aria-label="Loading content"
className={cn("rounded-xl border border-line bg-surface overflow-hidden", className)}
{...props}
Expand Down
Loading