diff --git a/src/app/courses/loading.tsx b/src/app/courses/loading.tsx index f74f1f9..59c5634 100644 --- a/src/app/courses/loading.tsx +++ b/src/app/courses/loading.tsx @@ -1,13 +1,9 @@ -import { CourseCardSkeleton } from "@/components/shared/loading-skeleton"; +import { CourseGridSkeleton } from "@/components/shared/loading-skeleton"; export default function Loading() { return (
-
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
+
); } diff --git a/src/app/settings/loading.tsx b/src/app/settings/loading.tsx new file mode 100644 index 0000000..0a68984 --- /dev/null +++ b/src/app/settings/loading.tsx @@ -0,0 +1,10 @@ +import { ProfileSkeleton, FormSkeleton } from "@/components/shared/loading-skeleton"; + +export default function Loading() { + return ( +
+ + +
+ ); +} diff --git a/src/components/shared/loading-skeleton.tsx b/src/components/shared/loading-skeleton.tsx index b8a97b2..3362301 100644 --- a/src/components/shared/loading-skeleton.tsx +++ b/src/components/shared/loading-skeleton.tsx @@ -16,22 +16,34 @@ interface LoadingSkeletonProps { variant?: "card" | "text" | "circle"; } +/** + * Generic placeholder primitive. For page-shaped loading states prefer the + * dedicated variants below (CourseGridSkeleton, TableSkeleton, FormSkeleton, + * ProfileSkeleton, …) which mirror the real content structure. + */ export function LoadingSkeleton({ className, count = 1, variant = "card", }: LoadingSkeletonProps) { + const shape = + variant === "text" + ? "h-4 w-full rounded" + : variant === "circle" + ? "h-12 w-12 rounded-full" + : "h-48 w-full rounded-xl"; + return ( -
- {Array.from({ length: count }).map((_, i) => { - if (variant === "text") { - return ; - } - if (variant === "circle") { - return ; - } - return ; - })} +
+ Loading… + {Array.from({ length: count }).map((_, i) => ( + + ))}
); } @@ -52,9 +64,115 @@ export function CourseCardSkeleton() { ); } +/** + * Responsive grid of course-card placeholders, matching the catalog and + * dashboard grids (1 / 2 / 3 columns). + */ +export function CourseGridSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ Loading courses… + {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +/** + * Table placeholder with a header row and `rows` body rows. `columns` controls + * the cell count; the first column is rendered wider to stand in for a label. + */ +export function TableSkeleton({ + rows = 5, + columns = 4, +}: { + rows?: number; + columns?: number; +}) { + return ( +
+ Loading table… +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: rows }).map((_, r) => ( +
+ {Array.from({ length: columns }).map((_, c) => ( + + ))} +
+ ))} +
+
+ ); +} + +/** + * Form placeholder: `fields` label + input pairs followed by a submit button. + */ +export function FormSkeleton({ fields = 4 }: { fields?: number }) { + return ( +
+ Loading form… + {Array.from({ length: fields }).map((_, i) => ( +
+ + +
+ ))} + +
+ ); +} + +/** + * Profile / account placeholder: avatar, name, meta line, and a details block. + */ +export function ProfileSkeleton() { + return ( +
+ Loading profile… +
+ +
+ + +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ ); +} + export function DashboardSkeleton() { return ( -
+
+ Loading dashboard…
{Array.from({ length: 3 }).map((_, i) => ( @@ -71,7 +189,7 @@ export function DashboardSkeleton() { export function CredentialCardSkeleton() { return ( -
+
@@ -83,7 +201,8 @@ export function CredentialCardSkeleton() { export function RewardsSkeleton() { return ( -
+
+ Loading rewards…
@@ -98,7 +217,8 @@ export function RewardsSkeleton() { export function VerifySkeleton() { return ( -
+
+ Verifying… @@ -108,7 +228,8 @@ export function VerifySkeleton() { export function CourseDetailSkeleton() { return ( -
+
+ Loading course… diff --git a/src/components/ui/skeleton.test.tsx b/src/components/ui/skeleton.test.tsx index 1ad003b..7e6bec9 100644 --- a/src/components/ui/skeleton.test.tsx +++ b/src/components/ui/skeleton.test.tsx @@ -8,10 +8,16 @@ import { } from "@/components/ui/skeleton"; describe("Skeleton", () => { - it("renders with pulse animation", () => { + it("renders with a shimmer animation by default", () => { const { container } = render(); - expect(container.firstChild).toHaveClass("animate-pulse"); + expect(container.firstChild).toHaveClass("animate-shimmer"); expect(container.firstChild).toHaveAttribute("aria-hidden", "true"); + expect(container.firstChild).toHaveAttribute("data-slot", "skeleton"); + }); + + it("supports opting into the pulse animation", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("animate-pulse"); }); it("supports text, circle, and rectangle variants", () => { @@ -33,6 +39,8 @@ describe("Skeleton", () => {
); - expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(4); + expect( + container.querySelectorAll('[data-slot="skeleton"]').length + ).toBeGreaterThanOrEqual(4); }); }); diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index a65d61f..f9a5fa6 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -1,29 +1,49 @@ import * as React from "react"; import { cn } from "@/lib/utils/cn"; +export type SkeletonAnimation = "shimmer" | "pulse" | "none"; + export interface SkeletonProps extends React.HTMLAttributes { /** Visual shape preset. Defaults to a rounded rectangle. */ variant?: "text" | "circle" | "rectangle"; + /** + * Loading animation. `shimmer` (default) sweeps a highlight band across the + * placeholder; `pulse` fades opacity; `none` is static (useful in tests or + * when `prefers-reduced-motion` is handled upstream). + */ + animation?: SkeletonAnimation; } +const ANIMATION_CLASSES: Record = { + // 200%-wide gradient so the shimmer keyframe has room to travel. Falls back + // to a static fill when the viewer prefers reduced motion. + shimmer: + "animate-shimmer bg-[length:200%_100%] bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 motion-reduce:animate-none motion-reduce:bg-gray-200 dark:from-gray-800 dark:via-gray-700 dark:to-gray-800 dark:motion-reduce:bg-gray-800", + pulse: "animate-pulse bg-gray-200 motion-reduce:animate-none dark:bg-gray-800", + none: "bg-gray-200 dark:bg-gray-800", +}; + /** - * Base pulse skeleton. Pass className to match real content dimensions. + * Base skeleton block. Pass className to match real content dimensions. * * @example * * * + * */ function Skeleton({ className, variant = "rectangle", + animation = "shimmer", ...props }: SkeletonProps) { return (