diff --git a/app/demo/loading-states/page.tsx b/app/demo/loading-states/page.tsx
new file mode 100644
index 0000000..babcc34
--- /dev/null
+++ b/app/demo/loading-states/page.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import * as React from "react";
+import {
+ LoadingState,
+ LoadingSkeleton,
+ LoadingSpinner,
+ LoadingProgress,
+ EditorSkeleton,
+ useDelayedLoading,
+} from "@/components/ui/loading-state";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+
+export default function LoadingStatesDemo() {
+ const [showFullscreen, setShowFullscreen] = React.useState(false);
+ const [progress, setProgress] = React.useState(0);
+ const [isDelayedLoading, setIsDelayedLoading] = React.useState(false);
+ const showDelayed = useDelayedLoading(isDelayedLoading, 500);
+
+ // Simulate progress
+ React.useEffect(() => {
+ const interval = setInterval(() => {
+ setProgress((prev) => {
+ if (prev >= 100) return 0;
+ return prev + 10;
+ });
+ }, 500);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return (
+
+
Loading States Demo
+
+
+
+ Skeleton
+ Spinner
+ Progress
+ Delayed
+
+
+
+
+
+ Skeleton Loading States
+
+
+
+
+ Generic Loading Skeleton
+
+
+
+
+
+
+ Editor Loading Skeleton
+
+
+
+
+
+
+ Direct Skeleton Component
+
+
+
+
+
+
+
+
+
+
+ Spinner Loading States
+
+
+
+
Default Spinner
+
+
+
+
+
+ Spinner with Custom Message
+
+
+
+
+
+
+ Direct Spinner Component
+
+
+
+
+
+
+ Fullscreen Spinner
+
+
+ {showFullscreen && (
+
+ )}
+
+
+
+
+
+
+
+
+ Progress Loading States
+
+
+
+
+ Auto-incrementing Progress
+
+
+
+
+
+
+ Static Progress Examples
+
+
+
+
+
+
+
+
+
+
+ Edge Cases (Clamped Values)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Delayed Loading States
+
+
+
+
+ useDelayedLoading Hook Demo
+
+
+ The loading state only shows if the operation takes longer
+ than 500ms. This prevents flashing loading states for quick
+ operations.
+
+
+
+
+
+
+
+
+ Loading state active: {isDelayedLoading ? "Yes" : "No"}
+
+
+ Showing loading UI: {showDelayed ? "Yes" : "No"}
+
+
+
+ {showDelayed && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ Usage Examples
+
+
+
+ {`// Basic skeleton
+
+
+// Spinner with message
+
+
+// Progress bar
+
+
+// Fullscreen overlay
+
+
+// Editor skeleton
+
+
+// Delayed loading hook
+const showLoading = useDelayedLoading(isLoading, 100);
+{showLoading && }`}
+
+
+
+
+
+
+ Accessibility Features
+
+
+ ✓ Proper ARIA roles (status, progressbar)
+ ✓ Screen reader announcements with aria-live
+ ✓ Descriptive aria-labels
+ ✓ Progress percentage announcements
+ ✓ Respects prefers-reduced-motion
+
+
+
+ );
+}
diff --git a/components/ui/loading-state.test.tsx b/components/ui/loading-state.test.tsx
new file mode 100644
index 0000000..2df0b9d
--- /dev/null
+++ b/components/ui/loading-state.test.tsx
@@ -0,0 +1,219 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
+import {
+ LoadingState,
+ LoadingSkeleton,
+ LoadingSpinner,
+ LoadingProgress,
+ EditorSkeleton,
+ useDelayedLoading,
+} from "./loading-state";
+import { renderHook, act } from "@testing-library/react";
+
+describe("LoadingState", () => {
+ it("renders skeleton variant", () => {
+ const { container } = render();
+ // Check for skeleton elements
+ const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+
+ it("renders spinner variant with message", () => {
+ render();
+ expect(screen.getByText("Loading data...")).toBeInTheDocument();
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ });
+
+ it("renders progress variant with percentage", () => {
+ render(
+
+ );
+ expect(screen.getByText("Uploading...")).toBeInTheDocument();
+ expect(screen.getByText("75%")).toBeInTheDocument();
+ expect(screen.getByRole("progressbar")).toHaveAttribute(
+ "aria-valuenow",
+ "75"
+ );
+ });
+
+ it("renders fullscreen overlay when fullscreen prop is true", () => {
+ const { container } = render(
+
+ );
+ const overlay = container.querySelector(".fixed.inset-0");
+ expect(overlay).toBeInTheDocument();
+ expect(overlay).toHaveClass("z-50");
+ });
+
+ it("applies custom className", () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toHaveClass("custom-class");
+ });
+
+ it("sets custom aria-label", () => {
+ render(
+
+ );
+ expect(screen.getByRole("status")).toHaveAttribute(
+ "aria-label",
+ "Custom loading message"
+ );
+ });
+});
+
+describe("LoadingSkeleton", () => {
+ it("renders skeleton structure", () => {
+ const { container } = render();
+ const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+});
+
+describe("LoadingSpinner", () => {
+ it("renders with default message", () => {
+ render();
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("renders with custom message", () => {
+ render();
+ expect(screen.getByText("Please wait...")).toBeInTheDocument();
+ });
+
+ it("has proper ARIA attributes", () => {
+ render();
+ const status = screen.getByRole("status");
+ expect(status).toHaveAttribute("aria-live", "polite");
+ expect(status).toHaveAttribute("aria-label", "Loading...");
+ });
+
+ it("applies custom className", () => {
+ render();
+ expect(screen.getByRole("status")).toHaveClass("custom-spinner");
+ });
+});
+
+describe("LoadingProgress", () => {
+ it("renders with progress bar", () => {
+ render();
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ expect(screen.getByText("50%")).toBeInTheDocument();
+ });
+
+ it("clamps progress values between 0 and 100", () => {
+ const { rerender } = render();
+ expect(screen.getByRole("progressbar")).toHaveAttribute(
+ "aria-valuenow",
+ "0"
+ );
+ expect(screen.getByText("0%")).toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByRole("progressbar")).toHaveAttribute(
+ "aria-valuenow",
+ "100"
+ );
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+
+ it("renders custom message", () => {
+ render();
+ expect(screen.getByText("Uploading files...")).toBeInTheDocument();
+ });
+
+ it("has proper ARIA attributes", () => {
+ render();
+ const progressbar = screen.getByRole("progressbar");
+ expect(progressbar).toHaveAttribute("aria-valuenow", "60");
+ expect(progressbar).toHaveAttribute("aria-valuemin", "0");
+ expect(progressbar).toHaveAttribute("aria-valuemax", "100");
+ expect(progressbar).toHaveAttribute("aria-label", "File upload progress");
+ });
+});
+
+describe("EditorSkeleton", () => {
+ it("renders editor skeleton structure", () => {
+ const { container } = render();
+ // Should have multiple skeleton elements for editor UI
+ const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
+ expect(skeletons.length).toBeGreaterThan(5);
+ });
+
+ it("applies custom className", () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass("custom-editor");
+ });
+});
+
+describe("useDelayedLoading", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns false initially when loading is true", () => {
+ const { result } = renderHook(() => useDelayedLoading(true, 100));
+ expect(result.current).toBe(false);
+ });
+
+ it("returns true after delay when loading is true", async () => {
+ const { result } = renderHook(() => useDelayedLoading(true, 100));
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it("returns false when loading is false", () => {
+ const { result } = renderHook(() => useDelayedLoading(false, 100));
+ expect(result.current).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it("resets to false when loading changes from true to false", () => {
+ const { result, rerender } = renderHook(
+ ({ loading }) => useDelayedLoading(loading, 100),
+ { initialProps: { loading: true } }
+ );
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+ expect(result.current).toBe(true);
+
+ rerender({ loading: false });
+ expect(result.current).toBe(false);
+ });
+
+ it("uses default delay of 100ms when not specified", () => {
+ const { result } = renderHook(() => useDelayedLoading(true));
+
+ expect(result.current).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(99);
+ });
+ expect(result.current).toBe(false);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(result.current).toBe(true);
+ });
+});
diff --git a/components/ui/loading-state.tsx b/components/ui/loading-state.tsx
new file mode 100644
index 0000000..a5594fc
--- /dev/null
+++ b/components/ui/loading-state.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent } from "@/components/ui/card";
+
+export interface LoadingStateProps {
+ /**
+ * Type of loading indicator
+ */
+ type: "skeleton" | "spinner" | "progress";
+ /**
+ * Message to display (for spinner and progress types)
+ */
+ message?: string;
+ /**
+ * Progress percentage (0-100) for progress type
+ */
+ progress?: number;
+ /**
+ * Whether to show fullscreen overlay
+ */
+ fullscreen?: boolean;
+ /**
+ * Additional CSS classes
+ */
+ className?: string;
+ /**
+ * Accessible label for screen readers
+ */
+ ariaLabel?: string;
+}
+
+/**
+ * LoadingState component provides consistent loading indicators
+ */
+export function LoadingState({
+ type,
+ message,
+ progress = 0,
+ fullscreen = false,
+ className,
+ ariaLabel,
+}: LoadingStateProps) {
+ const content = React.useMemo(() => {
+ switch (type) {
+ case "skeleton":
+ return ;
+ case "spinner":
+ return ;
+ case "progress":
+ return (
+
+ );
+ }
+ }, [type, message, progress, ariaLabel]);
+
+ if (fullscreen) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return {content}
;
+}
+
+/**
+ * LoadingSkeleton for page/component loading states
+ */
+export function LoadingSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * LoadingSpinner with optional message
+ */
+export function LoadingSpinner({
+ message = "Loading...",
+ ariaLabel,
+ className,
+}: {
+ message?: string;
+ ariaLabel?: string;
+ className?: string;
+}) {
+ return (
+
+
+ {message &&
{message}
}
+
+ );
+}
+
+/**
+ * LoadingProgress for file processing operations
+ */
+export function LoadingProgress({
+ message = "Processing...",
+ progress = 0,
+ ariaLabel,
+ className,
+}: {
+ message?: string;
+ progress?: number;
+ ariaLabel?: string;
+ className?: string;
+}) {
+ const clampedProgress = Math.max(0, Math.min(100, progress));
+
+ return (
+
+
+
{message}
+
+ {clampedProgress}%
+
+
+
+
+ );
+}
+
+/**
+ * EditorSkeleton specifically for code editor loading states
+ */
+export function EditorSkeleton({ className }: { className?: string }) {
+ return (
+
+ {/* File header skeleton */}
+
+ {/* Editor content skeleton */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Utility hook for delayed loading states
+ * Shows loading state only if operation takes longer than threshold
+ */
+export function useDelayedLoading(
+ isLoading: boolean,
+ delay: number = 100
+): boolean {
+ const [showLoading, setShowLoading] = React.useState(false);
+
+ React.useEffect(() => {
+ if (isLoading) {
+ const timeout = setTimeout(() => {
+ setShowLoading(true);
+ }, delay);
+
+ return () => clearTimeout(timeout);
+ } else {
+ setShowLoading(false);
+ }
+ }, [isLoading, delay]);
+
+ return showLoading;
+}
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..0168998
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/docs/PHASE_4_ISSUE_TRACKING.md b/docs/PHASE_4_ISSUE_TRACKING.md
index 9ccb644..3655723 100644
--- a/docs/PHASE_4_ISSUE_TRACKING.md
+++ b/docs/PHASE_4_ISSUE_TRACKING.md
@@ -39,7 +39,7 @@ All 19 Phase 4 UI component issues have been successfully created on GitHub. Thi
| #61 | GistViewer | HIGH | 🟢 Complete | Read-only gist viewer |
| #66 | FileList | MEDIUM | 🟢 Complete | File navigation tabs/list |
| #71 | VersionHistory | LOW | 🟡 Ready | Version history dropdown |
-| #67 | LoadingStates | MEDIUM | 🟡 Ready | Consistent loading components |
+| #67 | LoadingStates | MEDIUM | 🟢 Complete | Consistent loading components |
| #58 | ErrorBoundary | HIGH | 🟢 Complete | Error boundary for graceful failures |
### UI Features (3 issues)
@@ -76,7 +76,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
12. **#64** - ExpirySelector (Gist options) ✅ COMPLETE
13. **#65** - PasswordInput (Security feature) ✅ COMPLETE
14. **#66** - FileList (Navigation) ✅ COMPLETE
-15. **#67** - LoadingStates (UX improvement)
+15. **#67** - LoadingStates (UX improvement) ✅ COMPLETE
16. **#68** - Toast Notifications (User feedback) ✅ COMPLETE
### Week 4: Polish
@@ -89,7 +89,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
- **CRITICAL** (3): #54 ✅, #55 ✅, #56 ✅
- **HIGH** (6): #53 ✅, #57 ✅, #58 ✅, #59 ✅, #60 ✅, #61 ✅
-- **MEDIUM** (7): #62 ✅, #63 ✅, #64 ✅, #65 ✅, #66 ✅, #67, #68 ✅
+- **MEDIUM** (7): #62 ✅, #63 ✅, #64 ✅, #65 ✅, #66 ✅, #67 ✅, #68 ✅
- **LOW** (3): #70, #71, #72
## Status Legend
@@ -120,6 +120,7 @@ Note: Issue #69 appears to be a duplicate of #59 (both for copy to clipboard fun
- #64 (ExpirySelector) - PR #93 ✅
- #65 (PasswordInput) - PR #95 ✅
- #66 (FileList) - PR #96 ✅
+ - #67 (LoadingStates) - PR #97 ✅
## Quick Commands
@@ -139,19 +140,19 @@ gh pr create --title "feat: implement [component]" --body "Closes #[number]"
## Progress Summary
-- **Completed**: 15 out of 19 issues (79%)
+- **Completed**: 16 out of 19 issues (84%)
- All CRITICAL issues are complete ✅
- All HIGH priority issues are complete ✅
- - 6 out of 7 MEDIUM priority issues complete
-- **Remaining**: 4 issues
+ - All MEDIUM priority issues are complete ✅
+- **Remaining**: 3 issues
- 0 HIGH priority
- - 1 MEDIUM priority
+ - 0 MEDIUM priority
- 3 LOW priority
### Next Priority Issues
-1. **#67** - LoadingStates (MEDIUM) - UX improvement
-2. **#70** - Footer (LOW) - Complete layout
-3. **#71** - VersionHistory (LOW) - Advanced feature
+1. **#70** - Footer (LOW) - Complete layout
+2. **#71** - VersionHistory (LOW) - Advanced feature
+3. **#72** - Keyboard Shortcuts (LOW) - Power users
Last Updated: 2025-06-07
diff --git a/docs/TODO.md b/docs/TODO.md
index fe6a8bf..9c8628c 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -120,7 +120,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks
- [x] Create GistViewer component - [#61](https://github.com/nullcoder/ghostpaste/issues/61)
- [x] Create FileList component (vertical display for viewing) - [#66](https://github.com/nullcoder/ghostpaste/issues/66)
- [ ] Create VersionHistory dropdown - [#71](https://github.com/nullcoder/ghostpaste/issues/71)
-- [ ] Create LoadingStates component - [#67](https://github.com/nullcoder/ghostpaste/issues/67)
+- [x] Create LoadingStates component - [#67](https://github.com/nullcoder/ghostpaste/issues/67)
- [x] Create ErrorBoundary component - [#58](https://github.com/nullcoder/ghostpaste/issues/58)
### UI Features