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
82 changes: 81 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
:root {
--background: #fafaf9;
--foreground: #1c1917;
--spinner-accent: #57534e;
}

@media (prefers-color-scheme: dark) {
Expand All @@ -14,6 +15,10 @@
}
}

.dark {
--spinner-accent: #a8a29e;
}

body {
color: var(--foreground);
background: var(--background);
Expand Down Expand Up @@ -368,14 +373,89 @@ h6 {
animation: chart-live-flash-core-down 1.8s ease-in-out infinite;
}

/* 3D arc loading spinner */
.loading-spinner {
position: relative;
transform-style: preserve-3d;
perspective: 800px;
}

.loading-spinner--sm {
width: 2rem;
height: 2rem;
}

.loading-spinner--md {
width: 3rem;
height: 3rem;
}

.loading-spinner--lg {
width: 4rem;
height: 4rem;
}

.loading-spinner__arc {
position: absolute;
inset: 0;
border-radius: 50%;
border-bottom: 3px solid var(--spinner-accent);
}

.loading-spinner--sm .loading-spinner__arc {
border-bottom-width: 2px;
}

.loading-spinner__arc:nth-child(1) {
animation: loading-spinner-rotate-1 1.15s linear infinite;
animation-delay: -0.8s;
}

.loading-spinner__arc:nth-child(2) {
animation: loading-spinner-rotate-2 1.15s linear infinite;
animation-delay: -0.4s;
}

.loading-spinner__arc:nth-child(3) {
animation: loading-spinner-rotate-3 1.15s linear infinite;
}

@keyframes loading-spinner-rotate-1 {
from {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(0);
}
to {
transform: rotateX(35deg) rotateY(-45deg) rotateZ(1turn);
}
}

@keyframes loading-spinner-rotate-2 {
from {
transform: rotateX(50deg) rotateY(10deg) rotateZ(0);
}
to {
transform: rotateX(50deg) rotateY(10deg) rotateZ(1turn);
}
}

@keyframes loading-spinner-rotate-3 {
from {
transform: rotateX(35deg) rotateY(55deg) rotateZ(0);
}
to {
transform: rotateX(35deg) rotateY(55deg) rotateZ(1turn);
}
}

@media (prefers-reduced-motion: reduce) {
.chart-sparkle-sweep--loading,
.chart-sparkle-flash--loading,
.chart-scrub-beam--active,
.chart-scrub-point--active,
.chart-live-flash-ring,
.chart-live-flash-core--up,
.chart-live-flash-core--down {
.chart-live-flash-core--down,
.loading-spinner__arc {
animation: none;
}

Expand Down
2 changes: 1 addition & 1 deletion components/ChartComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -772,7 +772,7 @@ export function ChartComponent({
className="flex items-center justify-center rounded-xl bg-stone-100/50 dark:bg-stone-900/40"
style={{ height: `${height}px` }}
>
<LoadingSpinner size="md" message="Updating chart..." />
<LoadingSpinner size="md" />
</div>
)}
</div>
Expand Down
34 changes: 21 additions & 13 deletions components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,45 +4,53 @@ import { DNA_BODY } from "@/lib/design-dna";

/**
* LoadingSpinner Component
* Reusable loading indicator with configurable size and optional message.
* Supports dark mode via Tailwind CSS classes.
* 3D arc spinner with theme-aware accent color via `--spinner-accent`.
*
* Requirements: 14.1
*/

export interface LoadingSpinnerProps {
/** Spinner size: "sm" (20px), "md" (32px), or "lg" (48px). Defaults to "md". */
/** Spinner size: "sm" (32px), "md" (48px), or "lg" (64px). Defaults to "md". */
size?: "sm" | "md" | "lg";
/** Optional message displayed below the spinner */
/** Accessible label (screen readers only unless `showMessage` is true). */
message?: string;
/** Show the message visibly below the spinner. Defaults to false. */
showMessage?: boolean;
/** Additional CSS class names */
className?: string;
}

const SIZE_CLASSES: Record<NonNullable<LoadingSpinnerProps["size"]>, string> = {
sm: "h-5 w-5 border-2",
md: "h-8 w-8 border-2",
lg: "h-12 w-12 border-[3px]",
sm: "loading-spinner--sm",
md: "loading-spinner--md",
lg: "loading-spinner--lg",
};

export function LoadingSpinner({
size = "md",
message,
showMessage = false,
className = "",
}: LoadingSpinnerProps) {
const label = message ?? "Loading";

return (
<div
className={`flex flex-col items-center justify-center ${className}`}
role="status"
aria-live="polite"
aria-label={message ?? "Loading"}
aria-label={label}
data-testid="loading-spinner"
>
<div
className={`animate-spin rounded-full border-stone-700 border-b-transparent dark:border-stone-300 ${SIZE_CLASSES[size]}`}
/>
{message && <p className={`mt-3 ${DNA_BODY}`}>{message}</p>}
<span className="sr-only">{message ?? "Loading"}</span>
<div className={`loading-spinner ${SIZE_CLASSES[size]}`} aria-hidden>
<div className="loading-spinner__arc" />
<div className="loading-spinner__arc" />
<div className="loading-spinner__arc" />
</div>
{showMessage && message && (
<p className={`mt-3 ${DNA_BODY}`}>{message}</p>
)}
<span className="sr-only">{label}</span>
</div>
);
}
2 changes: 1 addition & 1 deletion components/__tests__/ColorContrast.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe("Color Contrast Compliance (Req 18.3)", () => {
describe("LoadingSpinner", () => {
it("uses readable stone text (not text-gray-400) for message text in dark mode", () => {
const { container } = render(
<LoadingSpinner message="Loading data..." />
<LoadingSpinner message="Loading data..." showMessage />
);
const messageEl = container.querySelector("p");
expect(messageEl).not.toBeNull();
Expand Down
29 changes: 16 additions & 13 deletions components/__tests__/LoadingSpinner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,33 +24,36 @@ describe("LoadingSpinner", () => {
expect(screen.getByText("Loading")).toBeDefined(); // sr-only text
});

it("should display a custom message below the spinner", () => {
it("should display a custom message for screen readers only by default", () => {
render(<LoadingSpinner message="Fetching data..." />);
const messages = screen.getAllByText("Fetching data...");
expect(messages.length).toBe(2); // visible <p> + sr-only <span>
expect(screen.getAllByText("Fetching data...")).toHaveLength(1);
expect(screen.queryByRole("paragraph")).toBeNull();
const spinner = screen.getByTestId("loading-spinner");
expect(spinner.getAttribute("aria-label")).toBe("Fetching data...");
});

it("should show visible message when showMessage is true", () => {
render(<LoadingSpinner message="Fetching data..." showMessage />);
expect(screen.getAllByText("Fetching data...")).toHaveLength(2);
});

it("should render small size variant", () => {
const { container } = render(<LoadingSpinner size="sm" />);
const spinnerCircle = container.querySelector(".animate-spin");
expect(spinnerCircle?.classList.contains("h-5")).toBe(true);
expect(spinnerCircle?.classList.contains("w-5")).toBe(true);
const arcSpinner = container.querySelector(".loading-spinner");
expect(arcSpinner?.classList.contains("loading-spinner--sm")).toBe(true);
expect(container.querySelectorAll(".loading-spinner__arc")).toHaveLength(3);
});

it("should render medium size variant (default)", () => {
const { container } = render(<LoadingSpinner />);
const spinnerCircle = container.querySelector(".animate-spin");
expect(spinnerCircle?.classList.contains("h-8")).toBe(true);
expect(spinnerCircle?.classList.contains("w-8")).toBe(true);
const arcSpinner = container.querySelector(".loading-spinner");
expect(arcSpinner?.classList.contains("loading-spinner--md")).toBe(true);
});

it("should render large size variant", () => {
const { container } = render(<LoadingSpinner size="lg" />);
const spinnerCircle = container.querySelector(".animate-spin");
expect(spinnerCircle?.classList.contains("h-12")).toBe(true);
expect(spinnerCircle?.classList.contains("w-12")).toBe(true);
const arcSpinner = container.querySelector(".loading-spinner");
expect(arcSpinner?.classList.contains("loading-spinner--lg")).toBe(true);
});

it("should apply additional className", () => {
Expand All @@ -60,7 +63,7 @@ describe("LoadingSpinner", () => {
});

it("should include sr-only text for screen readers", () => {
render(<LoadingSpinner message="Loading chart" />);
render(<LoadingSpinner message="Loading chart" showMessage />);
const srOnly = screen.getByText("Loading chart", {
selector: ".sr-only",
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "the-open-stock",
"version": "0.66.0",
"version": "0.67.0",
"private": true,
"scripts": {
"dev": "bun --bun next dev",
Expand Down
Loading