Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/app/components/home/HomeContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>

Expand All @@ -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"
/>

Expand All @@ -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"
/>
</div>
Expand Down
149 changes: 149 additions & 0 deletions src/app/tooltip-demo/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-gray-50 dark:bg-gray-900 p-10">
<h1 className="mb-2 text-3xl font-bold text-gray-900 dark:text-white">
Tooltip System Demo
</h1>
<p className="mb-10 text-gray-500 dark:text-gray-400">
Hover or focus the buttons below to see tooltips. Anomaly detection is active — rapidly
toggling a tooltip or keeping it open for &gt;10 s will log an anomaly.
</p>

{/* Placement showcase */}
<section className="mb-12">
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">
Placements
</h2>
<div className="flex flex-wrap items-center gap-10">
{PLACEMENTS.map((placement) => (
<Tooltip
key={placement}
content={`Placement: ${placement}`}
placement={placement}
onAnomaly={(type) => onOpen(`placement-${placement}-${type}`)}
>
<button
className="rounded-lg bg-indigo-600 px-5 py-2 text-sm font-medium text-white shadow hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onFocus={() => onOpen(`placement-${placement}`)}
onBlur={() => onClose(`placement-${placement}`)}
onMouseEnter={() => onOpen(`placement-${placement}`)}
onMouseLeave={() => onClose(`placement-${placement}`)}
>
{placement}
</button>
</Tooltip>
))}
</div>
</section>

{/* Rich content tooltip */}
<section className="mb-12">
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">
Rich Content
</h2>
<Tooltip
content={
<span>
<strong>Tip:</strong> This tooltip supports{' '}
<em>rich React content</em>.
</span>
}
placement="right"
delayMs={100}
onAnomaly={(type) => onOpen(`rich-${type}`)}
>
<button
className="rounded-lg bg-emerald-600 px-5 py-2 text-sm font-medium text-white shadow hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500"
onFocus={() => onOpen('rich')}
onBlur={() => onClose('rich')}
onMouseEnter={() => onOpen('rich')}
onMouseLeave={() => onClose('rich')}
>
Hover for rich tooltip
</button>
</Tooltip>
</section>

{/* Disabled tooltip */}
<section className="mb-12">
<h2 className="mb-6 text-xl font-semibold text-gray-800 dark:text-gray-200">
Disabled State
</h2>
<Tooltip content="You should never see this" disabled>
<button className="rounded-lg bg-gray-400 px-5 py-2 text-sm font-medium text-white shadow cursor-not-allowed">
Disabled tooltip
</button>
</Tooltip>
</section>

{/* Anomaly log */}
<section>
<div className="mb-3 flex items-center gap-4">
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200">
Anomaly Log
</h2>
{anomalies.length > 0 && (
<button
onClick={clearAnomalies}
className="rounded bg-red-100 px-3 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-300"
>
Clear
</button>
)}
</div>

{anomalies.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-gray-500">
No anomalies detected yet. Try rapidly toggling a tooltip!
</p>
) : (
<ul className="space-y-2">
{anomalies.map((a, i) => (
<li
key={i}
className="rounded-lg border border-yellow-300 bg-yellow-50 px-4 py-2 text-sm dark:border-yellow-700 dark:bg-yellow-900/20"
>
<span className="font-semibold text-yellow-800 dark:text-yellow-300">
[{a.type}]
</span>{' '}
<span className="text-gray-700 dark:text-gray-300">
tooltip: <code>{a.tooltipId}</code>
</span>
{a.detail && (
<span className="ml-2 text-gray-500 dark:text-gray-400">— {a.detail}</span>
)}
<span className="ml-2 text-xs text-gray-400">
{new Date(a.timestamp).toLocaleTimeString()}
</span>
</li>
))}
</ul>
)}
</section>
</main>
);
}
2 changes: 2 additions & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
131 changes: 131 additions & 0 deletions src/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<TooltipPlacement, string> = {
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<TooltipProps> = ({
content,
children,
placement = 'top',
delayMs = 200,
disabled = false,
className = '',
onAnomaly,
}) => {
const [visible, setVisible] = useState(false);
const tooltipId = useId();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const openCountRef = useRef(0);
const windowStartRef = useRef<number>(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 (
<span className="relative inline-flex">
{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 && (
<span
id={tooltipId}
role="tooltip"
className={[
'pointer-events-none absolute z-50 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg dark:bg-gray-700',
PLACEMENT_CLASSES[placement],
className,
]
.filter(Boolean)
.join(' ')}
>
{content}
</span>
)}
</span>
);
};

export default Tooltip;
Loading
Loading