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 changes: 2 additions & 6 deletions src/app/courses/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { CourseCardSkeleton } from "@/components/shared/loading-skeleton";
import { CourseGridSkeleton } from "@/components/shared/loading-skeleton";

export default function Loading() {
return (
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CourseCardSkeleton key={i} />
))}
</div>
<CourseGridSkeleton count={6} />
</div>
);
}
10 changes: 10 additions & 0 deletions src/app/settings/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ProfileSkeleton, FormSkeleton } from "@/components/shared/loading-skeleton";

export default function Loading() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 space-y-8">
<ProfileSkeleton />
<FormSkeleton fields={5} />
</div>
);
}
151 changes: 136 additions & 15 deletions src/components/shared/loading-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={cn("space-y-3", className)} aria-live="polite" aria-busy="true">
{Array.from({ length: count }).map((_, i) => {
if (variant === "text") {
return <SkeletonText key={i} />;
}
if (variant === "circle") {
return <SkeletonCircle key={i} />;
}
return <SkeletonCard key={i} />;
})}
<div
className={cn("space-y-3", className)}
role="status"
aria-live="polite"
aria-busy="true"
>
<span className="sr-only">Loading…</span>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className={shape} />
))}
</div>
);
}
Expand All @@ -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 (
<div
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
role="status"
aria-busy="true"
>
<span className="sr-only">Loading courses…</span>
{Array.from({ length: count }).map((_, i) => (
<CourseCardSkeleton key={i} />
))}
</div>
);
}

/**
* 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 (
<div
className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800"
role="status"
aria-busy="true"
>
<span className="sr-only">Loading table…</span>
<div className="flex gap-4 border-b border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-900">
{Array.from({ length: columns }).map((_, i) => (
<SkeletonText
key={i}
className={cn("h-4", i === 0 ? "w-1/3" : "flex-1")}
/>
))}
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex items-center gap-4 px-4 py-4">
{Array.from({ length: columns }).map((_, c) => (
<SkeletonText
key={c}
className={cn("h-4", c === 0 ? "w-1/3" : "flex-1")}
/>
))}
</div>
))}
</div>
</div>
);
}

/**
* Form placeholder: `fields` label + input pairs followed by a submit button.
*/
export function FormSkeleton({ fields = 4 }: { fields?: number }) {
return (
<div className="space-y-6" role="status" aria-busy="true">
<span className="sr-only">Loading form…</span>
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-2">
<SkeletonText className="h-4 w-32" />
<SkeletonRect className="h-10 w-full rounded-lg" />
</div>
))}
<SkeletonRect className="h-10 w-32 rounded-lg" />
</div>
);
}

/**
* Profile / account placeholder: avatar, name, meta line, and a details block.
*/
export function ProfileSkeleton() {
return (
<div className="space-y-6" role="status" aria-busy="true">
<span className="sr-only">Loading profile…</span>
<div className="flex items-center gap-4">
<SkeletonCircle className="h-20 w-20" />
<div className="flex-1 space-y-2">
<SkeletonText className="h-6 w-1/3" />
<SkeletonText className="h-4 w-1/4" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-5 w-2/3" />
</div>
))}
</div>
</div>
);
}

