diff --git a/README.md b/README.md index 7dbf7eb..933045f 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,5 @@ export default defineConfig([ }, ]) ``` + + diff --git a/src/layouts/MainLayout/BaseLayout.tsx b/src/layouts/MainLayout/BaseLayout.tsx index 78cb47a..cafdb9e 100644 --- a/src/layouts/MainLayout/BaseLayout.tsx +++ b/src/layouts/MainLayout/BaseLayout.tsx @@ -1,25 +1,24 @@ -import ThemeToggleSwitch from '@/ThemeToggleSwitch'; -import React, { Suspense } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Tooltip } from '@/shared/Tooltip' +import ThemeToggleSwitch from '@/ThemeToggleSwitch' +import React, { Suspense } from 'react' +import { Outlet } from 'react-router-dom' const BaseLayout: React.FC = () => { return ( -
- +
{/* Main Content */}
-
+
-
- +
+ } content="Change theme" position="left" />
-
- ); -}; + ) +} -export default BaseLayout; \ No newline at end of file +export default BaseLayout diff --git a/src/shared/Tooltip/Tooltip.stories.tsx b/src/shared/Tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000..f84a861 --- /dev/null +++ b/src/shared/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { InformationCircleIcon } from '@heroicons/react/24/outline' + +import { Tooltip } from './Tooltip' + +const meta = { + title: 'Shared/Composites/Tooltip', + + component: Tooltip, + + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +const IconButton = () => ( + +) + +export const Top: Story = { + args: { + content: 'Top tooltip', + position: 'top', + + children: , + }, +} + +export const Bottom: Story = { + args: { + content: 'Bottom tooltip', + position: 'bottom', + + children: , + }, +} + +export const Left: Story = { + args: { + content: 'Left tooltip', + position: 'left', + + children: , + }, +} + +export const Right: Story = { + args: { + content: 'Right tooltip', + position: 'right', + + children: , + }, +} + +export const LongContent: Story = { + args: { + content: 'AI confidence score is below the escalation threshold.', + + position: 'top', + + children: , + }, +} diff --git a/src/shared/Tooltip/Tooltip.tsx b/src/shared/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..9d697d4 --- /dev/null +++ b/src/shared/Tooltip/Tooltip.tsx @@ -0,0 +1,200 @@ +import { type ReactNode, useEffect, useRef, useState } from 'react' + +import { createPortal } from 'react-dom' + +export interface TooltipProps { + content: string + + children: ReactNode + + position?: 'top' | 'bottom' | 'left' | 'right' + + delay?: number + + className?: string +} + +const arrowClasses = { + top: ` + border-l-[6px] border-r-[6px] border-t-[6px] + border-l-transparent + border-r-transparent + border-t-bg-secondary + `, + + bottom: ` + border-l-[6px] border-r-[6px] border-b-[6px] + border-l-transparent + border-r-transparent + border-b-bg-secondary + `, + + left: ` + border-t-[6px] border-b-[6px] border-l-[6px] + border-t-transparent + border-b-transparent + border-l-bg-secondary + `, + + right: ` + border-t-[6px] border-b-[6px] border-r-[6px] + border-t-transparent + border-b-transparent + border-r-bg-secondary + `, +} + +const arrowPositionClasses = { + top: ` + top-full + left-1/2 + -translate-x-1/2 + `, + + bottom: ` + bottom-full + left-1/2 + -translate-x-1/2 + `, + + left: ` + left-full + top-1/2 + -translate-y-1/2 + `, + + right: ` + right-full + top-1/2 + -translate-y-1/2 + `, +} + +export function Tooltip({ + content, + children, + + position = 'top', + + delay = 400, + + className = '', +}: TooltipProps) { + const [visible, setVisible] = useState(false) + + const [coords, setCoords] = useState({ + top: 0, + left: 0, + }) + + const triggerRef = useRef(null) + + const timeoutRef = useRef(null) + + const showTooltip = () => { + timeoutRef.current = window.setTimeout(() => { + if (!triggerRef.current) return + + const rect = triggerRef.current.getBoundingClientRect() + + const spacing = 10 + + let top = 0 + let left = 0 + + switch (position) { + case 'top': + top = rect.top - spacing + left = rect.left + rect.width / 2 + break + + case 'bottom': + top = rect.bottom + spacing + left = rect.left + rect.width / 2 + break + + case 'left': + top = rect.top + rect.height / 2 + left = rect.left - spacing + break + + case 'right': + top = rect.top + rect.height / 2 + left = rect.right + spacing + break + } + + setCoords({ + top, + left, + }) + + setVisible(true) + }, delay) + } + + const hideTooltip = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + setVisible(false) + } + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + return ( + <> + {/* Trigger */} +
+ {children} +
+ + {/* Tooltip */} + {visible && + createPortal( +
+ {content} + + {/* Arrow */} +
+
, + document.body + )} + + ) +} + +export default Tooltip diff --git a/src/shared/Tooltip/index.ts b/src/shared/Tooltip/index.ts new file mode 100644 index 0000000..eaca424 --- /dev/null +++ b/src/shared/Tooltip/index.ts @@ -0,0 +1,2 @@ +export { Tooltip } from './Tooltip' +export type { TooltipProps } from './Tooltip'