From f7c4879694a26d6c46cd989f683ac0ca40e73a7d Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Thu, 30 Jul 2026 00:29:42 +0100 Subject: [PATCH] feat(Skeleton): add header slot to SkeletonCard, square shape to Skeleton, and count prop to SkeletonRow (#335) --- src/components/ui/Skeleton.test.tsx | 37 +++++++++++++ src/components/ui/Skeleton.tsx | 81 ++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/src/components/ui/Skeleton.test.tsx b/src/components/ui/Skeleton.test.tsx index df4460d..fa1666d 100644 --- a/src/components/ui/Skeleton.test.tsx +++ b/src/components/ui/Skeleton.test.tsx @@ -18,6 +18,24 @@ describe("Skeleton", () => { const { container } = render(); expect(container.firstElementChild).toHaveClass("rounded-full"); }); + + it("applies rounded-none when shape='square'", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("rounded-none"); + }); + + it("applies rounded-full when shape='circle'", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("rounded-full"); + }); + + it("applies rounded-lg when shape='rounded' or default", () => { + const { container: c1 } = render(); + expect(c1.firstElementChild).toHaveClass("rounded-lg"); + + const { container: c2 } = render(); + expect(c2.firstElementChild).toHaveClass("rounded-lg"); + }); }); describe("SkeletonRow", () => { @@ -25,6 +43,14 @@ describe("SkeletonRow", () => { const { container } = render(); expect(container.firstElementChild).toHaveAttribute("role", "presentation"); }); + + it("renders multiple rows when count prop is provided", () => { + const { container } = render(); + 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", () => { @@ -39,6 +65,17 @@ describe("SkeletonCard", () => { const placeholders = container.querySelectorAll('[role="presentation"]'); expect(placeholders.length).toBe(2 + 5); }); + + it("renders custom header slot when header prop is provided", () => { + const customHeader = ( +
+ + +
+ ); + render(); + expect(screen.getByTestId("custom-card-header")).toBeInTheDocument(); + }); }); describe("AssetRowSkeleton", () => { diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx index fef49e0..570dcae 100644 --- a/src/components/ui/Skeleton.tsx +++ b/src/components/ui/Skeleton.tsx @@ -1,17 +1,28 @@ import { cn } from "@/lib/utils"; -interface SkeletonProps extends React.HTMLAttributes { - /** Render as a circle (for avatars/icons) */ +export type SkeletonShape = "rounded" | "circle" | "square"; + +export interface SkeletonProps extends React.HTMLAttributes { + /** 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, className, ...props }: SkeletonProps) { +export function Skeleton({ circle, shape, className, ...props }: SkeletonProps) { + const roundedClass = + shape === "circle" || circle + ? "rounded-full" + : shape === "square" + ? "rounded-none" + : "rounded-lg"; + return (
{ + /** Number of skeleton rows to render */ + count?: number; +} + /** Pre-composed row skeleton: icon + two lines of text */ -export function SkeletonRow({ className }: { className?: string }) { +export function SkeletonRow({ count, className, ...props }: SkeletonRowProps) { + if (count !== undefined && count > 1) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
+ +
+ + +
+
+ ))} + + ); + } + return ( -
+
@@ -57,17 +94,37 @@ export function AssetRowSkeleton({ className }: { className?: string }) { ); } +export interface SkeletonCardProps extends React.HTMLAttributes { + /** 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 }: { rows?: number }) { +export function SkeletonCard({ + rows = 3, + header, + className, + ...props +}: SkeletonCardProps) { return (
-
- - -
+ {header !== undefined ? ( + header + ) : ( +
+ + +
+ )}
{Array.from({ length: rows }).map((_, i) => (