Skip to content

Commit

Permalink
feat: Add Skeleton loader (#348)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Jan 28, 2025
1 parent 6a2bc7b commit fb82f83
Show file tree
Hide file tree
Showing 8 changed files with 278 additions and 31 deletions.
5 changes: 5 additions & 0 deletions .changeset/mean-tools-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": patch
---

Improve loading state of quests with skeleton loader component
31 changes: 31 additions & 0 deletions src/components/common/Skeleton/Skeleton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta } from "@storybook/react";

import { Skeleton, SkeletonCircle, SkeletonText } from "./Skeleton";

const meta = {
component: Skeleton,
parameters: {
layout: "padded",
},
} satisfies Meta<typeof Skeleton>;

export default meta;

export const Default = (args: any) => <Skeleton {...args} />;

Default.args = {
className: "w-48 h-24",
};

export const Circle = (args: any) => <SkeletonCircle {...args} />;

Circle.args = {
className: "w-10 h-10",
};

export const Text = (args: any) => <SkeletonText {...args} />;

Text.args = {
lines: 3,
align: "left",
};
81 changes: 81 additions & 0 deletions src/components/common/Skeleton/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Skeleton, SkeletonCircle, SkeletonText } from "./Skeleton";

describe("Skeleton", () => {
it("should render base skeleton with correct attributes", () => {
render(<Skeleton />);
const skeleton = screen.getByTestId("skeleton");

expect(skeleton).toBeInTheDocument();
expect(skeleton).toHaveAttribute("aria-hidden", "true");
expect(skeleton.className).toContain("animate-fade-in");
});

it("should apply custom className to base skeleton", () => {
render(<Skeleton className="custom-class" />);
const skeleton = screen.getByTestId("skeleton");

expect(skeleton.className).toContain("custom-class");
});
});

describe("SkeletonCircle", () => {
it("should render circle skeleton with correct attributes", () => {
render(<SkeletonCircle />);
const skeleton = screen.getByTestId("skeleton-circle");

expect(skeleton).toBeInTheDocument();
expect(skeleton).toHaveAttribute("aria-hidden", "true");
expect(skeleton.className).toContain("rounded-full");
});

it("should apply custom className to circle skeleton", () => {
render(<SkeletonCircle className="custom-class" />);
const skeleton = screen.getByTestId("skeleton-circle");

expect(skeleton.className).toContain("custom-class");
});
});

describe("SkeletonText", () => {
it("should render text skeleton with default props", () => {
render(<SkeletonText />);
const container = screen.getByTestId("skeleton-text");
const skeletonLines = container.children;

expect(container).toBeInTheDocument();
expect(container).toHaveAttribute("aria-hidden", "true");
expect(skeletonLines).toHaveLength(2); // default lines prop
expect(container.className).toContain("items-start"); // default left alignment
});

it("should render specified number of lines", () => {
render(<SkeletonText lines={4} />);
const container = screen.getByTestId("skeleton-text");
const skeletonLines = container.children;

expect(skeletonLines).toHaveLength(4);
});

it("should apply correct alignment classes", () => {
render(<SkeletonText align="center" />);
const container = screen.getByTestId("skeleton-text");

expect(container.className).toContain("items-center");
});

it("should generate random widths for each line", () => {
render(<SkeletonText lines={3} />);
const container = screen.getByTestId("skeleton-text");
const skeletonLines = Array.from(container.children);

const widths = skeletonLines.map((line) => line.getAttribute("style"));
// Check that each line has a width between 70% and 90%
for (const width of widths) {
const percentage = Number.parseInt(width?.match(/\d+/)?.[0] ?? "0");
expect(percentage).toBeGreaterThanOrEqual(70);
expect(percentage).toBeLessThanOrEqual(90);
}
});
});
81 changes: 81 additions & 0 deletions src/components/common/Skeleton/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { tv } from "tailwind-variants";

const skeletonStyles = tv({
base: "w-full h-full relative animate-fade-in before:inset-0 before:absolute before:bg-graya-4 before:dark:bg-graydarka-4 before:rounded before:animate-pulse",
variants: {
type: {
circle: "rounded-full",
text: "h-4",
},
},
});

type SkeletonProps = {
className?: string;
} & React.HTMLAttributes<HTMLDivElement>;

export const Skeleton = ({ className }: SkeletonProps) => {
return (
<div
className={skeletonStyles({ className })}
aria-hidden
data-testid="skeleton"
/>
);
};

export const SkeletonCircle = ({ className }: SkeletonProps) => {
return (
<div
className={skeletonStyles({ className, type: "circle" })}
aria-hidden
data-testid="skeleton-circle"
/>
);
};

const skeletonParagraphStyles = tv({
base: "flex flex-col gap-2",
variants: {
align: {
left: "items-start",
center: "items-center",
right: "items-end",
},
},
});

