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