diff --git a/docs/components/Tooltip.md b/docs/components/Tooltip.md new file mode 100644 index 0000000..d773bad --- /dev/null +++ b/docs/components/Tooltip.md @@ -0,0 +1,31 @@ +# `[Component] Tooltip` + +## `type` + +```ts +export interface TooltipProps { + children: React.ReactNode; + placement?: Placement; + content?: React.ReactNode; + offset?: number; + color?: NormalColorType; +} +``` + +## `example` + +```tsx +import { Tooltip } from '@wap-ui/react'; + +const App = () => { + return ( + <> + + + + + ); +}; +``` diff --git a/packages/react/package.json b/packages/react/package.json index e275ff4..1349c5a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@wap-ui/react", - "version": "1.4.0", + "version": "2.0.0", "description": "WAP UI / React UI Components Library", "repository": "https://github.com/pknu-wap/2022_2_WAP_WEB_TEAM1.git", "author": "neko113 ", diff --git a/packages/react/src/components/Tooltip/Tooltip.stories.tsx b/packages/react/src/components/Tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000..ed48829 --- /dev/null +++ b/packages/react/src/components/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,103 @@ +import styled from '@emotion/styled'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import React from 'react'; +import { Tooltip, TooltipProps } from './Tooltip'; +import { Button } from '../Button'; + +export default { + title: 'Components/Tooltip', + component: Tooltip, +} as ComponentMeta; + +const Template: ComponentStory = (args: TooltipProps) => ( + + + + + +); + +export const Default = Template.bind({}); + +Default.args = { + placement: 'top', + content: 'Boooooom!', +}; + +export const Placement = () => { + return ( + + + + + + + + + + + + + + + ); +}; + +export const Color = () => { + return ( + + + + + + + + + + + + + + + + + + ); +}; + +const FlexColumn = styled.div` + display: flex; + flex-direction: column; + gap: 2rem; + margin-left: 5rem; + margin-top: 2rem; +`; + +const Container = styled.div` + display: flex; + justify-content: center; + align-items: center; + height: 300px; + gap: 5rem; + margin-top: 5rem; +`; diff --git a/packages/react/src/components/Tooltip/Tooltip.styles.tsx b/packages/react/src/components/Tooltip/Tooltip.styles.tsx new file mode 100644 index 0000000..c3d9f5b --- /dev/null +++ b/packages/react/src/components/Tooltip/Tooltip.styles.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { NormalColorType, palette } from '../../theme'; +import { TooltipIconPlacement, TooltipPlacement } from './placement'; + +export const StyledTooltipTrigger = styled.div` + width: max-content; + display: inherit; +`; + +export const StyledTooltipArrow = styled.div<{ + tooltipIconPlacement: TooltipIconPlacement; +}>` + border-radius: 0 0 2px 0; + position: absolute; + width: 12px; + height: 12px; + background-color: #ccc; + opacity: 0; + transition: 0.1s ease-in-out; + + ${({ tooltipIconPlacement }) => css` + top: ${tooltipIconPlacement.top}; + left: ${tooltipIconPlacement.left}; + right: ${tooltipIconPlacement.right}; + bottom: ${tooltipIconPlacement.bottom}; + transform: ${tooltipIconPlacement.transform}; + `}; +`; + +export const StyledTooltipContent = styled.div<{ + visible: boolean; + color: NormalColorType; + tooltipPlacement: TooltipPlacement; +}>` + position: absolute; + border-radius: 14px; + padding: 8px 12px; + opacity: 0; + transition: 0.1s ease-in-out; + + ${({ tooltipPlacement }) => css` + top: calc(${tooltipPlacement.top} + 6px); + left: ${tooltipPlacement.left}; + transform: ${tooltipPlacement.transform}; + `}; + + ${({ visible, tooltipPlacement }) => + visible && + css` + opacity: 1; + top: ${tooltipPlacement.top}; + ${StyledTooltipArrow} { + opacity: 1; + } + `}; + + ${({ color }) => css` + background-color: ${palette[color]}; + ${StyledTooltipArrow} { + background-color: ${palette[color]}; + } + `}; +`; + +export const StyledTooltip = styled.div` + position: relative; + font-size: 14px; + color: #fff; + line-height: 1.5; +`; diff --git a/packages/react/src/components/Tooltip/Tooltip.tsx b/packages/react/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..b54549b --- /dev/null +++ b/packages/react/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,76 @@ +import { TooltipContent } from './TooltipContent'; +import * as S from './Tooltip.styles'; +import React, { useRef, useState } from 'react'; +import { Placement } from './placement'; +import { NormalColorType } from '../../theme'; + +export interface TooltipProps { + children: React.ReactNode; + placement?: Placement; + content?: React.ReactNode; + offset?: number; + color?: NormalColorType; +} + +/** + * @example + * ```tsx + * + * + * + * ``` + */ +export const Tooltip = ({ + children, + placement = 'top', + content, + offset, + color = 'primary', + ...props +}: TooltipProps) => { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + const timer = useRef(); + + const contentProps = { + placement, + parent: ref, + visible, + offset, + color, + }; + + const handleChangeVisible = (nextState: boolean) => { + const clear = () => { + clearTimeout(timer.current); + timer.current = undefined; + }; + const handler = (nextState: boolean) => { + setVisible(nextState); + clear(); + }; + + clear(); + if (nextState) { + timer.current = window.setTimeout(() => handler(true), 100); + + return; + } + timer.current = window.setTimeout(() => handler(false), 100); + }; + + return ( + handleChangeVisible(true)} + onMouseLeave={() => handleChangeVisible(false)} + {...props} + > + {children} + {content ? ( + {content} + ) : null} + {/* null을 사용하지 않으면, TooltipContent가 렌더링되지 않는다. */} + + ); +}; diff --git a/packages/react/src/components/Tooltip/TooltipContent.tsx b/packages/react/src/components/Tooltip/TooltipContent.tsx new file mode 100644 index 0000000..a79e7dd --- /dev/null +++ b/packages/react/src/components/Tooltip/TooltipContent.tsx @@ -0,0 +1,81 @@ +import React, { MutableRefObject, useEffect, useMemo, useState } from 'react'; +import * as S from './Tooltip.styles'; +import { createPortal } from 'react-dom'; +import usePortal from '../../hooks/usePortal'; +import { + Placement, + TooltipPlacement, + defaultTooltipPlacement, + getIconPlacement, + getPlacement, + getRect, +} from './placement'; +import { NormalColorType } from '../../theme'; + +interface TooltipContentProps { + placement?: Placement; + visible: boolean; + children?: React.ReactNode; + parent?: MutableRefObject | undefined; + offset?: number; + color?: NormalColorType; +} + +export const TooltipContent = ({ + placement = 'top', + visible, + children, + offset = 12, + color = 'primary', + parent, +}: TooltipContentProps) => { + const el = usePortal('tooltip'); + const [rect, setRect] = useState(defaultTooltipPlacement); + + if (!parent) return null; + + const updateRect = () => { + const pos = getPlacement(placement, getRect(parent), offset); + + setRect(pos); + }; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const { transform, top, left, right, bottom } = useMemo( + () => getIconPlacement(placement, 5), + [placement], + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + updateRect(); + }, [visible]); + + if (!el) return null; + + return createPortal( + + + + {children} + + , + el, + ); +}; diff --git a/packages/react/src/components/Tooltip/index.ts b/packages/react/src/components/Tooltip/index.ts new file mode 100644 index 0000000..b44d466 --- /dev/null +++ b/packages/react/src/components/Tooltip/index.ts @@ -0,0 +1 @@ +export { Tooltip } from './Tooltip'; diff --git a/packages/react/src/components/Tooltip/placement.ts b/packages/react/src/components/Tooltip/placement.ts new file mode 100644 index 0000000..7f4ac45 --- /dev/null +++ b/packages/react/src/components/Tooltip/placement.ts @@ -0,0 +1,139 @@ +import { MutableRefObject } from 'react'; + +export type Placement = 'top' | 'right' | 'bottom' | 'left'; + +interface ParentDomRect { + top: number; + left: number; + right: number; + bottom: number; + width: number; + height: number; +} + +interface ReactiveDomReact { + top: number; + bottom: number; + left: number; + right: number; + width: number; + height: number; +} + +// defaultRect는 보이지 않는 상태에서의 위치를 잡기 위한 값 +const defaultRect: ReactiveDomReact = { + top: -1000, + left: -1000, + right: -1000, + bottom: -1000, + width: 0, + height: 0, +}; + +export interface TooltipPlacement { + top: string; + left: string; + transform: string; +} + +export const defaultTooltipPlacement = { + top: '-1000px', + left: '-1000px', + transform: 'none', +}; + +export interface TooltipIconPlacement { + top: string; + left: string; + right: string; + bottom: string; + transform: string; +} + +export const getRect = (ref: MutableRefObject) => { + if (!ref || !ref.current) return defaultRect; + + const rect = ref.current.getBoundingClientRect(); + + return { + ...rect, + width: rect.width || rect.right - rect.left, + height: rect.height || rect.bottom - rect.top, + top: rect.top + document.documentElement.scrollTop, + bottom: rect.bottom + document.documentElement.scrollTop, + left: rect.left + document.documentElement.scrollLeft, + right: rect.right + document.documentElement.scrollLeft, + }; +}; + +export const getPlacement = ( + placement: Placement, + rect: ParentDomRect, + offset: number, +) => { + const placements = { + top: { + top: `${rect.top - offset}px`, + left: `${rect.left + rect.width / 2}px`, + transform: 'translate(-50%, -100%)', + }, + bottom: { + top: `${rect.bottom + offset}px`, + left: `${rect.left + rect.width / 2}px`, + transform: 'translate(-50%, 0)', + }, + left: { + top: `${rect.top + rect.height / 2}px`, + left: `${rect.left - offset}px`, + transform: 'translate(-100%, -50%)', + }, + right: { + top: `${rect.top + rect.height / 2}px`, + left: `${rect.right + offset}px`, + transform: 'translate(0, -50%)', + }, + }; + + return placements[placement]; +}; + +export const getIconPlacement = ( + placement: Placement, + offset: number, +): TooltipIconPlacement => { + const placements = { + top: { + top: 'auto', + right: 'auto', + left: '50%', + bottom: '0px', + transform: 'translate(-50%, 100%) rotate(45deg)', + }, + + bottom: { + top: '0px', + right: 'auto', + left: '50%', + bottom: 'auto', + transform: 'translate(-50%, -100%) rotate(225deg)', + }, + + left: { + top: '50%', + right: `-${offset - 1}px`, + left: 'auto', + bottom: 'auto', + transform: 'translate(100%, -50%) rotate(-45deg)', + }, + + right: { + top: '50%', + right: 'auto', + left: `-${offset - 1}px`, + bottom: 'auto', + transform: 'translate(-100%, -50%) rotate(135deg)', + }, + }; + + return placements[placement]; +}; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 7e5f3f5..f52fff4 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -10,3 +10,4 @@ export * from './Loader'; export * from './Toast'; export * from './Dropdown'; export * from './ScrollToTop'; +export * from './Tooltip';