type SkeletonTextProps = {
className?: string;
lines?: number;
align?: "left" | "center" | "right";
} & React.HTMLAttributes<HTMLDivElement>;

export const SkeletonText = ({
className,
lines = 2,
align = "left",
}: SkeletonTextProps) => {
return (
<div
className={skeletonParagraphStyles({ className, align })}
aria-hidden
data-testid="skeleton-text"
>
{Array.from({ length: lines }).map((_, index) => {
const minWidth = 70;
const maxWidth = 90;
const randomWidth = `${Math.floor(Math.random() * (maxWidth - minWidth + 1)) + minWidth}%`;

return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: Fine
key={index}
className={skeletonStyles({ type: "text", className })}
style={{ width: randomWidth }}
/>
);
})}
</div>
);
};
1 change: 1 addition & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export * from "./RichText/RichText";
export * from "./SearchField/SearchField";
export * from "./Select/Select";
export * from "./Separator/Separator";
export * from "./Skeleton/Skeleton";
export * from "./Slider/Slider";
export * from "./Switch/Switch";
export * from "./Table/Table";
Expand Down
56 changes: 34 additions & 22 deletions src/components/quests/QuestSteps/QuestSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,30 +199,42 @@ export const QuestSteps = ({ quest, editable = false }: QuestStepsProps) => {

const hasSteps = steps && steps.length > 0;

// Loading state
if (steps === undefined)
return (
<Steps>
{Array.from({ length: quest.steps?.length ?? 3 }).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: It's fine
<Step key={index} isLoading={true} />
))}
</Steps>
);

// Empty state
if (!hasSteps) {
return (
<Empty
title="No steps"
icon={Milestone}
button={
editable
? { children: "Add step", onPress: handleAddStep }
: undefined
}
/>
);
}

return (
<>
{!hasSteps ? (
<Empty
title="No steps"
icon={Milestone}
button={
editable
? { children: "Add step", onPress: handleAddStep }
: undefined
}
/>
) : (
<>
<Steps>
{steps
.filter((step) => step !== null)
.map((step) => (
<QuestStep key={step._id} step={step} editable={editable} />
))}
</Steps>
{editable && <Button onPress={handleAddStep}>Add step</Button>}
</>
)}
<Steps>
{steps
.filter((step) => step !== null)
.map((step) => (
<QuestStep key={step._id} step={step} editable={editable} />
))}
</Steps>
{editable && <Button onPress={handleAddStep}>Add step</Button>}
</>
);
};
37 changes: 28 additions & 9 deletions src/components/quests/Steps/Steps.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
import { Skeleton, SkeletonText } from "@/components/common";
import { Heading } from "react-aria-components";
import { twMerge } from "tailwind-merge";

interface StepProps {
children: React.ReactNode;
children?: React.ReactNode;
title?: string;
className?: string;
actions?: React.ReactNode;
isLoading?: boolean;
}

export const Step = ({ title, children, className, actions }: StepProps) => {
export const Step = ({
title,
children,
className,
actions,
isLoading,
}: StepProps) => {
return (
<li
className={twMerge(
"relative pl-12 pb-8 last:pb-0 before:text-gray-dim before:size-8 before:rounded-full before:bg-gray-3 dark:before:bg-graydark-3 before:flex before:text-xl before:items-center before:justify-center before:content-[counter(steps)] before:[counter-increment:steps] before:absolute before:left-0 after:w-[2px] after:h-[calc(100%-2rem)] after:bg-gray-3 dark:after:bg-graydark-3 after:absolute after:bottom-0 after:left-[calc(1rem-1px)] after:last:invisible flex flex-col",
className,
)}
>
<div className="flex justify-between items-start">
{title && (
<Heading className="text-2xl font-medium pb-3">{title}</Heading>
)}
{actions}
</div>
<div className="text-gray-dim flex flex-col gap-4">{children}</div>
{isLoading ? (
<div className="flex flex-col gap-4">
<Skeleton className="max-w-60 h-8" />
<SkeletonText lines={3} />
</div>
) : (
<>
<div className="flex justify-between items-start">
{title && (
<Heading className="text-2xl font-medium pb-3">{title}</Heading>
)}
{actions}
</div>
{children && (
<div className="text-gray-dim flex flex-col gap-4">{children}</div>
)}
</>
)}
</li>
);
};
Expand Down
17 changes: 17 additions & 0 deletions tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,21 @@ export default {
}),
tailwindcssContainerQueries,
],
theme: {
extend: {
animation: {
"fade-in": "fade-in 0.2s ease-in 0.2s both",
},
keyframes: {
"fade-in": {
"0%": {
opacity: "0",
},
"100%": {
opacity: "1",
},
},
},
},
},
} satisfies Config;

0 comments on commit fb82f83

Please sign in to comment.