From 952605e3b4cc71115a96cfdfca4a506019a18edb Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Sat, 22 Aug 2026 20:22:40 +0800 Subject: [PATCH] feat: add credential loading states with reusable skeleton and spinner components (Closes #70) - LoadingSpinner: reusable animated spinner with size variants and label support - SkeletonCard: skeleton placeholder for credential list items - SkeletonAnalytics: full-page skeleton for analytics dashboard - Replace plain "Loading analytics..." text with SkeletonAnalytics - Add saving/loading state to CredentialEditModal with spinner + disabled inputs - Add initial loading skeleton to HealthCredentialVault - All inputs and buttons disabled during loading operations - 14 tests for loading states components - 53/53 tests passing --- frontend/__tests__/loading-states.test.tsx | 96 +++++++++++++++++++ .../credential-analytics-dashboard.tsx | 7 +- .../src/components/credential-edit-modal.tsx | 57 +++++++---- .../components/health-credential-vault.tsx | 14 ++- frontend/src/components/loading/index.ts | 3 + .../components/loading/loading-spinner.tsx | 44 +++++++++ .../components/loading/skeleton-analytics.tsx | 68 +++++++++++++ .../src/components/loading/skeleton-card.tsx | 48 ++++++++++ 8 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 frontend/__tests__/loading-states.test.tsx create mode 100644 frontend/src/components/loading/index.ts create mode 100644 frontend/src/components/loading/loading-spinner.tsx create mode 100644 frontend/src/components/loading/skeleton-analytics.tsx create mode 100644 frontend/src/components/loading/skeleton-card.tsx diff --git a/frontend/__tests__/loading-states.test.tsx b/frontend/__tests__/loading-states.test.tsx new file mode 100644 index 00000000..1fd9c356 --- /dev/null +++ b/frontend/__tests__/loading-states.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { LoadingSpinner } from '../src/components/loading/loading-spinner'; +import { SkeletonCard } from '../src/components/loading/skeleton-card'; +import { SkeletonAnalytics } from '../src/components/loading/skeleton-analytics'; + +describe('LoadingSpinner', () => { + it('renders default spinner', () => { + const { container } = render(); + const spinner = container.querySelector('[role="status"]'); + expect(spinner).toBeInTheDocument(); + }); + + it('renders with label', () => { + render(); + const labels = screen.getAllByText('Loading...'); + expect(labels.length).toBeGreaterThanOrEqual(1); + expect(labels[0]).toBeInTheDocument(); + }); + + it('renders with custom size', () => { + const { container } = render(); + const spinner = container.querySelector('[role="status"]'); + expect(spinner).toBeInTheDocument(); + }); + + it('renders with custom className', () => { + const { container } = render(); + const spinner = container.querySelector('[role="status"]'); + expect(spinner).toBeInTheDocument(); + }); + + it('has sr-only text for accessibility', () => { + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('has aria-live polite for screen readers', () => { + render(); + const statuses = screen.getAllByRole('status'); + expect(statuses.length).toBeGreaterThanOrEqual(1); + expect(statuses[0]).toHaveAttribute('aria-live', 'polite'); + }); +}); + +describe('SkeletonCard', () => { + it('renders default skeleton with 3 cards', () => { + const { container } = render(); + const skeleton = screen.getByTestId('skeleton-card'); + expect(skeleton).toBeInTheDocument(); + // Should have 3 skeleton card items + const items = container.querySelectorAll('.rounded-lg'); + expect(items.length).toBeGreaterThanOrEqual(3); + }); + + it('renders custom count', () => { + const { container } = render(); + const items = container.querySelectorAll('.rounded-lg'); + expect(items.length).toBeGreaterThanOrEqual(5); + }); + + it('has aria-busy true', () => { + render(); + const skeleton = screen.getByTestId('skeleton-card'); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + }); + + it('has accessible label', () => { + render(); + const skeleton = screen.getByTestId('skeleton-card'); + expect(skeleton).toHaveAttribute('aria-label', 'Loading vault'); + }); + + it('has sr-only text', () => { + render(); + expect(screen.getByText('Loading credentials...')).toBeInTheDocument(); + }); +}); + +describe('SkeletonAnalytics', () => { + it('renders analytics skeleton', () => { + const { container } = render(); + const skeleton = screen.getByTestId('skeleton-analytics'); + expect(skeleton).toBeInTheDocument(); + }); + + it('has aria-busy true', () => { + render(); + const skeleton = screen.getByTestId('skeleton-analytics'); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + }); + + it('has sr-only text', () => { + render(); + expect(screen.getByText('Loading analytics dashboard...')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/credential-analytics-dashboard.tsx b/frontend/src/components/credential-analytics-dashboard.tsx index b786b76c..882d3f6d 100644 --- a/frontend/src/components/credential-analytics-dashboard.tsx +++ b/frontend/src/components/credential-analytics-dashboard.tsx @@ -1,3 +1,4 @@ +import { SkeletonAnalytics } from './loading/skeleton-analytics'; import { useState, useEffect } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, @@ -98,11 +99,7 @@ export function CredentialAnalyticsDashboard() { }, []); if (loading || !data) { - return ( -
-
Loading analytics...
-
- ); + return ; } const pieData = [ diff --git a/frontend/src/components/credential-edit-modal.tsx b/frontend/src/components/credential-edit-modal.tsx index a0773ab9..71ca4ee8 100644 --- a/frontend/src/components/credential-edit-modal.tsx +++ b/frontend/src/components/credential-edit-modal.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Edit2, CheckCircle } from 'lucide-react'; +import { LoadingSpinner } from './loading/loading-spinner'; interface Credential { id: string; @@ -26,25 +27,35 @@ export function CredentialEditModal({ }: CredentialEditModalProps) { const [vaccineType, setVaccineType] = useState(''); const [vaccinationDate, setVaccinationDate] = useState(''); + const [isSaving, setIsSaving] = useState(false); useEffect(() => { if (credential && isOpen) { setVaccineType(credential.vaccineType); setVaccinationDate(credential.vaccinationDate); + setIsSaving(false); } }, [credential, isOpen]); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (credential) { - onSave({ - ...credential, - vaccineType, - vaccinationDate, - }); - onClose(); - } - }; + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (credential && !isSaving) { + setIsSaving(true); + // Simulate async save + setTimeout(() => { + onSave({ + ...credential, + vaccineType, + vaccinationDate, + }); + setIsSaving(false); + onClose(); + }, 800); + } + }, + [credential, isSaving, onSave, onClose, vaccineType, vaccinationDate] + ); if (!credential) return null; @@ -90,7 +101,8 @@ export function CredentialEditModal({ type="text" value={vaccineType} onChange={(e) => setVaccineType(e.target.value)} - className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 text-base sm:text-sm" + disabled={isSaving} + className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 text-base sm:text-sm disabled:opacity-50" required /> @@ -100,7 +112,8 @@ export function CredentialEditModal({ type="text" value={vaccinationDate} onChange={(e) => setVaccinationDate(e.target.value)} - className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 text-base sm:text-sm" + disabled={isSaving} + className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 text-base sm:text-sm disabled:opacity-50" required /> @@ -109,16 +122,24 @@ export function CredentialEditModal({ diff --git a/frontend/src/components/health-credential-vault.tsx b/frontend/src/components/health-credential-vault.tsx index 92654c85..3bea9ff0 100644 --- a/frontend/src/components/health-credential-vault.tsx +++ b/frontend/src/components/health-credential-vault.tsx @@ -7,6 +7,7 @@ import { AnimatedProgress, SuccessOverlay, SuccessToast } from './animations'; import { DeletionConfirmationModal } from './deletion-confirmation-modal'; import { CredentialDetailsModal } from './credential-details-modal'; import { CredentialEditModal } from './credential-edit-modal'; +import { SkeletonCard } from './loading/skeleton-card'; import { useAccessibility } from '@/contexts/AccessibilityContext'; interface HealthCredentialVaultProps { @@ -22,6 +23,7 @@ interface Credential { export function HealthCredentialVault({ walletAddress }: HealthCredentialVaultProps) { const [credentials, setCredentials] = useState([]); + const [initialLoading, setInitialLoading] = useState(true); const [uploadProgress, setUploadProgress] = useState(null); const [showSuccess, setShowSuccess] = useState(false); const [toast, setToast] = useState<{ show: boolean; title: string; description?: string }>({ @@ -39,6 +41,14 @@ export function HealthCredentialVault({ walletAddress }: HealthCredentialVaultPr const { announceToScreenReader } = useAccessibility(); const fileInputRef = useRef(null); + useEffect(() => { + // Simulate initial loading of credentials from storage + const timer = setTimeout(() => { + setInitialLoading(false); + }, 1200); + return () => clearTimeout(timer); + }, []); + const simulateUpload = useCallback((fileName: string) => { setUploadProgress(0); announceToScreenReader('Uploading file...'); @@ -210,7 +220,9 @@ export function HealthCredentialVault({ walletAddress }: HealthCredentialVaultPr {/* Credentials list */}
- {credentials.length === 0 ? ( + {initialLoading ? ( + + ) : credentials.length === 0 ? ( + {label || 'Loading...'} + + ); + + if (!label) return ( +
+ {spinner} +
+ ); + + return ( +
+ {spinner} + {label} +
+ ); +} + +export { LoadingSpinner as default }; \ No newline at end of file diff --git a/frontend/src/components/loading/skeleton-analytics.tsx b/frontend/src/components/loading/skeleton-analytics.tsx new file mode 100644 index 00000000..35a8cb49 --- /dev/null +++ b/frontend/src/components/loading/skeleton-analytics.tsx @@ -0,0 +1,68 @@ +'use client'; + +const SkeletonPulse = ({ className }: { className: string }) => ( +
+
+
+); + +export function SkeletonAnalytics() { + return ( +
+ {/* Header */} +
+ +
+ + +
+
+ + {/* Status bar */} + + + {/* Stat cards */} +
+ {[1, 2, 3].map((i) => ( +
+ + + +
+ ))} +
+ + {/* Charts */} +
+
+ + +
+
+ + +
+
+ + {/* Activity feed */} +
+ +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+ Loading analytics dashboard... +
+ ); +} + +export { SkeletonAnalytics as default }; \ No newline at end of file diff --git a/frontend/src/components/loading/skeleton-card.tsx b/frontend/src/components/loading/skeleton-card.tsx new file mode 100644 index 00000000..faa81cf3 --- /dev/null +++ b/frontend/src/components/loading/skeleton-card.tsx @@ -0,0 +1,48 @@ +'use client'; + +interface SkeletonCardProps { + count?: number; + label?: string; +} + +const SkeletonPulse = ({ className }: { className: string }) => ( +
+
+
+); + +export function SkeletonCard({ count = 3, label = 'Loading credentials' }: SkeletonCardProps) { + return ( +
+ {Array.from({ length: count }).map((_, index) => ( +
+
+ +
+ + + +
+
+
+ + + +
+
+ ))} + {label}... +
+ ); +} + +export { SkeletonCard as default }; \ No newline at end of file