Skip to content

Commit 35d0500

Browse files
authored
Merge pull request #45 from CleanCode366/task/38-component-tooltip-component
feat: Tooltip component created
2 parents 258b8ee + 579f741 commit 35d0500

5 files changed

Lines changed: 284 additions & 12 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,5 @@ export default defineConfig([
7171
},
7272
])
7373
```
74+
75+
<!-- 38 35 34 26 -->
Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
1-
import ThemeToggleSwitch from '@/ThemeToggleSwitch';
2-
import React, { Suspense } from 'react';
3-
import { Outlet } from 'react-router-dom';
1+
import { Tooltip } from '@/shared/Tooltip'
2+
import ThemeToggleSwitch from '@/ThemeToggleSwitch'
3+
import React, { Suspense } from 'react'
4+
import { Outlet } from 'react-router-dom'
45

56
const BaseLayout: React.FC = () => {
67
return (
7-
<div className="min-h-screen bg-bg-primary flex flex-col">
8-
8+
<div className="bg-bg-primary flex min-h-screen flex-col">
99
{/* Main Content */}
1010
<main className="flex-1 overflow-y-auto px-4 sm:px-6 lg:px-8">
11-
<div className="py-8 sm:py-12 text-text-primary">
11+
<div className="text-text-primary py-8 sm:py-12">
1212
<Suspense fallback={null}>
1313
<Outlet />
14-
<div className="flex gap-2 absolute top-4 right-10">
15-
<ThemeToggleSwitch />
14+
<div className="absolute top-4 right-10 flex gap-2">
15+
<Tooltip children={<ThemeToggleSwitch />} content="Change theme" position="left" />
1616
</div>
1717
</Suspense>
1818
</div>
1919
</main>
20-
2120
</div>
22-
);
23-
};
21+
)
22+
}
2423

25-
export default BaseLayout;
24+
export default BaseLayout
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
3+
import { InformationCircleIcon } from '@heroicons/react/24/outline'
4+
5+
import { Tooltip } from './Tooltip'
6+
7+
const meta = {
8+
title: 'Shared/Composites/Tooltip',
9+
10+
component: Tooltip,
11+
12+
tags: ['autodocs'],
13+
} satisfies Meta<typeof Tooltip>
14+
15+
export default meta
16+
17+
type Story = StoryObj<typeof meta>
18+
19+
const IconButton = () => (
20+
<button className="bg-bg-secondary border-border-secondary text-text-primary rounded-md border p-2">
21+
<InformationCircleIcon className="h-5 w-5" />
22+
</button>
23+
)
24+
25+
export const Top: Story = {
26+
args: {
27+
content: 'Top tooltip',
28+
position: 'top',
29+
30+
children: <IconButton />,
31+
},
32+
}
33+
34+
export const Bottom: Story = {
35+
args: {
36+
content: 'Bottom tooltip',
37+
position: 'bottom',
38+
39+
children: <IconButton />,
40+
},
41+
}
42+
43+
export const Left: Story = {
44+
args: {
45+
content: 'Left tooltip',
46+
position: 'left',
47+
48+
children: <IconButton />,
49+
},
50+
}
51+
52+
export const Right: Story = {
53+
args: {
54+
content: 'Right tooltip',
55+
position: 'right',
56+
57+
children: <IconButton />,
58+
},
59+
}
60+
61+
export const LongContent: Story = {
62+
args: {
63+
content: 'AI confidence score is below the escalation threshold.',
64+
65+
position: 'top',
66+
67+
children: <IconButton />,
68+
},
69+
}

src/shared/Tooltip/Tooltip.tsx

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { type ReactNode, useEffect, useRef, useState } from 'react'
2+
3+
import { createPortal } from 'react-dom'
4+
5+
export interface TooltipProps {
6+
content: string
7+
8+
children: ReactNode
9+
10+
position?: 'top' | 'bottom' | 'left' | 'right'
11+
12+
delay?: number
13+
14+
className?: string
15+
}
16+
17+
const arrowClasses = {
18+
top: `
19+
border-l-[6px] border-r-[6px] border-t-[6px]
20+
border-l-transparent
21+
border-r-transparent
22+
border-t-bg-secondary
23+
`,
24+
25+
bottom: `
26+
border-l-[6px] border-r-[6px] border-b-[6px]
27+
border-l-transparent
28+
border-r-transparent
29+
border-b-bg-secondary
30+
`,
31+
32+
left: `
33+
border-t-[6px] border-b-[6px] border-l-[6px]
34+
border-t-transparent
35+
border-b-transparent
36+
border-l-bg-secondary
37+
`,
38+
39+
right: `
40+
border-t-[6px] border-b-[6px] border-r-[6px]
41+
border-t-transparent
42+
border-b-transparent
43+
border-r-bg-secondary
44+
`,
45+
}
46+
47+
const arrowPositionClasses = {
48+
top: `
49+
top-full
50+
left-1/2
51+
-translate-x-1/2
52+
`,
53+
54+
bottom: `
55+
bottom-full
56+
left-1/2
57+
-translate-x-1/2
58+
`,
59+
60+
left: `
61+
left-full
62+
top-1/2
63+
-translate-y-1/2
64+
`,
65+
66+
right: `
67+
right-full
68+
top-1/2
69+
-translate-y-1/2
70+
`,
71+
}
72+
73+
export function Tooltip({
74+
content,
75+
children,
76+
77+
position = 'top',
78+
79+
delay = 400,
80+
81+
className = '',
82+
}: TooltipProps) {
83+
const [visible, setVisible] = useState(false)
84+
85+
const [coords, setCoords] = useState({
86+
top: 0,
87+
left: 0,
88+
})
89+
90+
const triggerRef = useRef<HTMLDivElement>(null)
91+
92+
const timeoutRef = useRef<number | null>(null)
93+
94+
const showTooltip = () => {
95+
timeoutRef.current = window.setTimeout(() => {
96+
if (!triggerRef.current) return
97+
98+
const rect = triggerRef.current.getBoundingClientRect()
99+
100+
const spacing = 10
101+
102+
let top = 0
103+
let left = 0
104+
105+
switch (position) {
106+
case 'top':
107+
top = rect.top - spacing
108+
left = rect.left + rect.width / 2
109+
break
110+
111+
case 'bottom':
112+
top = rect.bottom + spacing
113+
left = rect.left + rect.width / 2
114+
break
115+
116+
case 'left':
117+
top = rect.top + rect.height / 2
118+
left = rect.left - spacing
119+
break
120+
121+
case 'right':
122+
top = rect.top + rect.height / 2
123+
left = rect.right + spacing
124+
break
125+
}
126+
127+
setCoords({
128+
top,
129+
left,
130+
})
131+
132+
setVisible(true)
133+
}, delay)
134+
}
135+
136+
const hideTooltip = () => {
137+
if (timeoutRef.current) {
138+
clearTimeout(timeoutRef.current)
139+
}
140+
141+
setVisible(false)
142+
}
143+
144+
useEffect(() => {
145+
return () => {
146+
if (timeoutRef.current) {
147+
clearTimeout(timeoutRef.current)
148+
}
149+
}
150+
}, [])
151+
152+
return (
153+
<>
154+
{/* Trigger */}
155+
<div
156+
ref={triggerRef}
157+
className="inline-flex items-center"
158+
onMouseEnter={showTooltip}
159+
onMouseLeave={hideTooltip}
160+
onFocus={showTooltip}
161+
onBlur={hideTooltip}
162+
>
163+
{children}
164+
</div>
165+
166+
{/* Tooltip */}
167+
{visible &&
168+
createPortal(
169+
<div
170+
role="tooltip"
171+
aria-describedby="tooltip"
172+
className={`bg-bg-secondary text-text-primary pointer-events-none fixed z-50 rounded-md px-3 py-2 text-sm whitespace-nowrap shadow-lg ${className} `}
173+
style={{
174+
top: coords.top,
175+
left: coords.left,
176+
177+
transform:
178+
position === 'top'
179+
? 'translate(-50%, -100%)'
180+
: position === 'bottom'
181+
? 'translate(-50%, 0)'
182+
: position === 'left'
183+
? 'translate(-100%, -5%)'
184+
: 'translate(0, -45%)',
185+
}}
186+
>
187+
{content}
188+
189+
{/* Arrow */}
190+
<div
191+
className={`absolute h-0 w-0 ${arrowClasses[position]} ${arrowPositionClasses[position]} `}
192+
/>
193+
</div>,
194+
document.body
195+
)}
196+
</>
197+
)
198+
}
199+
200+
export default Tooltip

src/shared/Tooltip/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Tooltip } from './Tooltip'
2+
export type { TooltipProps } from './Tooltip'

0 commit comments

Comments
 (0)