diff --git a/frontend/src/components/KYCSubmissionForm.tsx b/frontend/src/components/KYCSubmissionForm.tsx index 623d7a6..947e16f 100644 --- a/frontend/src/components/KYCSubmissionForm.tsx +++ b/frontend/src/components/KYCSubmissionForm.tsx @@ -3,6 +3,7 @@ import React, { useState, useCallback } from "react"; import { motion, AnimatePresence, type Variants } from "framer-motion"; import { useTranslations } from "next-intl"; +import { useOptimisticUpdate } from "@/hooks/useOptimisticUpdate"; /** * Form data interface for KYC submission @@ -98,10 +99,24 @@ export const KYCSubmissionForm: React.FC = ({ documentUpload: null, }); const [errors, setErrors] = useState>({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitStatus, setSubmitStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); const [announcementText, setAnnouncementText] = useState(""); + const { + state: kycState, + isPending, + executeUpdate, + } = useOptimisticUpdate<{ submitted: boolean; data: KYCFormData | null }>( + { submitted: false, data: null }, + { + onSuccess: () => { + setAnnouncementText(t("kyc.submitSuccess") || "KYC form submitted successfully!"); + }, + onError: () => { + setAnnouncementText(t("kyc.submitError") || "Failed to submit KYC form. Please try again."); + }, + } + ); + /** * Validate form data */ @@ -147,21 +162,13 @@ export const KYCSubmissionForm: React.FC = ({ return; } - setIsSubmitting(true); - setSubmitStatus("loading"); setAnnouncementText(t("kyc.submitting") || "Submitting KYC form..."); - try { - await onSubmit?.(formData); - setSubmitStatus("success"); - setAnnouncementText(t("kyc.submitSuccess") || "KYC form submitted successfully!"); - } catch (error) { - setSubmitStatus("error"); - setAnnouncementText(t("kyc.submitError") || "Failed to submit KYC form. Please try again."); - } finally { - setIsSubmitting(false); - } - }, [formData, validateForm, onSubmit, t]); + await executeUpdate( + () => ({ submitted: true, data: formData }), + async () => { await onSubmit?.(formData); } + ); + }, [formData, validateForm, onSubmit, t, executeUpdate]); /** * Handle input changes @@ -488,7 +495,7 @@ export const KYCSubmissionForm: React.FC = ({ className="flex-1 rounded-xl border border-pluto-200 bg-white px-6 py-3 font-semibold text-pluto-900 transition-all hover:bg-pluto-50 focus:ring-2 focus:ring-pluto-400" variants={submitVariants} animate="idle" - disabled={isSubmitting} + disabled={isPending} > {t("common.cancel") || "Cancel"} @@ -496,20 +503,20 @@ export const KYCSubmissionForm: React.FC = ({ type="submit" className="flex-1 rounded-xl bg-pluto-600 px-6 py-3 font-semibold text-white transition-all hover:bg-pluto-700 focus:ring-2 focus:ring-pluto-400 disabled:opacity-50 disabled:cursor-not-allowed" variants={submitVariants} - animate={submitStatus} - disabled={isSubmitting} + animate={kycState.submitted ? (isPending ? "loading" : "success") : "idle"} + disabled={isPending || kycState.submitted} aria-describedby="submit-status" > - {isSubmitting ? ( + {isPending && kycState.submitted ? ( - {t("kyc.submitting") || "Submitting..."} + {t("kyc.confirming") || "Confirming..."} - ) : submitStatus === "success" ? ( + ) : kycState.submitted ? ( t("kyc.submitted") || "Submitted!" ) : ( t("kyc.submit") || "Submit KYC" @@ -519,9 +526,8 @@ export const KYCSubmissionForm: React.FC = ({ {/* Submit status for screen readers */}
- {submitStatus === "loading" && (t("kyc.submitting") || "Submitting form...")} - {submitStatus === "success" && (t("kyc.submitSuccess") || "Form submitted successfully")} - {submitStatus === "error" && (t("kyc.submitError") || "Form submission failed")} + {isPending && kycState.submitted && (t("kyc.confirming") || "Confirming submission...")} + {!isPending && kycState.submitted && (t("kyc.submitSuccess") || "Form submitted successfully")}
diff --git a/frontend/src/components/KycSubmissionForm.tsx b/frontend/src/components/KycSubmissionForm.tsx index 623d7a6..947e16f 100644 --- a/frontend/src/components/KycSubmissionForm.tsx +++ b/frontend/src/components/KycSubmissionForm.tsx @@ -3,6 +3,7 @@ import React, { useState, useCallback } from "react"; import { motion, AnimatePresence, type Variants } from "framer-motion"; import { useTranslations } from "next-intl"; +import { useOptimisticUpdate } from "@/hooks/useOptimisticUpdate"; /** * Form data interface for KYC submission @@ -98,10 +99,24 @@ export const KYCSubmissionForm: React.FC = ({ documentUpload: null, }); const [errors, setErrors] = useState>({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [submitStatus, setSubmitStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); const [announcementText, setAnnouncementText] = useState(""); + const { + state: kycState, + isPending, + executeUpdate, + } = useOptimisticUpdate<{ submitted: boolean; data: KYCFormData | null }>( + { submitted: false, data: null }, + { + onSuccess: () => { + setAnnouncementText(t("kyc.submitSuccess") || "KYC form submitted successfully!"); + }, + onError: () => { + setAnnouncementText(t("kyc.submitError") || "Failed to submit KYC form. Please try again."); + }, + } + ); + /** * Validate form data */ @@ -147,21 +162,13 @@ export const KYCSubmissionForm: React.FC = ({ return; } - setIsSubmitting(true); - setSubmitStatus("loading"); setAnnouncementText(t("kyc.submitting") || "Submitting KYC form..."); - try { - await onSubmit?.(formData); - setSubmitStatus("success"); - setAnnouncementText(t("kyc.submitSuccess") || "KYC form submitted successfully!"); - } catch (error) { - setSubmitStatus("error"); - setAnnouncementText(t("kyc.submitError") || "Failed to submit KYC form. Please try again."); - } finally { - setIsSubmitting(false); - } - }, [formData, validateForm, onSubmit, t]); + await executeUpdate( + () => ({ submitted: true, data: formData }), + async () => { await onSubmit?.(formData); } + ); + }, [formData, validateForm, onSubmit, t, executeUpdate]); /** * Handle input changes @@ -488,7 +495,7 @@ export const KYCSubmissionForm: React.FC = ({ className="flex-1 rounded-xl border border-pluto-200 bg-white px-6 py-3 font-semibold text-pluto-900 transition-all hover:bg-pluto-50 focus:ring-2 focus:ring-pluto-400" variants={submitVariants} animate="idle" - disabled={isSubmitting} + disabled={isPending} > {t("common.cancel") || "Cancel"} @@ -496,20 +503,20 @@ export const KYCSubmissionForm: React.FC = ({ type="submit" className="flex-1 rounded-xl bg-pluto-600 px-6 py-3 font-semibold text-white transition-all hover:bg-pluto-700 focus:ring-2 focus:ring-pluto-400 disabled:opacity-50 disabled:cursor-not-allowed" variants={submitVariants} - animate={submitStatus} - disabled={isSubmitting} + animate={kycState.submitted ? (isPending ? "loading" : "success") : "idle"} + disabled={isPending || kycState.submitted} aria-describedby="submit-status" > - {isSubmitting ? ( + {isPending && kycState.submitted ? ( - {t("kyc.submitting") || "Submitting..."} + {t("kyc.confirming") || "Confirming..."} - ) : submitStatus === "success" ? ( + ) : kycState.submitted ? ( t("kyc.submitted") || "Submitted!" ) : ( t("kyc.submit") || "Submit KYC" @@ -519,9 +526,8 @@ export const KYCSubmissionForm: React.FC = ({ {/* Submit status for screen readers */}
- {submitStatus === "loading" && (t("kyc.submitting") || "Submitting form...")} - {submitStatus === "success" && (t("kyc.submitSuccess") || "Form submitted successfully")} - {submitStatus === "error" && (t("kyc.submitError") || "Form submission failed")} + {isPending && kycState.submitted && (t("kyc.confirming") || "Confirming submission...")} + {!isPending && kycState.submitted && (t("kyc.submitSuccess") || "Form submitted successfully")}
diff --git a/frontend/src/components/NetworkStatusIndicator.tsx b/frontend/src/components/NetworkStatusIndicator.tsx index 931a39b..2e29be3 100644 --- a/frontend/src/components/NetworkStatusIndicator.tsx +++ b/frontend/src/components/NetworkStatusIndicator.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useRef, useState, useCallback } from "react"; +import React, { useState, useEffect } from "react"; import { motion, AnimatePresence, type Variants, useAnimation } from "framer-motion"; import { useTranslations } from "next-intl"; import { useNetworkStatusStore } from "@/lib/network-status-store"; @@ -20,14 +20,11 @@ import { getStatusDotVariant, useReducedMotion, getAdaptiveTransition, + statusChangeFlashVariants, + offlineAlertVariants, + monitoringPulseVariants, } from "@/lib/network-animations"; -import { - useScreenReader, - useFocusManagement, - ScreenReaderManager, - AriaManager, - KeyboardManager, -} from "@/lib/accessibility-utils"; +import { useNetworkMonitor } from "@/hooks/useNetworkMonitor"; /** * Props for NetworkStatusIndicator component @@ -44,20 +41,6 @@ interface NetworkStatusIndicatorProps { announcementsEnabled?: boolean; } -/** - * Connection type detector - */ -const getConnectionType = (): string => { - if (typeof navigator === "undefined") return "unknown"; - - const connection = - (navigator as any).connection || - (navigator as any).mozConnection || - (navigator as any).webkitConnection; - - return connection?.effectiveType || "unknown"; -}; - /** * Status color mapper */ @@ -114,7 +97,7 @@ export const NetworkStatusIndicator: React.FC< > = ({ showDetails = true, autoCheck = true, - checkInterval = 30000, // 30 seconds + checkInterval = 30000, onStatusChange, showConnectionQuality = true, enableMicroInteractions = true, @@ -123,210 +106,32 @@ export const NetworkStatusIndicator: React.FC< announcementsEnabled = true, }) => { const t = useTranslations(); - const intervalRef = useRef(null); const [isHovered, setIsHovered] = useState(false); const [isFocused, setIsFocused] = useState(false); const controls = useAnimation(); const reducedMotion = useReducedMotion(); - - // Accessibility hooks - const { - announce, - announceStatusChange, - announceLatency, - announceConnectionType, - announceError, - announceQuality, - } = useScreenReader(); - - const { - saveFocus, - restoreFocus, - setFocus, - getCurrentFocus, - } = useFocusManagement(); - - // Refs for accessibility - const statusRegionRef = useRef(null); - const detailsRegionRef = useRef(null); - const refreshButtonRef = useRef(null); - const previousStatusRef = useRef("checking"); - - // Zustand store - const { - status, - latency, - connectionType, - errorMessage, - setStatus, - setConnectionType, - setIsMonitoring, - checkStatus, - } = useNetworkStatusStore(); - - /** - * Initialize monitoring - */ - useEffect(() => { - if (!autoCheck) return; - - // Initial check - checkStatus(); - setIsMonitoring(true); - - // Detect connection type - const type = getConnectionType(); - setConnectionType(type); - // Set up periodic checks - intervalRef.current = setInterval(() => { - checkStatus(); - }, checkInterval); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - setIsMonitoring(false); - }; - }, [autoCheck, checkInterval, checkStatus, setIsMonitoring, setConnectionType]); - - /** - * Handle status changes with screen reader announcements - */ - useEffect(() => { - onStatusChange?.(status); - - // Screen reader announcements for status changes - if (enableScreenReaderSupport && announcementsEnabled && status !== previousStatusRef.current) { - const previousStatus = previousStatusRef.current; - previousStatusRef.current = status; - - // Announce status change - let details = ""; - if (latency !== null) { - details = `Latency is ${latency} milliseconds`; - } - if (connectionType && connectionType !== "unknown") { - details += details ? `, connection type is ${connectionType}` : `Connection type is ${connectionType}`; - } - - announceStatusChange(previousStatus, status, details); - - // Announce connection quality if applicable - if (showConnectionQuality && latency !== null) { - let quality = "poor"; - if (latency < 50) quality = "excellent"; - else if (latency < 150) quality = "good"; - else if (latency < 300) quality = "fair"; - - announceQuality(quality, latency); - } - } - }, [status, onStatusChange, enableScreenReaderSupport, announcementsEnabled, latency, connectionType, showConnectionQuality, announceStatusChange, announceQuality]); - - /** - * Announce latency changes - */ - useEffect(() => { - if (enableScreenReaderSupport && announcementsEnabled && latency !== null) { - announceLatency(latency); - } - }, [latency, enableScreenReaderSupport, announcementsEnabled, announceLatency]); - - /** - * Announce connection type changes - */ - useEffect(() => { - if (enableScreenReaderSupport && announcementsEnabled && connectionType && connectionType !== "unknown") { - announceConnectionType(connectionType); - } - }, [connectionType, enableScreenReaderSupport, announcementsEnabled, announceConnectionType]); - - /** - * Announce errors - */ - useEffect(() => { - if (enableScreenReaderSupport && announcementsEnabled && errorMessage) { - announceError(errorMessage); - } - }, [errorMessage, enableScreenReaderSupport, announcementsEnabled, announceError]); - - /** - * Handle online/offline events with screen reader announcements - */ - useEffect(() => { - const handleOnline = () => { - setStatus("online"); - if (enableScreenReaderSupport && announcementsEnabled) { - announce("Network connection restored", "assertive"); - } - }; - - const handleOffline = () => { - setStatus("offline"); - if (enableScreenReaderSupport && announcementsEnabled) { - announce("Network connection lost", "assertive"); - } - }; + const { statusRegionRef, detailsRegionRef, refreshButtonRef, handleRefresh } = + useNetworkMonitor({ + autoCheck, + checkInterval, + onStatusChange, + showConnectionQuality, + enableScreenReaderSupport, + enableKeyboardNavigation, + announcementsEnabled, + }); - window.addEventListener("online", handleOnline); - window.addEventListener("offline", handleOffline); - - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", handleOffline); - }; - }, [setStatus, enableScreenReaderSupport, announcementsEnabled, announce]); - - /** - * Setup keyboard navigation - */ - useEffect(() => { - if (!enableKeyboardNavigation) return; - - const handleKeyDown = (event: KeyboardEvent) => { - // Handle keyboard shortcuts for network status - if (event.ctrlKey && event.key === "r") { - event.preventDefault(); - handleRefresh(); - } - - if (event.key === "Escape" && getCurrentFocus() === refreshButtonRef.current) { - restoreFocus(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [enableKeyboardNavigation, getCurrentFocus, restoreFocus]); - - /** - * Enhanced refresh handler with accessibility - */ - const handleRefresh = useCallback(() => { - if (status === "checking") return; - - saveFocus(); - - if (enableScreenReaderSupport && announcementsEnabled) { - announce("Checking network status", "polite"); - } - - checkStatus(); - - // Announce completion after a delay - setTimeout(() => { - if (enableScreenReaderSupport && announcementsEnabled) { - const currentStatus = status === "checking" ? "completed" : status; - announce(`Network status check ${currentStatus}`, "polite"); - } - }, 2000); - }, [status, checkStatus, saveFocus, enableScreenReaderSupport, announcementsEnabled, announce]); + const { status, latency, connectionType, errorMessage } = + useNetworkStatusStore(); const colors = getStatusColor(status); const adaptiveTransition = getAdaptiveTransition({ duration: 0.3, ease: [0.16, 1, 0.3, 1] }); + useEffect(() => { + if (!reducedMotion) controls.start("flash"); + }, [status, reducedMotion, controls]); + return ( )} + {/* Status change flash overlay */} + {!reducedMotion && ( + + )} +
@@ -374,7 +195,17 @@ export const NetworkStatusIndicator: React.FC< variants={statusDotVariants} animate={getStatusDotVariant(status)} /> - + + {/* Monitoring active pulse ring */} + {autoCheck && !reducedMotion && ( + + )} + {/* Pulse effect for active states */} {(status === "checking" || status === "slow") && !reducedMotion && ( - - {colors.label} - + + {colors.label} + + {showDetails && latency !== null && ( ; +const mockUseScreenReader = useScreenReader as ReturnType; +const mockUseFocusManagement = useFocusManagement as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseNetworkStatusStore.mockReturnValue(mockStore); + mockUseScreenReader.mockReturnValue({ + announce: mockAnnounce, + announceStatusChange: mockAnnounceStatusChange, + announceLatency: mockAnnounceLatency, + announceConnectionType: mockAnnounceConnectionType, + announceError: mockAnnounceError, + announceQuality: mockAnnounceQuality, + }); + mockUseFocusManagement.mockReturnValue({ + saveFocus: mockSaveFocus, + restoreFocus: mockRestoreFocus, + getCurrentFocus: mockGetCurrentFocus, + }); +}); + +describe("useNetworkMonitor", () => { + describe("Monitoring setup", () => { + it("calls checkStatus and setIsMonitoring on mount when autoCheck is true", () => { + renderHook(() => useNetworkMonitor({ autoCheck: true })); + + expect(mockCheckStatus).toHaveBeenCalledTimes(1); + expect(mockSetIsMonitoring).toHaveBeenCalledWith(true); + }); + + it("does not call checkStatus when autoCheck is false", () => { + renderHook(() => useNetworkMonitor({ autoCheck: false })); + + expect(mockCheckStatus).not.toHaveBeenCalled(); + expect(mockSetIsMonitoring).not.toHaveBeenCalled(); + }); + + it("sets isMonitoring to false on unmount", () => { + const { unmount } = renderHook(() => useNetworkMonitor({ autoCheck: true })); + vi.clearAllMocks(); + unmount(); + + expect(mockSetIsMonitoring).toHaveBeenCalledWith(false); + }); + + it("detects and sets connection type on mount", () => { + renderHook(() => useNetworkMonitor({ autoCheck: true })); + + expect(mockSetConnectionType).toHaveBeenCalledTimes(1); + }); + }); + + describe("Interval management", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("sets up interval for periodic checks", async () => { + renderHook(() => useNetworkMonitor({ autoCheck: true, checkInterval: 5000 })); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(mockCheckStatus).toHaveBeenCalledTimes(1); + }); + + it("fires multiple intervals", async () => { + renderHook(() => useNetworkMonitor({ autoCheck: true, checkInterval: 5000 })); + vi.clearAllMocks(); + + await vi.advanceTimersByTimeAsync(15000); + + expect(mockCheckStatus).toHaveBeenCalledTimes(3); + }); + + it("clears interval on unmount", async () => { + const { unmount } = renderHook(() => + useNetworkMonitor({ autoCheck: true, checkInterval: 5000 }) + ); + vi.clearAllMocks(); + unmount(); + + await vi.advanceTimersByTimeAsync(10000); + + expect(mockCheckStatus).not.toHaveBeenCalled(); + }); + }); + + describe("handleRefresh", () => { + it("calls checkStatus when status is not checking", () => { + const { result } = renderHook(() => useNetworkMonitor({ autoCheck: false })); + + act(() => result.current.handleRefresh()); + + expect(mockCheckStatus).toHaveBeenCalledTimes(1); + }); + + it("does not call checkStatus when status is checking", () => { + mockUseNetworkStatusStore.mockReturnValue({ + ...mockStore, + status: "checking", + }); + + const { result } = renderHook(() => useNetworkMonitor({ autoCheck: false })); + + act(() => result.current.handleRefresh()); + + expect(mockCheckStatus).not.toHaveBeenCalled(); + }); + + it("saves focus before refreshing", () => { + const { result } = renderHook(() => useNetworkMonitor({ autoCheck: false })); + + act(() => result.current.handleRefresh()); + + expect(mockSaveFocus).toHaveBeenCalledTimes(1); + }); + + it("announces check when screen reader support is enabled", () => { + const { result } = renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: true, + announcementsEnabled: true, + }) + ); + + act(() => result.current.handleRefresh()); + + expect(mockAnnounce).toHaveBeenCalledWith( + "Checking network status", + "polite" + ); + }); + + it("does not announce when screen reader support is disabled", () => { + const { result } = renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: false, + }) + ); + + act(() => result.current.handleRefresh()); + + expect(mockAnnounce).not.toHaveBeenCalled(); + }); + + it("does not announce when announcements are disabled", () => { + const { result } = renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: true, + announcementsEnabled: false, + }) + ); + + act(() => result.current.handleRefresh()); + + expect(mockAnnounce).not.toHaveBeenCalled(); + }); + }); + + describe("Online/offline events", () => { + it("sets status to online when online event fires", () => { + renderHook(() => useNetworkMonitor({ autoCheck: false })); + + act(() => window.dispatchEvent(new Event("online"))); + + expect(mockSetStatus).toHaveBeenCalledWith("online"); + }); + + it("sets status to offline when offline event fires", () => { + renderHook(() => useNetworkMonitor({ autoCheck: false })); + + act(() => window.dispatchEvent(new Event("offline"))); + + expect(mockSetStatus).toHaveBeenCalledWith("offline"); + }); + + it("announces connection restored on online event", () => { + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: true, + announcementsEnabled: true, + }) + ); + + act(() => window.dispatchEvent(new Event("online"))); + + expect(mockAnnounce).toHaveBeenCalledWith( + "Network connection restored", + "assertive" + ); + }); + + it("announces connection lost on offline event", () => { + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: true, + announcementsEnabled: true, + }) + ); + + act(() => window.dispatchEvent(new Event("offline"))); + + expect(mockAnnounce).toHaveBeenCalledWith( + "Network connection lost", + "assertive" + ); + }); + + it("does not announce browser events when screen reader support is disabled", () => { + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: false, + }) + ); + + act(() => { + window.dispatchEvent(new Event("online")); + window.dispatchEvent(new Event("offline")); + }); + + expect(mockAnnounce).not.toHaveBeenCalled(); + }); + + it("removes event listeners on unmount", () => { + const { unmount } = renderHook(() => + useNetworkMonitor({ autoCheck: false }) + ); + unmount(); + + act(() => window.dispatchEvent(new Event("online"))); + + expect(mockSetStatus).not.toHaveBeenCalled(); + }); + }); + + describe("Status change callbacks", () => { + it("calls onStatusChange with current status on mount", () => { + const onStatusChange = vi.fn(); + + renderHook(() => + useNetworkMonitor({ autoCheck: false, onStatusChange }) + ); + + expect(onStatusChange).toHaveBeenCalledWith("online"); + }); + + it("does not throw when onStatusChange is not provided", () => { + expect(() => { + renderHook(() => useNetworkMonitor({ autoCheck: false })); + }).not.toThrow(); + }); + }); + + describe("Error announcements", () => { + it("announces error message when present", () => { + mockUseNetworkStatusStore.mockReturnValue({ + ...mockStore, + errorMessage: "Connection timeout", + }); + + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: true, + announcementsEnabled: true, + }) + ); + + expect(mockAnnounceError).toHaveBeenCalledWith("Connection timeout"); + }); + + it("does not announce error when screen reader support is disabled", () => { + mockUseNetworkStatusStore.mockReturnValue({ + ...mockStore, + errorMessage: "Connection timeout", + }); + + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableScreenReaderSupport: false, + }) + ); + + expect(mockAnnounceError).not.toHaveBeenCalled(); + }); + }); + + describe("Return values", () => { + it("returns DOM refs", () => { + const { result } = renderHook(() => + useNetworkMonitor({ autoCheck: false }) + ); + + expect(result.current.statusRegionRef).toBeDefined(); + expect(result.current.detailsRegionRef).toBeDefined(); + expect(result.current.refreshButtonRef).toBeDefined(); + }); + + it("returns handleRefresh function", () => { + const { result } = renderHook(() => + useNetworkMonitor({ autoCheck: false }) + ); + + expect(typeof result.current.handleRefresh).toBe("function"); + }); + }); + + describe("Keyboard navigation", () => { + it("triggers refresh on Ctrl+R when keyboard navigation is enabled", () => { + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableKeyboardNavigation: true, + }) + ); + vi.clearAllMocks(); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "r", + ctrlKey: true, + bubbles: true, + }) + ); + }); + + expect(mockCheckStatus).toHaveBeenCalledTimes(1); + }); + + it("does not add keyboard listener when keyboard navigation is disabled", () => { + renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableKeyboardNavigation: false, + }) + ); + vi.clearAllMocks(); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "r", + ctrlKey: true, + bubbles: true, + }) + ); + }); + + expect(mockCheckStatus).not.toHaveBeenCalled(); + }); + + it("removes keyboard listener on unmount", () => { + const { unmount } = renderHook(() => + useNetworkMonitor({ + autoCheck: false, + enableKeyboardNavigation: true, + }) + ); + vi.clearAllMocks(); + unmount(); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "r", + ctrlKey: true, + bubbles: true, + }) + ); + }); + + expect(mockCheckStatus).not.toHaveBeenCalled(); + }); + }); + + describe("Default options", () => { + it("uses default options when none are provided", () => { + renderHook(() => useNetworkMonitor()); + + expect(mockCheckStatus).toHaveBeenCalledTimes(1); + expect(mockSetIsMonitoring).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/frontend/src/hooks/useNetworkMonitor.ts b/frontend/src/hooks/useNetworkMonitor.ts new file mode 100644 index 0000000..a12ae6f --- /dev/null +++ b/frontend/src/hooks/useNetworkMonitor.ts @@ -0,0 +1,224 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { useNetworkStatusStore } from "@/lib/network-status-store"; +import { + useScreenReader, + useFocusManagement, +} from "@/lib/accessibility-utils"; + +const getConnectionType = (): string => { + if (typeof navigator === "undefined") return "unknown"; + const connection = + (navigator as any).connection || + (navigator as any).mozConnection || + (navigator as any).webkitConnection; + return connection?.effectiveType || "unknown"; +}; + +export interface UseNetworkMonitorOptions { + autoCheck?: boolean; + checkInterval?: number; + onStatusChange?: (status: string) => void; + showConnectionQuality?: boolean; + enableScreenReaderSupport?: boolean; + enableKeyboardNavigation?: boolean; + announcementsEnabled?: boolean; +} + +export interface UseNetworkMonitorReturn { + statusRegionRef: React.RefObject; + detailsRegionRef: React.RefObject; + refreshButtonRef: React.RefObject; + handleRefresh: () => void; +} + +export function useNetworkMonitor({ + autoCheck = true, + checkInterval = 30000, + onStatusChange, + showConnectionQuality = true, + enableScreenReaderSupport = true, + enableKeyboardNavigation = true, + announcementsEnabled = true, +}: UseNetworkMonitorOptions = {}): UseNetworkMonitorReturn { + const intervalRef = useRef(null); + const statusRegionRef = useRef(null); + const detailsRegionRef = useRef(null); + const refreshButtonRef = useRef(null); + const previousStatusRef = useRef("checking"); + + const { + status, + latency, + connectionType, + errorMessage, + setStatus, + setConnectionType, + setIsMonitoring, + checkStatus, + } = useNetworkStatusStore(); + + const { + announce, + announceStatusChange, + announceLatency, + announceConnectionType, + announceError, + announceQuality, + } = useScreenReader(); + + const { saveFocus, restoreFocus, getCurrentFocus } = useFocusManagement(); + + useEffect(() => { + if (!autoCheck) return; + checkStatus(); + setIsMonitoring(true); + setConnectionType(getConnectionType()); + intervalRef.current = setInterval(() => checkStatus(), checkInterval); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + setIsMonitoring(false); + }; + }, [autoCheck, checkInterval, checkStatus, setIsMonitoring, setConnectionType]); + + useEffect(() => { + onStatusChange?.(status); + if ( + enableScreenReaderSupport && + announcementsEnabled && + status !== previousStatusRef.current + ) { + const previousStatus = previousStatusRef.current; + previousStatusRef.current = status; + + let details = ""; + if (latency !== null) details = `Latency is ${latency} milliseconds`; + if (connectionType && connectionType !== "unknown") { + details += details + ? `, connection type is ${connectionType}` + : `Connection type is ${connectionType}`; + } + announceStatusChange(previousStatus, status, details); + + if (showConnectionQuality && latency !== null) { + let quality = "poor"; + if (latency < 50) quality = "excellent"; + else if (latency < 150) quality = "good"; + else if (latency < 300) quality = "fair"; + announceQuality(quality, latency); + } + } + }, [ + status, + onStatusChange, + enableScreenReaderSupport, + announcementsEnabled, + latency, + connectionType, + showConnectionQuality, + announceStatusChange, + announceQuality, + ]); + + useEffect(() => { + if (enableScreenReaderSupport && announcementsEnabled && latency !== null) { + announceLatency(latency); + } + }, [latency, enableScreenReaderSupport, announcementsEnabled, announceLatency]); + + useEffect(() => { + if ( + enableScreenReaderSupport && + announcementsEnabled && + connectionType && + connectionType !== "unknown" + ) { + announceConnectionType(connectionType); + } + }, [ + connectionType, + enableScreenReaderSupport, + announcementsEnabled, + announceConnectionType, + ]); + + useEffect(() => { + if (enableScreenReaderSupport && announcementsEnabled && errorMessage) { + announceError(errorMessage); + } + }, [errorMessage, enableScreenReaderSupport, announcementsEnabled, announceError]); + + useEffect(() => { + const handleOnline = () => { + setStatus("online"); + if (enableScreenReaderSupport && announcementsEnabled) { + announce("Network connection restored", "assertive"); + } + }; + const handleOffline = () => { + setStatus("offline"); + if (enableScreenReaderSupport && announcementsEnabled) { + announce("Network connection lost", "assertive"); + } + }; + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [setStatus, enableScreenReaderSupport, announcementsEnabled, announce]); + + const handleRefresh = useCallback(() => { + if (status === "checking") return; + saveFocus(); + if (enableScreenReaderSupport && announcementsEnabled) { + announce("Checking network status", "polite"); + } + checkStatus(); + setTimeout(() => { + if (enableScreenReaderSupport && announcementsEnabled) { + const currentStatus = status === "checking" ? "completed" : status; + announce(`Network status check ${currentStatus}`, "polite"); + } + }, 2000); + }, [ + status, + checkStatus, + saveFocus, + enableScreenReaderSupport, + announcementsEnabled, + announce, + ]); + + useEffect(() => { + if (!enableKeyboardNavigation) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey && event.key === "r") { + event.preventDefault(); + handleRefresh(); + } + if ( + event.key === "Escape" && + getCurrentFocus() === refreshButtonRef.current + ) { + restoreFocus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [ + enableKeyboardNavigation, + getCurrentFocus, + restoreFocus, + handleRefresh, + ]); + + return { + statusRegionRef, + detailsRegionRef, + refreshButtonRef, + handleRefresh, + }; +} diff --git a/frontend/src/lib/network-animations.ts b/frontend/src/lib/network-animations.ts index a88529f..662f0c8 100644 --- a/frontend/src/lib/network-animations.ts +++ b/frontend/src/lib/network-animations.ts @@ -186,18 +186,22 @@ export const connectionQualityVariants: Variants = { excellent: { backgroundColor: "rgb(34, 197, 94)", width: "100%", + transition: { type: "spring", stiffness: 120, damping: 20 }, }, good: { backgroundColor: "rgb(134, 239, 172)", width: "75%", + transition: { type: "spring", stiffness: 120, damping: 20 }, }, fair: { backgroundColor: "rgb(250, 204, 21)", width: "50%", + transition: { type: "spring", stiffness: 100, damping: 18 }, }, poor: { backgroundColor: "rgb(239, 68, 68)", width: "25%", + transition: { type: "spring", stiffness: 80, damping: 15 }, }, }; @@ -354,6 +358,68 @@ export const focusRingVariants: Variants = { }, }; +// Status change flash — brief highlight when status transitions +export const statusChangeFlashVariants: Variants = { + idle: { opacity: 0, scale: 1 }, + flash: { + opacity: [0, 0.4, 0], + scale: [1, 1.08, 1], + transition: { + duration: 0.5, + ease: "easeOut", + }, + }, +}; + +// Offline alert — draws attention with a gentle shake +export const offlineAlertVariants: Variants = { + idle: { x: 0 }, + shake: { + x: [0, -6, 6, -4, 4, -2, 2, 0], + transition: { + duration: 0.5, + ease: "easeInOut", + }, + }, +}; + +// Monitoring pulse — indicates active background monitoring +export const monitoringPulseVariants: Variants = { + active: { + scale: [1, 1.4, 1], + opacity: [0.6, 0, 0.6], + transition: { + duration: 2.5, + repeat: Infinity, + ease: "easeOut", + }, + }, + inactive: { + scale: 1, + opacity: 0, + transition: { duration: 0.3 }, + }, +}; + +// Latency bar — fills proportionally with spring physics +export const latencyBarVariants: Variants = { + good: { + scaleX: 0.25, + backgroundColor: "rgb(34, 197, 94)", + transition: { type: "spring", stiffness: 120, damping: 20 }, + }, + warning: { + scaleX: 0.55, + backgroundColor: "rgb(250, 204, 21)", + transition: { type: "spring", stiffness: 100, damping: 18 }, + }, + bad: { + scaleX: 1, + backgroundColor: "rgb(239, 68, 68)", + transition: { type: "spring", stiffness: 80, damping: 15 }, + }, +}; + // Animation utility functions export const getLatencyVariant = (latency: number | null): keyof typeof latencyVariants => { if (latency === null) return "good";