From 479583bc9e9fa3445fabfce426acc881d404c41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=85=20Daniel=20Danielecki?= Date: Thu, 18 Jun 2026 11:42:09 +0200 Subject: [PATCH] feat: make loading indicator beautiful --- app/globals.css | 82 +++++++++++++++++++- components/ChartComponent.tsx | 2 +- components/LoadingSpinner.tsx | 34 ++++---- components/__tests__/ColorContrast.test.tsx | 2 +- components/__tests__/LoadingSpinner.test.tsx | 29 +++---- package.json | 2 +- 6 files changed, 121 insertions(+), 30 deletions(-) diff --git a/app/globals.css b/app/globals.css index bd4a613..453dc28 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,6 +5,7 @@ :root { --background: #fafaf9; --foreground: #1c1917; + --spinner-accent: #57534e; } @media (prefers-color-scheme: dark) { @@ -14,6 +15,10 @@ } } +.dark { + --spinner-accent: #a8a29e; +} + body { color: var(--foreground); background: var(--background); @@ -368,6 +373,80 @@ 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, @@ -375,7 +454,8 @@ h6 { .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; } diff --git a/components/ChartComponent.tsx b/components/ChartComponent.tsx index 561b335..ecd3d36 100644 --- a/components/ChartComponent.tsx +++ b/components/ChartComponent.tsx @@ -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` }} > - + )} diff --git a/components/LoadingSpinner.tsx b/components/LoadingSpinner.tsx index 8c22260..31efe70 100644 --- a/components/LoadingSpinner.tsx +++ b/components/LoadingSpinner.tsx @@ -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, 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 (
-
- {message &&

{message}

} - {message ?? "Loading"} +
+
+
+
+
+ {showMessage && message && ( +

{message}

+ )} + {label}
); } diff --git a/components/__tests__/ColorContrast.test.tsx b/components/__tests__/ColorContrast.test.tsx index ceda564..27baff6 100644 --- a/components/__tests__/ColorContrast.test.tsx +++ b/components/__tests__/ColorContrast.test.tsx @@ -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( - + ); const messageEl = container.querySelector("p"); expect(messageEl).not.toBeNull(); diff --git a/components/__tests__/LoadingSpinner.test.tsx b/components/__tests__/LoadingSpinner.test.tsx index cde5fd0..fafad43 100644 --- a/components/__tests__/LoadingSpinner.test.tsx +++ b/components/__tests__/LoadingSpinner.test.tsx @@ -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(); - const messages = screen.getAllByText("Fetching data..."); - expect(messages.length).toBe(2); // visible

+ sr-only + 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(); + expect(screen.getAllByText("Fetching data...")).toHaveLength(2); + }); + it("should render small size variant", () => { const { container } = render(); - 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(); - 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(); - 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", () => { @@ -60,7 +63,7 @@ describe("LoadingSpinner", () => { }); it("should include sr-only text for screen readers", () => { - render(); + render(); const srOnly = screen.getByText("Loading chart", { selector: ".sr-only", }); diff --git a/package.json b/package.json index 1bfb2c0..c054a62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "the-open-stock", - "version": "0.66.0", + "version": "0.67.0", "private": true, "scripts": { "dev": "bun --bun next dev",