export function DashboardSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-6" role="status" aria-busy="true">
<span className="sr-only">Loading dashboard…</span>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<SkeletonRect key={i} className="h-24 w-full rounded-xl" />
Expand All @@ -71,7 +189,7 @@ export function DashboardSkeleton() {

export function CredentialCardSkeleton() {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4 flex gap-4">
<div className="rounded-xl border border-gray-200 bg-white p-4 flex gap-4 dark:border-gray-800 dark:bg-gray-950">
<SkeletonRect className="h-12 w-12 flex-shrink-0 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonText className="h-5 w-1/2" />
Expand All @@ -83,7 +201,8 @@ export function CredentialCardSkeleton() {

export function RewardsSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-6" role="status" aria-busy="true">
<span className="sr-only">Loading rewards…</span>
<div className="flex items-center gap-4">
<SkeletonCircle className="h-10 w-10" />
<div className="flex-1 space-y-2">
Expand All @@ -98,7 +217,8 @@ export function RewardsSkeleton() {

export function VerifySkeleton() {
return (
<div className="mx-auto max-w-lg space-y-4 text-center">
<div className="mx-auto max-w-lg space-y-4 text-center" role="status" aria-busy="true">
<span className="sr-only">Verifying…</span>
<SkeletonCircle className="mx-auto h-16 w-16" />
<SkeletonText className="mx-auto h-6 w-2/3" />
<SkeletonStack lines={3} className="mx-auto max-w-sm" />
Expand All @@ -108,7 +228,8 @@ export function VerifySkeleton() {

export function CourseDetailSkeleton() {
return (
<div className="space-y-6">
<div className="space-y-6" role="status" aria-busy="true">
<span className="sr-only">Loading course…</span>
<SkeletonRect className="h-48 w-full rounded-xl" />
<SkeletonText className="h-8 w-2/3" />
<SkeletonStack lines={4} />
Expand Down
14 changes: 11 additions & 3 deletions src/components/ui/skeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Skeleton className="h-4 w-20" />);
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(<Skeleton animation="pulse" />);
expect(container.firstChild).toHaveClass("animate-pulse");
});

it("supports text, circle, and rectangle variants", () => {
Expand All @@ -33,6 +39,8 @@ describe("Skeleton", () => {
<SkeletonStack lines={2} />
</div>
);
expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(4);
expect(
container.querySelectorAll('[data-slot="skeleton"]').length
).toBeGreaterThanOrEqual(4);
});
});
50 changes: 43 additions & 7 deletions src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
/** 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<SkeletonAnimation, string> = {
// 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
* <Skeleton className="h-4 w-3/4" />
* <Skeleton variant="circle" className="h-10 w-10" />
* <Skeleton variant="text" className="w-full" />
* <Skeleton animation="pulse" className="h-24 w-full" />
*/
function Skeleton({
className,
variant = "rectangle",
animation = "shimmer",
...props
}: SkeletonProps) {
return (
<div
aria-hidden="true"
data-slot="skeleton"
className={cn(
"animate-pulse bg-gray-200",
ANIMATION_CLASSES[animation],
variant === "text" && "h-4 w-full rounded",
variant === "circle" && "rounded-full",
variant === "rectangle" && "rounded-md",
Expand All @@ -40,7 +60,7 @@ Skeleton.displayName = "Skeleton";
function SkeletonText({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: Omit<SkeletonProps, "variant">) {
return <Skeleton variant="text" className={className} {...props} />;
}

Expand All @@ -50,7 +70,7 @@ SkeletonText.displayName = "SkeletonText";
function SkeletonCircle({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: Omit<SkeletonProps, "variant">) {
return (
<Skeleton
variant="circle"
Expand All @@ -68,15 +88,18 @@ SkeletonCircle.displayName = "SkeletonCircle";
function SkeletonStack({
lines = 3,
className,
animation,
}: {
lines?: number;
className?: string;
animation?: SkeletonAnimation;
}) {
return (
<div className={cn("space-y-2", className)}>
{Array.from({ length: lines }).map((_, i) => (
<SkeletonText
key={i}
animation={animation}
className={i === lines - 1 ? "w-2/3" : "w-full"}
/>
))}
Expand All @@ -90,7 +113,7 @@ SkeletonStack.displayName = "SkeletonStack";
function SkeletonRect({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
}: Omit<SkeletonProps, "variant">) {
return <Skeleton variant="rectangle" className={className} {...props} />;
}

Expand All @@ -103,7 +126,13 @@ function SkeletonCard({
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("rounded-xl border border-gray-200 bg-white overflow-hidden", className)} {...props}>
<div
className={cn(
"rounded-xl border border-gray-200 bg-white overflow-hidden dark:border-gray-800 dark:bg-gray-950",
className
)}
{...props}
>
{children || (
<>
<SkeletonRect className="h-40 w-full rounded-none" />
Expand All @@ -119,4 +148,11 @@ function SkeletonCard({

SkeletonCard.displayName = "SkeletonCard";

export { Skeleton, SkeletonText, SkeletonCircle, SkeletonStack, SkeletonRect, SkeletonCard };
export {
Skeleton,
SkeletonText,
SkeletonCircle,
SkeletonStack,
SkeletonRect,
SkeletonCard,
};
Loading