diff --git a/src/components/atoms/tooltip/Tooltip.stories.tsx b/src/components/atoms/tooltip/Tooltip.stories.tsx new file mode 100644 index 00000000..47bc2746 --- /dev/null +++ b/src/components/atoms/tooltip/Tooltip.stories.tsx @@ -0,0 +1,250 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Button from '../button'; +import IconButton from '../icon-button'; +import Tooltip from './Tooltip'; + +/** + * ## DESCRIPTION + * The Tooltip component is a non-intrusive interface element that displays an informative text message or a short label when the user hovers over an interface element (such as a button or icon). + * + * + * Its main purpose is to improve usability by providing immediate context about the element's function, without taking up permanent screen space. + */ + +const meta: Meta = { + title: 'Atoms/Tooltip', + component: Tooltip, + parameters: { + docs: { + autodocs: true + } + }, + tags: ['autodocs'] +}; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + content: 'I`m a Tooltip', + color: 'default', + placement: 'top', + onClick: false, + onFocus: false, + disabled: false, + children: + + + ) +}; + +/** + * + * We have 5 different colour variants so you can use it according to your needs. + * + */ +export const colors: Story = { + render: () => ( +
+ + + + + + + + + + + + + + + +
+ ) +}; + +/** + * + * With regard to positioning, we also have several variables that give us different results. + * + */ +export const Placement: Story = { + render: () => ( +
+
+ + + + + + + + + +
+
+
+ + + + + + + + + +
+
+ + + + + + + + + +
+
+
+ + + + + + + + + +
+
+ ) +}; + +/** + * + * We can give it a delay time for both entry and exit thanks to the `delayShow` and `delayHide` props. + * + */ +export const delay: Story = { + render: () => ( +
+ + + + + + + + + +
+ ) +}; + +/** + * + * With the `complement` property, we can add a small arrow depending on the positioning we use. + * + */ + +export const withArrow: Story = { + render: () => ( +
+ + + + + + + + + + + + +
+ ) +}; + +/** + * We can adjust the text to make it easier to read. + */ +export const width: Story = { + render: () => ( +
+ + + + + + + + + +
+ ) +}; + +/** + * The tooltip is interactive by default (to comply with WCAG 2.1 success criterion 1.4.13). It will not close when the user hovers over the pop-up description before the leaveDelay expires. You can disable this behaviour (thus failing to meet the success criterion required to achieve AA level) by passing disableInteractive. + */ + +export const DisabledIteractive: Story = { + render: () => ( +
+ + + + + + +
+ ) +}; + +/** + * We can define the event that will trigger the tooltip. + */ +export const Triggers: Story = { + render: () => ( +
+ + + + + + +
+ ) +}; diff --git a/src/components/atoms/tooltip/Tooltip.tsx b/src/components/atoms/tooltip/Tooltip.tsx new file mode 100644 index 00000000..96259cdd --- /dev/null +++ b/src/components/atoms/tooltip/Tooltip.tsx @@ -0,0 +1,63 @@ +import { cn } from '@/lib/utils'; +import type { FC } from 'react'; +import { createPortal } from 'react-dom'; +import type { TooltipProps } from './types'; +import { useTooltip } from './useTooltip'; + +const Tooltip: FC = ({ ...props }) => { + const { + content, + tooltipClass, + showTooltip, + showClickTooltip, + hideTooltip, + hideClickTooltip, + isVisible, + triggerRef, + tooltipRef, + position, + children, + animationHidde, + disabled, + onFocus, + onClick + } = useTooltip(props); + + return ( +
+
+ {children} + + {isVisible && + createPortal( +

+ {content} +

, + document.body + )} +
+
+ ); +}; + +export default Tooltip; diff --git a/src/components/atoms/tooltip/index.ts b/src/components/atoms/tooltip/index.ts new file mode 100644 index 00000000..2caa9047 --- /dev/null +++ b/src/components/atoms/tooltip/index.ts @@ -0,0 +1,3 @@ +import Tooltip from './Tooltip'; +export * from './types'; +export default Tooltip; diff --git a/src/components/atoms/tooltip/types.ts b/src/components/atoms/tooltip/types.ts new file mode 100644 index 00000000..9a7614a3 --- /dev/null +++ b/src/components/atoms/tooltip/types.ts @@ -0,0 +1,98 @@ +import { cva } from 'class-variance-authority'; +import type { ReactNode } from 'react'; +export const tooltipVariants = cva( + [' absolute ', ' py-1.5 px-2.5 ', ' bg-gray-600 text-[.8rem] text-white text-center ', ' animate-fadeIn'], + { + variants: { + color: { + default: 'dark:bg-gray-300 dark:text-gray-800 bg-gray-600', + primary: 'dark:bg-red-300 dark:text-gray-800 bg-red-600', + success: 'dark:bg-green-light dark:text-gray-800 bg-green-dark', + warning: 'dark:bg-yellow-light dark:text-gray-800 bg-yellow-dark', + transparent: ' dark:text-gray-300 bg-tranparent dark:bg-tranparent text-gray-800' + }, + complement: { + default: ' ', + 'arrow-bottom': + 'after:absolute after:bottom-[-5px] after:left-1/2 after:-translate-x-1/2 after:rotate-45 after:w-[10px] after:h-[10px] dark:after:bg-inherit after:bg-inherit ', + 'arrow-left': + 'after:absolute after:bottom-[-5px] after:left-0 after:-translate-x-1/2 after:-translate-y-1/2 after:top-1/2 after:rotate-45 after:w-[10px] after:h-[10px] dark:after:bg-inherit after:bg-inherit ', + 'arrow-right': + 'after:absolute after:bottom-[-5px] after:right-0 after:translate-x-1/2 after:-translate-y-1/2 after:top-1/2 after:rotate-45 after:w-[10px] after:h-[10px] dark:after:bg-inherit after:bg-inherit ', + 'arrow-top': + 'after:absolute after:top-[-5px] after:left-1/2 after:-translate-x-1/2 after:rotate-45 after:w-[10px] after:h-[10px] dark:after:bg-inherit after:bg-inherit ' + }, + width: { + default: 'w-max', + md: 'w-[200px]', + xl: 'w-[500px]' + } + }, + defaultVariants: {} + } +); +type TooltipColor = 'default' | 'primary' | 'success' | 'warning' | 'transparent'; + +type TooltipComplement = 'default' | 'arrow-bottom' | 'arrow-left' | 'arrow-right' | 'arrow-top'; + +type TooltipWidth = 'default' | 'md' | 'xl'; + +export type TooltipProps = { + /** @control text */ + content?: string; + + children?: ReactNode; + + /** + * @control select + * @default primary + */ + color?: TooltipColor; + + /** @control select */ + placement?: + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'left' + | 'left-start' + | 'left-end' + | 'right' + | 'right-start' + | 'right-end'; + + /** @control text */ + delayShow?: number; + + /** @control text */ + delayHide?: number; + + /** @control select */ + complement?: TooltipComplement; + + /** + * @control select + * @default default + */ + width?: TooltipWidth; + + /** + * @control boolean + * @default true + */ + disabled?: boolean; + + /** + * @control boolean + * @default false + */ + onFocus?: boolean; + /** + * @control boolean + * @default true + */ + onClick?: boolean; +}; diff --git a/src/components/atoms/tooltip/useTooltip.ts b/src/components/atoms/tooltip/useTooltip.ts new file mode 100644 index 00000000..a5ce8d7d --- /dev/null +++ b/src/components/atoms/tooltip/useTooltip.ts @@ -0,0 +1,186 @@ +import { useLayoutEffect, useRef, useState } from 'react'; +import { type TooltipProps, tooltipVariants } from './types'; + +export const useTooltip = ({ + content = 'I`m a tooltip', + children = null, + placement = 'top', + delayShow, + delayHide = 50, + complement = 'default', + width = 'default', + color = 'default', + disabled, + onFocus, + onClick +}: TooltipProps) => { + const tooltipClass = tooltipVariants({ complement, width, color }); + const [isVisible, setIsVisible] = useState(false); + const [animationHidde, setAnimationHide] = useState(false); + const [position, setPosition] = useState<{ top: number; left: number }>({ + top: 0, + left: 0 + }); + + const showTimeout = useRef | null>(null); + const hideTimeout = useRef | null>(null); + + const clearShow = () => { + if (showTimeout.current) { + clearTimeout(showTimeout.current); + showTimeout.current = null; + } + }; + + const clearHide = () => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + hideTimeout.current = null; + } + }; + + const showTooltip = () => { + clearShow(); + clearHide(); + setAnimationHide(false); + showTimeout.current = setTimeout(() => { + setIsVisible(true); + }, delayShow); + }; + + const showClickTooltip = () => { + setIsVisible(true); + setAnimationHide(false); + }; + + const hideClickTooltip = () => { + setTimeout(() => { + setIsVisible(false); + }, 200); + setAnimationHide(true); + }; + const hideTooltip = () => { + clearShow(); + clearHide(); + hideTimeout.current = setTimeout(() => { + hideTimeout.current = setTimeout(() => { + setIsVisible(false); + }, 200); + setAnimationHide(true); + }, delayHide); + }; + + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + + useLayoutEffect(() => { + const updatePosition = () => { + if (!isVisible || !triggerRef.current || !tooltipRef.current) { + return; + } + + const triggerSize = triggerRef.current.getBoundingClientRect(); + const tooltipSize = tooltipRef.current.getBoundingClientRect(); + const offset = 10; + + const positionTooltip: Record, () => { top: number; left: number }> = { + top: () => { + return { + top: triggerSize.top + window.scrollY - tooltipSize.height - offset, + left: triggerSize.left + window.scrollX + triggerSize.width / 2 - tooltipSize.width / 2 + }; + }, + 'top-start': () => ({ + top: triggerSize.top + window.scrollY - tooltipSize.height - offset, + left: triggerSize.left + window.scrollX + }), + 'top-end': () => ({ + top: triggerSize.top + window.scrollY - tooltipSize.height - offset, + left: triggerSize.right + window.scrollX - tooltipSize.width + }), + bottom: () => { + // Lógica de cálculo para 'bottom' + return { + top: triggerSize.bottom + window.scrollY + offset, + left: triggerSize.left + window.scrollX + triggerSize.width / 2 - tooltipSize.width / 2 + }; + }, + + 'bottom-start': () => ({ + top: triggerSize.bottom + window.scrollY + offset, + left: triggerSize.left + window.scrollX + }), + 'bottom-end': () => ({ + top: triggerSize.bottom + window.scrollY + offset, + left: triggerSize.right + window.scrollX - tooltipSize.width + }), + left: () => { + // Lógica de cálculo para 'left' + return { + top: triggerSize.top + window.scrollY + triggerSize.height / 2 - tooltipSize.height / 2, + left: triggerSize.left + window.scrollX - tooltipSize.width - offset + }; + }, + 'left-start': () => ({ + top: triggerSize.top + window.scrollY, + left: triggerSize.left + window.scrollX - tooltipSize.width - offset + }), + 'left-end': () => ({ + top: triggerSize.bottom + window.scrollY - tooltipSize.height, + left: triggerSize.left + window.scrollX - tooltipSize.width - offset + }), + right: () => { + // Lógica de cálculo para 'right' + return { + top: triggerSize.top + window.scrollY + triggerSize.height / 2 - tooltipSize.height / 2, + left: triggerSize.right + window.scrollX + offset + }; + }, + 'right-start': () => ({ + top: triggerSize.top + window.scrollY, + left: triggerSize.right + window.scrollX + offset + }), + 'right-end': () => ({ + top: triggerSize.bottom + window.scrollY - tooltipSize.height, + left: triggerSize.right + window.scrollX + offset + }) + }; + + const calcFunction = positionTooltip[placement]; + + setPosition(calcFunction()); + }; + + if (isVisible) { + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + } + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + }, [isVisible]); + + return { + content, + tooltipClass, + showTooltip, + showClickTooltip, + hideTooltip, + hideClickTooltip, + isVisible, + triggerRef, + tooltipRef, + position, + placement, + children, + animationHidde, + complement, + width, + disabled, + onFocus, + onClick + }; +};