diff --git a/src/app/components/home/HomeContent.tsx b/src/app/components/home/HomeContent.tsx index d5e198bb..000bb1f0 100644 --- a/src/app/components/home/HomeContent.tsx +++ b/src/app/components/home/HomeContent.tsx @@ -88,6 +88,7 @@ export default function HomeContent() { author="Sarah Johnson" progress={68} timeRemaining="12h remaining" + courseHref="/courses/web3-ux-design" imageUrl="https://thumbs.dreamstime.com/b/matrix-style-digital-rain-green-binary-code-falling-downward-direction-abstract-background-depicting-effect-stream-397887374.jpg" /> @@ -97,6 +98,7 @@ export default function HomeContent() { author="Michael Chen" progress={45} timeRemaining="12h remaining" + courseHref="/courses/smart-contract-security" imageUrl="https://static.vecteezy.com/system/resources/previews/053/715/379/non_2x/abstract-green-digital-rain-with-matrix-code-in-futuristic-cyber-background-perfect-for-technology-and-data-themed-visuals-png.png" /> @@ -106,6 +108,7 @@ export default function HomeContent() { author="Alex Rivera" progress={12} timeRemaining="12h remaining" + courseHref="/courses/scaling-dapps-starknet" imageUrl="https://thumbs.dreamstime.com/b/futuristic-laptop-glowing-digital-waves-emerging-screen-dark-setting-399809314.jpg" /> diff --git a/src/app/tooltip-demo/page.tsx b/src/app/tooltip-demo/page.tsx new file mode 100644 index 00000000..d5fac79a --- /dev/null +++ b/src/app/tooltip-demo/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +/** + * Tooltip System Demo Page + * Route: /tooltip-demo + * + * Demonstrates the Tooltip component with all placements and the + * useTooltipAnomalyDetection hook in action. + */ + +import React from 'react'; +import { Tooltip, TooltipPlacement } from '@/components/ui/Tooltip'; +import { useTooltipAnomalyDetection } from '@/hooks/useTooltipAnomalyDetection'; + +const PLACEMENTS: TooltipPlacement[] = ['top', 'bottom', 'left', 'right']; + +export default function TooltipDemoPage() { + const { onOpen, onClose, anomalies, clearAnomalies } = useTooltipAnomalyDetection({ + rapidToggleThreshold: 5, + rapidToggleWindowMs: 3000, + longHoverThresholdMs: 10000, + multiOpenThreshold: 3, + onAnomaly: (e) => console.warn('[TooltipAnomaly]', e), + }); + + return ( +
+

+ Tooltip System Demo +

+

+ Hover or focus the buttons below to see tooltips. Anomaly detection is active — rapidly + toggling a tooltip or keeping it open for >10 s will log an anomaly. +

+ + {/* Placement showcase */} +
+

+ Placements +

+
+ {PLACEMENTS.map((placement) => ( + onOpen(`placement-${placement}-${type}`)} + > + + + ))} +
+
+ + {/* Rich content tooltip */} +
+

+ Rich Content +

+ + Tip: This tooltip supports{' '} + rich React content. + + } + placement="right" + delayMs={100} + onAnomaly={(type) => onOpen(`rich-${type}`)} + > + + +
+ + {/* Disabled tooltip */} +
+

+ Disabled State +

+ + + +
+ + {/* Anomaly log */} +
+
+

+ Anomaly Log +

+ {anomalies.length > 0 && ( + + )} +
+ + {anomalies.length === 0 ? ( +

+ No anomalies detected yet. Try rapidly toggling a tooltip! +

+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index bc5bbf7d..7cf3a05e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -19,3 +19,5 @@ export { ShareModal } from './ShareModal'; export * from './ui/Table'; export { BulkImporter } from './BulkImporter'; export type { BulkImporterProps, TargetFieldDef } from './BulkImporter'; +export { Tooltip } from './ui/Tooltip'; +export type { TooltipProps, TooltipPlacement } from './ui/Tooltip'; diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx new file mode 100644 index 00000000..108bd358 --- /dev/null +++ b/src/components/ui/Tooltip.tsx @@ -0,0 +1,131 @@ +'use client'; + +/** + * Tooltip Component + * Accessible, reusable tooltip with anomaly detection integration. + * Follows WAI-ARIA tooltip pattern (role="tooltip", aria-describedby). + */ + +import React, { useState, useRef, useId, useCallback } from 'react'; + +export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'; + +export interface TooltipProps { + /** The content shown inside the tooltip */ + content: React.ReactNode; + /** The element that triggers the tooltip */ + children: React.ReactElement; + /** Placement relative to the trigger */ + placement?: TooltipPlacement; + /** Delay in ms before showing (default 200) */ + delayMs?: number; + /** Whether the tooltip is disabled */ + disabled?: boolean; + /** Optional extra class for the tooltip bubble */ + className?: string; + /** Called when an anomaly is detected (e.g. rapid open/close) */ + onAnomaly?: (type: string) => void; +} + +const PLACEMENT_CLASSES: Record = { + top: 'bottom-full left-1/2 -translate-x-1/2 mb-2', + bottom: 'top-full left-1/2 -translate-x-1/2 mt-2', + left: 'right-full top-1/2 -translate-y-1/2 mr-2', + right: 'left-full top-1/2 -translate-y-1/2 ml-2', +}; + +export const Tooltip: React.FC = ({ + content, + children, + placement = 'top', + delayMs = 200, + disabled = false, + className = '', + onAnomaly, +}) => { + const [visible, setVisible] = useState(false); + const tooltipId = useId(); + const timerRef = useRef | null>(null); + const openCountRef = useRef(0); + const windowStartRef = useRef(Date.now()); + + const clearTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + + /** Anomaly detection: flag if tooltip opens >5 times in 3 seconds */ + const trackOpen = useCallback(() => { + const now = Date.now(); + if (now - windowStartRef.current > 3000) { + openCountRef.current = 0; + windowStartRef.current = now; + } + openCountRef.current += 1; + if (openCountRef.current > 5) { + onAnomaly?.('rapid-toggle'); + openCountRef.current = 0; + windowStartRef.current = now; + } + }, [onAnomaly]); + + const show = useCallback(() => { + if (disabled) return; + clearTimer(); + timerRef.current = setTimeout(() => { + setVisible(true); + trackOpen(); + }, delayMs); + }, [disabled, delayMs, trackOpen]); + + const hide = useCallback(() => { + clearTimer(); + setVisible(false); + }, []); + + const child = React.Children.only(children); + + return ( + + {React.cloneElement(child, { + 'aria-describedby': visible ? tooltipId : undefined, + onMouseEnter: (e: React.MouseEvent) => { + show(); + child.props.onMouseEnter?.(e); + }, + onMouseLeave: (e: React.MouseEvent) => { + hide(); + child.props.onMouseLeave?.(e); + }, + onFocus: (e: React.FocusEvent) => { + show(); + child.props.onFocus?.(e); + }, + onBlur: (e: React.FocusEvent) => { + hide(); + child.props.onBlur?.(e); + }, + })} + + {visible && ( + + {content} + + )} + + ); +}; + +export default Tooltip; diff --git a/src/components/ui/__tests__/Tooltip.test.tsx b/src/components/ui/__tests__/Tooltip.test.tsx new file mode 100644 index 00000000..25fc1be0 --- /dev/null +++ b/src/components/ui/__tests__/Tooltip.test.tsx @@ -0,0 +1,181 @@ +/** + * Unit tests for Tooltip component and useTooltipAnomalyDetection hook + */ + +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Tooltip } from '../Tooltip'; +import { useTooltipAnomalyDetection } from '../../../hooks/useTooltipAnomalyDetection'; +import { renderHook } from '@testing-library/react'; + +// --------------------------------------------------------------------------- +// Tooltip component tests +// --------------------------------------------------------------------------- + +describe('Tooltip', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('renders children without tooltip initially', () => { + render( + + + + ); + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + it('shows tooltip after delay on mouseenter', () => { + render( + + + + ); + fireEvent.mouseEnter(screen.getByRole('button')); + expect(screen.queryByRole('tooltip')).toBeNull(); + act(() => vi.advanceTimersByTime(200)); + expect(screen.getByRole('tooltip')).toHaveTextContent('Hello'); + }); + + it('hides tooltip on mouseleave', () => { + render( + + + + ); + fireEvent.mouseEnter(screen.getByRole('button')); + act(() => vi.advanceTimersByTime(0)); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + fireEvent.mouseLeave(screen.getByRole('button')); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + it('shows tooltip on focus and hides on blur', () => { + render( + + + + ); + fireEvent.focus(screen.getByRole('button')); + act(() => vi.advanceTimersByTime(0)); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + fireEvent.blur(screen.getByRole('button')); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + it('does not show tooltip when disabled', () => { + render( + + + + ); + fireEvent.mouseEnter(screen.getByRole('button')); + act(() => vi.advanceTimersByTime(0)); + expect(screen.queryByRole('tooltip')).toBeNull(); + }); + + it('sets aria-describedby on trigger when visible', () => { + render( + + + + ); + fireEvent.mouseEnter(screen.getByRole('button')); + act(() => vi.advanceTimersByTime(0)); + const btn = screen.getByRole('button'); + const tooltip = screen.getByRole('tooltip'); + expect(btn.getAttribute('aria-describedby')).toBe(tooltip.id); + }); + + it('calls onAnomaly after rapid toggles', () => { + const onAnomaly = vi.fn(); + render( + + + + ); + const btn = screen.getByRole('button'); + // Open 6 times quickly + for (let i = 0; i < 6; i++) { + fireEvent.mouseEnter(btn); + act(() => vi.advanceTimersByTime(0)); + fireEvent.mouseLeave(btn); + } + expect(onAnomaly).toHaveBeenCalledWith('rapid-toggle'); + }); +}); + +// --------------------------------------------------------------------------- +// useTooltipAnomalyDetection hook tests +// --------------------------------------------------------------------------- + +describe('useTooltipAnomalyDetection', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('starts with no anomalies', () => { + const { result } = renderHook(() => useTooltipAnomalyDetection()); + expect(result.current.anomalies).toHaveLength(0); + }); + + it('detects rapid-toggle anomaly', () => { + const onAnomaly = vi.fn(); + const { result } = renderHook(() => + useTooltipAnomalyDetection({ rapidToggleThreshold: 3, rapidToggleWindowMs: 3000, onAnomaly }) + ); + act(() => { + for (let i = 0; i < 4; i++) result.current.onOpen('tip1'); + }); + expect(onAnomaly).toHaveBeenCalledWith(expect.objectContaining({ type: 'rapid-toggle', tooltipId: 'tip1' })); + }); + + it('detects long-hover anomaly', () => { + const onAnomaly = vi.fn(); + const { result } = renderHook(() => + useTooltipAnomalyDetection({ longHoverThresholdMs: 5000, onAnomaly }) + ); + act(() => result.current.onOpen('tip2')); + act(() => vi.advanceTimersByTime(5000)); + expect(onAnomaly).toHaveBeenCalledWith(expect.objectContaining({ type: 'long-hover', tooltipId: 'tip2' })); + }); + + it('cancels long-hover timer on close', () => { + const onAnomaly = vi.fn(); + const { result } = renderHook(() => + useTooltipAnomalyDetection({ longHoverThresholdMs: 5000, onAnomaly }) + ); + act(() => result.current.onOpen('tip3')); + act(() => result.current.onClose('tip3')); + act(() => vi.advanceTimersByTime(5000)); + expect(onAnomaly).not.toHaveBeenCalled(); + }); + + it('detects multi-open anomaly', () => { + const onAnomaly = vi.fn(); + const { result } = renderHook(() => + useTooltipAnomalyDetection({ multiOpenThreshold: 2, onAnomaly }) + ); + act(() => { + result.current.onOpen('a'); + result.current.onOpen('b'); + result.current.onOpen('c'); // exceeds threshold of 2 + }); + expect(onAnomaly).toHaveBeenCalledWith(expect.objectContaining({ type: 'multi-open' })); + }); + + it('clearAnomalies resets the log', () => { + const { result } = renderHook(() => + useTooltipAnomalyDetection({ rapidToggleThreshold: 2, rapidToggleWindowMs: 3000 }) + ); + act(() => { + result.current.onOpen('x'); + result.current.onOpen('x'); + result.current.onOpen('x'); + }); + expect(result.current.anomalies.length).toBeGreaterThan(0); + act(() => result.current.clearAnomalies()); + expect(result.current.anomalies).toHaveLength(0); + }); +}); diff --git a/src/hooks/useTooltipAnomalyDetection.ts b/src/hooks/useTooltipAnomalyDetection.ts new file mode 100644 index 00000000..ac4c52ca --- /dev/null +++ b/src/hooks/useTooltipAnomalyDetection.ts @@ -0,0 +1,140 @@ +/** + * useTooltipAnomalyDetection + * + * Detects anomalous tooltip interaction patterns and reports them. + * + * Anomalies detected: + * - "rapid-toggle" : tooltip opened >5 times within 3 seconds + * - "long-hover" : tooltip held open for >10 seconds continuously + * - "multi-open" : more than 3 distinct tooltips open simultaneously + */ + +import { useCallback, useRef, useState } from 'react'; + +export interface AnomalyEvent { + type: 'rapid-toggle' | 'long-hover' | 'multi-open'; + tooltipId: string; + timestamp: number; + detail?: string; +} + +export interface UseTooltipAnomalyDetectionOptions { + /** Max opens per window before "rapid-toggle" fires (default 5) */ + rapidToggleThreshold?: number; + /** Window size in ms for rapid-toggle detection (default 3000) */ + rapidToggleWindowMs?: number; + /** Max continuous open duration in ms before "long-hover" fires (default 10000) */ + longHoverThresholdMs?: number; + /** Max simultaneous open tooltips before "multi-open" fires (default 3) */ + multiOpenThreshold?: number; + /** Called whenever an anomaly is detected */ + onAnomaly?: (event: AnomalyEvent) => void; +} + +export interface TooltipTracker { + /** Call when a tooltip opens */ + onOpen: (tooltipId: string) => void; + /** Call when a tooltip closes */ + onClose: (tooltipId: string) => void; + /** Current list of detected anomalies */ + anomalies: AnomalyEvent[]; + /** Clear the anomaly log */ + clearAnomalies: () => void; +} + +export function useTooltipAnomalyDetection( + options: UseTooltipAnomalyDetectionOptions = {} +): TooltipTracker { + const { + rapidToggleThreshold = 5, + rapidToggleWindowMs = 3000, + longHoverThresholdMs = 10000, + multiOpenThreshold = 3, + onAnomaly, + } = options; + + const [anomalies, setAnomalies] = useState([]); + + // Per-tooltip open counts within the current time window + const openCountsRef = useRef>(new Map()); + // Per-tooltip long-hover timers + const longHoverTimersRef = useRef>>(new Map()); + // Currently open tooltip IDs + const openSetRef = useRef>(new Set()); + + const report = useCallback( + (event: AnomalyEvent) => { + setAnomalies((prev) => [...prev, event]); + onAnomaly?.(event); + }, + [onAnomaly] + ); + + const onOpen = useCallback( + (tooltipId: string) => { + const now = Date.now(); + + // --- rapid-toggle detection --- + const entry = openCountsRef.current.get(tooltipId) ?? { count: 0, windowStart: now }; + if (now - entry.windowStart > rapidToggleWindowMs) { + entry.count = 0; + entry.windowStart = now; + } + entry.count += 1; + openCountsRef.current.set(tooltipId, entry); + + if (entry.count > rapidToggleThreshold) { + report({ + type: 'rapid-toggle', + tooltipId, + timestamp: now, + detail: `Opened ${entry.count} times in ${rapidToggleWindowMs}ms`, + }); + entry.count = 0; + entry.windowStart = now; + } + + // --- long-hover detection --- + const existingTimer = longHoverTimersRef.current.get(tooltipId); + if (existingTimer) clearTimeout(existingTimer); + const timer = setTimeout(() => { + report({ + type: 'long-hover', + tooltipId, + timestamp: Date.now(), + detail: `Open for >${longHoverThresholdMs}ms`, + }); + longHoverTimersRef.current.delete(tooltipId); + }, longHoverThresholdMs); + longHoverTimersRef.current.set(tooltipId, timer); + + // --- multi-open detection --- + openSetRef.current.add(tooltipId); + if (openSetRef.current.size > multiOpenThreshold) { + report({ + type: 'multi-open', + tooltipId, + timestamp: now, + detail: `${openSetRef.current.size} tooltips open simultaneously`, + }); + } + }, + [rapidToggleThreshold, rapidToggleWindowMs, longHoverThresholdMs, multiOpenThreshold, report] + ); + + const onClose = useCallback((tooltipId: string) => { + // Cancel long-hover timer + const timer = longHoverTimersRef.current.get(tooltipId); + if (timer) { + clearTimeout(timer); + longHoverTimersRef.current.delete(tooltipId); + } + openSetRef.current.delete(tooltipId); + }, []); + + const clearAnomalies = useCallback(() => setAnomalies([]), []); + + return { onOpen, onClose, anomalies, clearAnomalies }; +} + +export default useTooltipAnomalyDetection; diff --git a/src/lib/api.ts b/src/lib/api.ts index ab53d182..46f21cef 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -209,15 +209,6 @@ class ApiClientImpl { body?: unknown, options?: Omit, ): Promise { - // --------------------------------------------------------------------------- - // METHODS - // --------------------------------------------------------------------------- - - get(url: string, options?: Omit) { - return this.requestWithRetry({ ...options, url, method: 'GET' }); - } - - post(url: string, body?: unknown, options?: Omit) { return this.requestWithRetry({ ...options, url, @@ -226,7 +217,11 @@ class ApiClientImpl { }); } - patch(url: string, body?: unknown, options?: Omit) { + async patch( + url: string, + body?: unknown, + options?: Omit, + ): Promise { return this.requestWithRetry({ ...options, url, @@ -235,7 +230,11 @@ class ApiClientImpl { }); } - put(url: string, body?: unknown, options?: Omit) { + async put( + url: string, + body?: unknown, + options?: Omit, + ): Promise { return this.requestWithRetry({ ...options, url,