refactor(ui): tooltip module, useTooltip, re-export, tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-06 10:32:14 +01:00
parent dfd9ba6220
commit 27fd12fe6c
8 changed files with 441 additions and 431 deletions

View file

@ -44,7 +44,7 @@ export const Default: Story = {
export const Positions: Story = {
render: () => (
<div className="flex gap-8 justify-center items-center h-[200px]">
<div className="flex gap-8 justify-center items-center min-h-layout-story">
<Tooltip content="Left" position="left">
<Button variant="outline">Left</Button>
</Tooltip>

View file

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/vitest';
@ -35,40 +35,43 @@ describe('Tooltip Component', () => {
expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
});
it('shows tooltip on hover after delay', () => {
render(
it('shows tooltip on hover after delay', async () => {
const { container } = render(
<Tooltip content="Tooltip text" delay={300}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
// Avancer le temps pour déclencher le délai (300ms) et le setTimeout imbriqué (10ms)
vi.advanceTimersByTime(310);
await act(async () => {
vi.advanceTimersByTime(310);
});
expect(screen.getByText('Tooltip text')).toBeInTheDocument();
});
it('hides tooltip when mouse leaves', () => {
render(
it('hides tooltip when mouse leaves', async () => {
const { container } = render(
<Tooltip content="Tooltip text" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
// Avancer pour le setTimeout(..., 0)
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getByText('Tooltip text')).toBeInTheDocument();
fireEvent.mouseLeave(button);
// Avancer pour la fin de l'animation (200ms)
vi.advanceTimersByTime(250);
fireEvent.mouseLeave(wrapper!);
await act(async () => {
vi.advanceTimersByTime(250);
});
// Le tooltip peut être monté mais invisible, vérifier qu'il n'est pas visible
const tooltip = screen.queryByText('Tooltip text');
@ -77,87 +80,96 @@ describe('Tooltip Component', () => {
}
});
it('shows tooltip immediately when delay is 0', () => {
render(
it('shows tooltip immediately when delay is 0', async () => {
const { container } = render(
<Tooltip content="Tooltip text" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
// Avancer pour le setTimeout(..., 0)
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getByText('Tooltip text')).toBeInTheDocument();
});
it('applies top position by default', () => {
render(
it('applies top position by default', async () => {
const { container } = render(
<Tooltip content="Tooltip text" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('Tooltip text');
expect(tooltip.closest('.bottom-full')).toBeInTheDocument();
expect(tooltip.className).toMatch(/bottom-full|top-full/);
});
it('applies bottom position', () => {
render(
it('applies bottom position', async () => {
const { container } = render(
<Tooltip content="Tooltip text" position="bottom" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('Tooltip text');
expect(tooltip.closest('.top-full')).toBeInTheDocument();
});
it('applies left position', () => {
render(
it('applies left position', async () => {
const { container } = render(
<Tooltip content="Tooltip text" position="left" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('Tooltip text');
expect(tooltip.closest('.right-full')).toBeInTheDocument();
expect(tooltip.className).toMatch(/left-full|right-full/);
});
it('applies right position', () => {
render(
it('applies right position', async () => {
const { container } = render(
<Tooltip content="Tooltip text" position="right" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('Tooltip text');
expect(tooltip.closest('.left-full')).toBeInTheDocument();
});
it('does not show tooltip when disabled', () => {
it('does not show tooltip when disabled', async () => {
render(
<Tooltip content="Tooltip text" disabled delay={0}>
<button>Hover me</button>
@ -167,82 +179,91 @@ describe('Tooltip Component', () => {
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
});
it('renders React node as content', () => {
render(
it('renders React node as content', async () => {
const { container } = render(
<Tooltip content={<span>Custom content</span>} delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getByText('Custom content')).toBeInTheDocument();
});
it('applies custom className', () => {
render(
it('applies custom className', async () => {
const { container } = render(
<Tooltip content="Tooltip text" className="custom-tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('Tooltip text');
expect(tooltip.closest('.custom-tooltip')).toBeInTheDocument();
});
it('has correct ARIA role', () => {
render(
it('has correct ARIA role', async () => {
const { container } = render(
<Tooltip content="Tooltip text" delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();
});
it('cancels tooltip display if mouse leaves before delay', () => {
render(
it('cancels tooltip display if mouse leaves before delay', async () => {
const { container } = render(
<Tooltip content="Tooltip text" delay={300}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
// Avancer le temps mais pas assez pour déclencher l'affichage
vi.advanceTimersByTime(200);
await act(async () => {
vi.advanceTimersByTime(200);
});
fireEvent.mouseLeave(button);
fireEvent.mouseLeave(wrapper!);
// Avancer le reste du temps
vi.advanceTimersByTime(200);
await act(async () => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByText('Tooltip text')).not.toBeInTheDocument();
});
describe('Triggers', () => {
it('shows tooltip on click when trigger is click', async () => {
const user = userEvent.setup({ delay: null });
render(
<Tooltip content="Click tooltip" trigger="click" delay={0}>
<button>Click me</button>
@ -250,15 +271,14 @@ describe('Tooltip Component', () => {
);
const button = screen.getByText('Click me');
await user.click(button);
vi.advanceTimersByTime(10);
await act(async () => {
fireEvent.click(button);
});
expect(screen.getByText('Click tooltip')).toBeInTheDocument();
});
it('toggles tooltip on click when trigger is click', async () => {
const user = userEvent.setup({ delay: null });
render(
<Tooltip content="Click tooltip" trigger="click" delay={0}>
<button>Click me</button>
@ -267,14 +287,17 @@ describe('Tooltip Component', () => {
const button = screen.getByText('Click me');
// Premier clic
await user.click(button);
vi.advanceTimersByTime(10);
await act(async () => {
fireEvent.click(button);
});
expect(screen.getByText('Click tooltip')).toBeInTheDocument();
// Deuxième clic pour fermer
await user.click(button);
vi.advanceTimersByTime(300);
await act(async () => {
fireEvent.click(button);
});
await act(async () => {
vi.advanceTimersByTime(300);
});
// Le tooltip est monté mais invisible, vérifier qu'il n'est pas visible
const tooltip = screen.queryByText('Click tooltip');
@ -285,7 +308,7 @@ describe('Tooltip Component', () => {
}
});
it('shows tooltip on focus when trigger is focus', () => {
it('shows tooltip on focus when trigger is focus', async () => {
render(
<Tooltip content="Focus tooltip" trigger="focus" delay={0}>
<input type="text" placeholder="Focus me" />
@ -295,12 +318,14 @@ describe('Tooltip Component', () => {
const input = screen.getByPlaceholderText('Focus me');
fireEvent.focus(input);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getByText('Focus tooltip')).toBeInTheDocument();
});
it('hides tooltip on blur when trigger is focus', () => {
it('hides tooltip on blur when trigger is focus', async () => {
render(
<Tooltip content="Focus tooltip" trigger="focus" delay={0}>
<input type="text" placeholder="Focus me" />
@ -309,12 +334,16 @@ describe('Tooltip Component', () => {
const input = screen.getByPlaceholderText('Focus me');
fireEvent.focus(input);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getByText('Focus tooltip')).toBeInTheDocument();
fireEvent.blur(input);
vi.advanceTimersByTime(300);
await act(async () => {
vi.advanceTimersByTime(300);
});
// Le tooltip est monté mais invisible, vérifier qu'il n'est pas visible
const tooltip = screen.queryByText('Focus tooltip');
@ -327,39 +356,43 @@ describe('Tooltip Component', () => {
});
describe('Advanced features', () => {
it('shows arrow when showArrow is true', () => {
render(
it('shows arrow when showArrow is true', async () => {
const { container } = render(
<Tooltip content="With arrow" showArrow delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
vi.advanceTimersByTime(10);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('With arrow');
const arrow = tooltip.parentElement?.querySelector('.border-4');
expect(arrow).toBeInTheDocument();
});
it('hides arrow when showArrow is false', () => {
render(
it('hides arrow when showArrow is false', async () => {
const { container } = render(
<Tooltip content="No arrow" showArrow={false} delay={0}>
<button>Hover me</button>
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
vi.advanceTimersByTime(10);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('No arrow');
const arrow = tooltip.parentElement?.querySelector('.border-4');
expect(arrow).not.toBeInTheDocument();
});
it('applies maxWidth style', () => {
it('applies maxWidth style', async () => {
render(
<Tooltip content="Limited width" maxWidth={200} delay={0}>
<button>Hover me</button>
@ -368,14 +401,16 @@ describe('Tooltip Component', () => {
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
vi.advanceTimersByTime(10);
await act(async () => {
vi.advanceTimersByTime(10);
});
const tooltip = screen.getByText('Limited width');
expect(tooltip).toHaveStyle({ maxWidth: '200px' });
});
it('renders rich content with HTML elements', () => {
render(
it('renders rich content with HTML elements', async () => {
const { container } = render(
<Tooltip
content={
<div>
@ -389,9 +424,11 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const button = screen.getByText('Hover me');
fireEvent.mouseEnter(button);
vi.advanceTimersByTime(10);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.getByText('Rich content')).toBeInTheDocument();
expect(screen.getByText('With multiple elements')).toBeInTheDocument();

View file

@ -1,327 +1,2 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
/**
* TooltipProps - Propriétés du composant Tooltip
*
* @interface TooltipProps
*/
export interface TooltipProps {
/**
* Contenu du tooltip (texte ou élément React)
*/
content: React.ReactNode;
/**
* Élément enfant qui déclenche le tooltip
*/
children: React.ReactNode;
/**
* Position du tooltip par rapport à l'élément
*
* - `top`: Au-dessus
* - `bottom`: En-dessous
* - `left`: À gauche
* - `right`: À droite
*
* @default 'top'
*/
position?: 'top' | 'bottom' | 'left' | 'right';
/**
* Événement qui déclenche l'affichage du tooltip
*
* - `hover`: Au survol (par défaut)
* - `click`: Au clic
* - `focus`: Au focus
*
* @default 'hover'
*/
trigger?: 'hover' | 'click' | 'focus';
/**
* Délai avant l'affichage en millisecondes
*
* @default 200
*/
delay?: number;
/**
* Si `true`, affiche une flèche pointant vers l'élément
*
* @default true
*/
showArrow?: boolean;
/**
* Largeur maximale du tooltip en pixels
*
* @default 300
*/
maxWidth?: number;
/**
* Si `true`, désactive le tooltip
*
* @default false
*/
disabled?: boolean;
/**
* Classes CSS personnalisées
*/
className?: string;
}
/**
* Tooltip - Composant de tooltip avec design system Kodo
*
* Composant de tooltip avec :
* - Positionnement intelligent (flip et shift automatiques)
* - Plusieurs triggers (hover, click, focus)
* - Délai configurable
* - Flèche optionnelle
* - Gestion du viewport
*
* @example
* ```tsx
* // Tooltip simple au survol
* <Tooltip content="Information supplémentaire">
* <button>Hover me</button>
* </Tooltip>
* ```
*
* @example
* ```tsx
* // Tooltip avec position et trigger personnalisés
* <Tooltip
* content="Cliquez pour plus d'infos"
* position="bottom"
* trigger="click"
* delay={300}
* >
* <button>Click me</button>
* </Tooltip>
* ```
*
* @component
* @param {TooltipProps} props - Propriétés du composant
* @returns {JSX.Element} Wrapper avec tooltip positionné
*/
export function Tooltip({
content,
children,
position = 'top',
trigger = 'hover',
delay = 200,
showArrow = true,
maxWidth = 300,
disabled = false,
className,
}: TooltipProps) {
const [visible, setVisible] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
// Calcul du positionnement avec flip et shift
const calculatePosition = useCallback(() => {
if (!wrapperRef.current || !tooltipRef.current || !visible) return;
const wrapperRect = wrapperRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 8;
let newPosition = position;
let offsetX = 0;
let offsetY = 0;
// Flip: inverser la position si le tooltip sort de l'écran
switch (position) {
case 'top':
if (wrapperRect.top - tooltipRect.height - margin < 0) {
newPosition = 'bottom';
}
break;
case 'bottom':
if (wrapperRect.bottom + tooltipRect.height + margin > viewportHeight) {
newPosition = 'top';
}
break;
case 'left':
if (wrapperRect.left - tooltipRect.width - margin < 0) {
newPosition = 'right';
}
break;
case 'right':
if (wrapperRect.right + tooltipRect.width + margin > viewportWidth) {
newPosition = 'left';
}
break;
}
// Shift: ajuster la position pour rester dans la viewport
if (newPosition === 'top' || newPosition === 'bottom') {
const centerX = wrapperRect.left + wrapperRect.width / 2;
const tooltipHalfWidth = tooltipRect.width / 2;
const minX = margin;
const maxX = viewportWidth - margin;
if (centerX - tooltipHalfWidth < minX) {
offsetX = minX - (centerX - tooltipHalfWidth);
} else if (centerX + tooltipHalfWidth > maxX) {
offsetX = maxX - (centerX + tooltipHalfWidth);
}
} else {
const centerY = wrapperRect.top + wrapperRect.height / 2;
const tooltipHalfHeight = tooltipRect.height / 2;
const minY = margin;
const maxY = viewportHeight - margin;
if (centerY - tooltipHalfHeight < minY) {
offsetY = minY - (centerY - tooltipHalfHeight);
} else if (centerY + tooltipHalfHeight > maxY) {
offsetY = maxY - (centerY + tooltipHalfHeight);
}
}
setCalculatedPosition(newPosition);
setTooltipStyle({
...(offsetX !== 0 && {
transform: `translate(calc(-50% + ${offsetX}px), 0)`,
}),
...(offsetY !== 0 && {
transform: `translate(0, calc(-50% + ${offsetY}px))`,
}),
});
}, [position, visible]);
useEffect(() => {
if (visible) {
setIsMounted(true);
calculatePosition();
}
}, [visible, calculatePosition]);
useEffect(() => {
if (isMounted && visible) {
calculatePosition();
}
}, [isMounted, visible, calculatePosition]);
const showTooltip = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setVisible(true);
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setVisible(false);
}, 100);
};
const handleClick = () => {
if (trigger === 'click') {
setVisible(!visible);
}
};
const triggerProps = {
hover: {
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
},
click: {
onClick: handleClick,
},
focus: {
onFocus: showTooltip,
onBlur: hideTooltip,
},
}[trigger];
const positionClasses = {
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',
};
const arrowClasses = {
top: 'top-full left-1/2 -translate-x-1/2 border-t-kodo-ink border-l-transparent border-r-transparent border-b-transparent',
bottom:
'bottom-full left-1/2 -translate-x-1/2 border-b-kodo-ink border-l-transparent border-r-transparent border-t-transparent',
left: 'left-full top-1/2 -translate-y-1/2 border-l-kodo-ink border-t-transparent border-b-transparent border-r-transparent',
right:
'right-full top-1/2 -translate-y-1/2 border-r-kodo-ink border-t-transparent border-b-transparent border-l-transparent',
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
if (disabled) {
return <>{children}</>;
}
const tooltipContent = (
<>
{isMounted && (
<div
ref={tooltipRef}
className={cn(
'absolute z-50 px-4 py-1.5 text-sm text-white bg-kodo-ink rounded-md shadow-lg',
'border border-kodo-steel pointer-events-none',
'transition-all duration-200',
positionClasses[calculatedPosition],
visible ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
className,
)}
role="tooltip"
style={{ maxWidth: `${maxWidth}px`, ...tooltipStyle }}
>
{content}
{showArrow && (
<div
className={cn(
'absolute w-0 h-0 border-4',
arrowClasses[calculatedPosition],
)}
/>
)}
</div>
)}
</>
);
return (
<div ref={wrapperRef} className="relative inline-block" {...triggerProps}>
{children}
{tooltipContent}
</div>
);
}
export { Tooltip } from './tooltip/index';
export type { TooltipProps } from './tooltip/index';

View file

@ -0,0 +1,57 @@
import * as React from 'react';
import { useTooltip } from './useTooltip';
import { TooltipContent } from './TooltipContent';
import type { TooltipProps } from './types';
export function Tooltip({
content,
children,
position = 'top',
trigger = 'hover',
delay = 200,
showArrow = true,
maxWidth,
disabled = false,
className,
}: TooltipProps) {
const {
visible,
isMounted,
calculatedPosition,
tooltipStyle,
wrapperRef,
tooltipRef,
triggerProps,
} = useTooltip(position, trigger, delay, disabled);
if (disabled) {
return <>{children}</>;
}
const isHover = trigger === 'hover';
const wrapperProps = isHover ? triggerProps : {};
const child =
!isHover &&
React.isValidElement(children) &&
React.Children.only(children)
? React.cloneElement(children, triggerProps as React.HTMLAttributes<HTMLElement>)
: children;
return (
<div ref={wrapperRef} className="relative inline-block" {...wrapperProps}>
{child}
{isMounted && (
<TooltipContent
content={content}
visible={visible}
calculatedPosition={calculatedPosition}
tooltipStyle={tooltipStyle}
tooltipRef={tooltipRef}
showArrow={showArrow}
maxWidth={maxWidth}
className={className}
/>
)}
</div>
);
}

View file

@ -0,0 +1,50 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { positionClasses, arrowClasses } from './useTooltip';
type Position = 'top' | 'bottom' | 'left' | 'right';
interface TooltipContentProps {
content: React.ReactNode;
visible: boolean;
calculatedPosition: Position;
tooltipStyle: React.CSSProperties;
tooltipRef: React.RefObject<HTMLDivElement | null>;
showArrow: boolean;
maxWidth?: number;
className?: string;
}
export function TooltipContent({
content,
visible,
calculatedPosition,
tooltipStyle,
tooltipRef,
showArrow,
maxWidth,
className,
}: TooltipContentProps) {
return (
<div
ref={tooltipRef}
className={cn(
'absolute z-50 max-w-xs px-4 py-1.5 text-sm text-white bg-kodo-ink rounded-md shadow-lg',
'border border-kodo-steel pointer-events-none',
'transition-all duration-200',
positionClasses[calculatedPosition],
visible ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
className,
)}
role="tooltip"
style={{ ...(maxWidth != null && { maxWidth: `${maxWidth}px` }), ...tooltipStyle }}
>
{content}
{showArrow && (
<div
className={cn('absolute w-0 h-0 border-4', arrowClasses[calculatedPosition])}
/>
)}
</div>
);
}

View file

@ -0,0 +1,2 @@
export { Tooltip } from './Tooltip';
export type { TooltipProps } from './types';

View file

@ -0,0 +1,12 @@
export interface TooltipProps {
content: React.ReactNode;
children: React.ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
trigger?: 'hover' | 'click' | 'focus';
delay?: number;
showArrow?: boolean;
/** Prefer className (e.g. max-w-sm) when possible; px only when needed. */
maxWidth?: number;
disabled?: boolean;
className?: string;
}

View file

@ -0,0 +1,177 @@
import { useState, useEffect, useRef, useCallback } from 'react';
type Position = 'top' | 'bottom' | 'left' | 'right';
export function useTooltip(
position: Position,
trigger: 'hover' | 'click' | 'focus',
delay: number,
disabled: boolean,
) {
const [visible, setVisible] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const calculatePosition = useCallback(() => {
if (!wrapperRef.current || !tooltipRef.current || !visible) return;
const wrapperRect = wrapperRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 8;
let newPosition = position;
let offsetX = 0;
let offsetY = 0;
switch (position) {
case 'top':
if (wrapperRect.top - tooltipRect.height - margin < 0) {
newPosition = 'bottom';
}
break;
case 'bottom':
if (wrapperRect.bottom + tooltipRect.height + margin > viewportHeight) {
newPosition = 'top';
}
break;
case 'left':
if (wrapperRect.left - tooltipRect.width - margin < 0) {
newPosition = 'right';
}
break;
case 'right':
if (wrapperRect.right + tooltipRect.width + margin > viewportWidth) {
newPosition = 'left';
}
break;
}
if (newPosition === 'top' || newPosition === 'bottom') {
const centerX = wrapperRect.left + wrapperRect.width / 2;
const tooltipHalfWidth = tooltipRect.width / 2;
const minX = margin;
const maxX = viewportWidth - margin;
if (centerX - tooltipHalfWidth < minX) {
offsetX = minX - (centerX - tooltipHalfWidth);
} else if (centerX + tooltipHalfWidth > maxX) {
offsetX = maxX - (centerX + tooltipHalfWidth);
}
} else {
const centerY = wrapperRect.top + wrapperRect.height / 2;
const tooltipHalfHeight = tooltipRect.height / 2;
const minY = margin;
const maxY = viewportHeight - margin;
if (centerY - tooltipHalfHeight < minY) {
offsetY = minY - (centerY - tooltipHalfHeight);
} else if (centerY + tooltipHalfHeight > maxY) {
offsetY = maxY - (centerY + tooltipHalfHeight);
}
}
setCalculatedPosition(newPosition);
setTooltipStyle({
...(offsetX !== 0 && {
transform: `translate(calc(-50% + ${offsetX}px), 0)`,
}),
...(offsetY !== 0 && {
transform: `translate(0, calc(-50% + ${offsetY}px))`,
}),
});
}, [position, visible]);
useEffect(() => {
if (visible) {
calculatePosition();
}
}, [visible, calculatePosition]);
useEffect(() => {
if (isMounted && visible) {
calculatePosition();
}
}, [isMounted, visible, calculatePosition]);
const showTooltip = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setVisible(true);
setIsMounted(true);
}, delay);
}, [delay]);
const hideTooltip = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setVisible(false);
}, 100);
}, []);
const handleClick = useCallback(() => {
if (trigger === 'click') {
setVisible((v) => {
const next = !v;
if (next) setIsMounted(true);
return next;
});
}
}, [trigger]);
const triggerProps =
trigger === 'hover'
? { onMouseEnter: showTooltip, onMouseLeave: hideTooltip }
: trigger === 'click'
? { onClick: handleClick }
: { onFocus: showTooltip, onBlur: hideTooltip };
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, []);
return {
visible,
isMounted,
calculatedPosition,
tooltipStyle,
wrapperRef,
tooltipRef,
triggerProps: disabled ? {} : triggerProps,
};
}
export const positionClasses: Record<
Position,
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 arrowClasses: Record<Position, string> = {
top: 'top-full left-1/2 -translate-x-1/2 border-t-kodo-ink border-l-transparent border-r-transparent border-b-transparent',
bottom:
'bottom-full left-1/2 -translate-x-1/2 border-b-kodo-ink border-l-transparent border-r-transparent border-t-transparent',
left: 'left-full top-1/2 -translate-y-1/2 border-l-kodo-ink border-t-transparent border-b-transparent border-r-transparent',
right:
'right-full top-1/2 -translate-y-1/2 border-r-kodo-ink border-t-transparent border-b-transparent border-l-transparent',
};