Skip to content

Commit b7752fa

Browse files
authored
Merge pull request #8 from webdevia/homework-7
[8] Реализовать сложный компонент
2 parents de97abc + edc2721 commit b7752fa

File tree

4 files changed

+191
-0
lines changed

4 files changed

+191
-0
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
.tooltip-target {
2+
display: inline-block;
3+
position: relative;
4+
}
5+
6+
.tooltip-content {
7+
position: absolute;
8+
padding: 5px;
9+
border-radius: 5px;
10+
background-color: black;
11+
color: whitesmoke;
12+
opacity: 1;
13+
transition-duration: var(--tooltip-animation-ms, 1000ms);
14+
transition-property: opacity;
15+
transition-timing-function: ease;
16+
}
17+
18+
.fade-out {
19+
opacity: 0;
20+
}

src/shared/tooltip/Tooltip.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import React, { useState, useRef, useLayoutEffect, ReactNode } from 'react';
2+
import { createPortal } from 'react-dom';
3+
import cn from 'clsx';
4+
import s from './Tooltip.module.scss';
5+
6+
type Coords = {
7+
top: number;
8+
left: number;
9+
};
10+
11+
type CoordProps = {
12+
targetRect: DOMRect;
13+
tooltipRect: DOMRect;
14+
offset: number;
15+
};
16+
17+
type Position = 'top' | 'bottom' | 'left' | 'right';
18+
19+
type PositionMap = Record<Position, (props: CoordProps) => Coords>;
20+
21+
const getCenterCoord = (primary: number, secondary: number) => (primary - secondary) / 2;
22+
23+
const YLeft = (primary: DOMRect, secondary: DOMRect) => primary.left + getCenterCoord(primary.width, secondary.width);
24+
const XTop = (primary: DOMRect, secondary: DOMRect) => primary.top + getCenterCoord(primary.height, secondary.height);
25+
26+
const positionMap: PositionMap = {
27+
top: ({ targetRect, tooltipRect, offset }) => ({
28+
top: targetRect.top - tooltipRect.height - offset,
29+
left: YLeft(targetRect, tooltipRect),
30+
}),
31+
bottom: ({ targetRect, tooltipRect, offset }) => ({
32+
top: targetRect.bottom + offset,
33+
left: YLeft(targetRect, tooltipRect),
34+
}),
35+
left: ({ targetRect, tooltipRect, offset }) => ({
36+
top: XTop(targetRect, tooltipRect),
37+
left: targetRect.left - (tooltipRect.width + offset),
38+
}),
39+
40+
right: ({ targetRect, tooltipRect, offset }) => ({
41+
top: XTop(targetRect, tooltipRect),
42+
left: targetRect.left + (targetRect.width + offset),
43+
}),
44+
};
45+
46+
type TooltipProps = {
47+
children: ReactNode;
48+
content: ReactNode;
49+
duration?: number;
50+
position?: Position;
51+
};
52+
53+
export const Tooltip = ({ children, content, duration = 1000, position = 'bottom' }: TooltipProps) => {
54+
const [visible, setVisible] = useState(false);
55+
const [mounted, setMounted] = useState(false);
56+
const [coords, setCoords] = useState({ top: 0, left: 0 });
57+
const tooltipRef = useRef<HTMLDivElement>(null);
58+
const targetRef = useRef<HTMLDivElement>(null);
59+
const timerRef = useRef(null);
60+
const mountTimerRef = useRef(null);
61+
62+
const mountTimer = 10;
63+
64+
const clearTimeouts = () => {
65+
timerRef.current && clearTimeout(timerRef.current);
66+
mountTimerRef.current && clearTimeout(mountTimerRef.current);
67+
};
68+
69+
const handleMouseEnter = () => {
70+
clearTimeouts();
71+
setMounted(true);
72+
mountTimerRef.current = setTimeout(() => setVisible(true), mountTimer);
73+
};
74+
75+
const handleMouseLeave = () => {
76+
setVisible(false);
77+
timerRef.current = setTimeout(() => setMounted(false), duration + mountTimer);
78+
};
79+
80+
useLayoutEffect(() => {
81+
const target = targetRef.current;
82+
const tooltip = tooltipRef.current;
83+
84+
if (!target || !tooltip) return;
85+
86+
tooltip.style.setProperty('--tooltip-animation-ms', `${duration + mountTimer}ms`);
87+
88+
if (mounted) {
89+
const targetRect = target.getBoundingClientRect();
90+
const tooltipRect = tooltip.getBoundingClientRect();
91+
const calcPosition = positionMap[position];
92+
setCoords(calcPosition({ targetRect, tooltipRect, offset: 5 }));
93+
}
94+
95+
return () => {
96+
clearTimeouts();
97+
};
98+
}, [mounted]);
99+
100+
return (
101+
<>
102+
<span
103+
className={s['tooltip-target']}
104+
ref={targetRef}
105+
onMouseEnter={handleMouseEnter}
106+
onMouseLeave={handleMouseLeave}
107+
>
108+
{children}
109+
</span>
110+
{mounted &&
111+
createPortal(
112+
<div ref={tooltipRef} className={cn(s['tooltip-content'], { [s['fade-out']]: !visible })} style={coords}>
113+
{content}
114+
</div>,
115+
document.body
116+
)}
117+
</>
118+
);
119+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react';
2+
import type { Meta } from '@storybook/react';
3+
4+
import { Tooltip } from '../shared/tooltip/Tooltip';
5+
6+
type TooltipInlineTextProps = {
7+
content: React.ReactNode;
8+
};
9+
10+
const TooltipInlineText = ({ content }: TooltipInlineTextProps) => (
11+
<p>
12+
{'Lorem ipsum dolor sit amet '}
13+
<Tooltip content={content}>
14+
<b>consectetur</b>
15+
</Tooltip>
16+
{' adipisicing elit.'}
17+
</p>
18+
);
19+
20+
const meta: Meta<typeof Tooltip> = {
21+
component: TooltipInlineText,
22+
title: 'Сложные компоненты/Подсказка/В тексте',
23+
tags: ['autodocs'],
24+
};
25+
26+
export default meta;
27+
28+
export const Test = {
29+
args: {
30+
content: 'Плавно всплывающая подсказка',
31+
},
32+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from 'react';
2+
import type { Meta } from '@storybook/react';
3+
4+
import { Tooltip } from '../shared/tooltip/Tooltip';
5+
import { Button } from '../shared/button/Button';
6+
7+
const meta: Meta<typeof Tooltip> = {
8+
component: Tooltip,
9+
title: 'Сложные компоненты/Подсказка/На кнопке',
10+
tags: ['autodocs'],
11+
};
12+
13+
export default meta;
14+
15+
export const Test = {
16+
args: {
17+
children: <Button>{'Наведи на меня'}</Button>,
18+
content: 'Плавно всплывающая подсказка',
19+
},
20+
};

0 commit comments

Comments
 (0)