refactor(ui): tooltip module, useTooltip, re-export, tests
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dfd9ba6220
commit
27fd12fe6c
8 changed files with 441 additions and 431 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
57
apps/web/src/components/ui/tooltip/Tooltip.tsx
Normal file
57
apps/web/src/components/ui/tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/ui/tooltip/TooltipContent.tsx
Normal file
50
apps/web/src/components/ui/tooltip/TooltipContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/components/ui/tooltip/index.ts
Normal file
2
apps/web/src/components/ui/tooltip/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Tooltip } from './Tooltip';
|
||||
export type { TooltipProps } from './types';
|
||||
12
apps/web/src/components/ui/tooltip/types.ts
Normal file
12
apps/web/src/components/ui/tooltip/types.ts
Normal 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;
|
||||
}
|
||||
177
apps/web/src/components/ui/tooltip/useTooltip.ts
Normal file
177
apps/web/src/components/ui/tooltip/useTooltip.ts
Normal 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',
|
||||
};
|
||||
Loading…
Reference in a new issue