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 (
-
- );
+ 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