From c6b44eea8772f7be6dab8391e0b2c6c568985492 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Fri, 13 May 2022 11:54:10 +0800 Subject: [PATCH] refactor(console): use-position in tooltip (#802) --- .../CopyToClipboard/index.module.scss | 9 +- .../src/components/CopyToClipboard/index.tsx | 4 +- .../console/src/components/Dropdown/index.tsx | 29 ++- .../src/components/Dropdown/use-position.ts | 49 ---- .../src/components/FormField/index.tsx | 2 +- .../src/components/Tooltip/index.module.scss | 25 +- .../console/src/components/Tooltip/index.tsx | 88 ++++--- packages/console/src/hooks/use-position.ts | 216 ++++++++++++++++++ .../components/SenderTester/index.module.scss | 9 +- .../components/SenderTester/index.tsx | 6 +- .../components/GetStartedProgress/index.tsx | 2 +- 11 files changed, 313 insertions(+), 126 deletions(-) delete mode 100644 packages/console/src/components/Dropdown/use-position.ts create mode 100644 packages/console/src/hooks/use-position.ts diff --git a/packages/console/src/components/CopyToClipboard/index.module.scss b/packages/console/src/components/CopyToClipboard/index.module.scss index 4b5e6c3cb..0ba0cc0a1 100644 --- a/packages/console/src/components/CopyToClipboard/index.module.scss +++ b/packages/console/src/components/CopyToClipboard/index.module.scss @@ -47,11 +47,6 @@ } } -div.successTooltip { - background: #008a71; - color: #fff; - - &::after { - border-top-color: #008a71; - } +.successfulTooltip { + background-color: var(--color-success-60); } diff --git a/packages/console/src/components/CopyToClipboard/index.tsx b/packages/console/src/components/CopyToClipboard/index.tsx index 49993be6f..f277881e6 100644 --- a/packages/console/src/components/CopyToClipboard/index.tsx +++ b/packages/console/src/components/CopyToClipboard/index.tsx @@ -56,9 +56,9 @@ const CopyToClipboard = ({ value, className, variant = 'contained' }: Props) => {variant === 'icon' ? null : value} diff --git a/packages/console/src/components/Dropdown/index.tsx b/packages/console/src/components/Dropdown/index.tsx index eb56b5c10..d4f974e4b 100644 --- a/packages/console/src/components/Dropdown/index.tsx +++ b/packages/console/src/components/Dropdown/index.tsx @@ -2,8 +2,9 @@ import classNames from 'classnames'; import React, { ReactNode, RefObject, useRef } from 'react'; import ReactModal from 'react-modal'; +import usePosition, { HorizontalAlignment } from '@/hooks/use-position'; + import * as styles from './index.module.scss'; -import usePosition, { HorizontalAlignment } from './use-position'; export { default as DropdownItem } from './DropdownItem'; @@ -28,26 +29,32 @@ const Dropdown = ({ isFullWidth, className, titleClassName, - horizontalAlign, + horizontalAlign = 'end', }: Props) => { const overlayRef = useRef(null); - const { position, mutate } = usePosition(anchorRef, overlayRef, horizontalAlign); + const { position, positionState, mutate } = usePosition({ + verticalAlign: 'bottom', + horizontalAlign, + offset: { vertical: 4, horizontal: 0 }, + anchorRef, + overlayRef, + }); return ( , - overlayRef: RefObject, - horizontalAlign: HorizontalAlignment = 'left' -) { - const [position, setPosition] = useState(); - const isRightAligned = horizontalAlign === 'right'; - - const updatePosition = useCallback(() => { - if (anchorRef.current && overlayRef.current) { - const anchor = anchorRef.current.getBoundingClientRect(); - const overlay = overlayRef.current.getBoundingClientRect(); - const isOnTop = anchor.y + anchor.height + overlay.height > window.innerHeight - safePadding; - const isOnLeft = anchor.x + overlay.width > window.innerWidth - safePadding; - const left = isOnLeft || isRightAligned ? anchor.x + anchor.width - overlay.width : anchor.x; - const top = isOnTop - ? anchor.y - overlay.height - distance - : anchor.y + anchor.height + distance; - setPosition({ left, top, width: anchor.width, isOnTop }); - } - }, [anchorRef, isRightAligned, overlayRef]); - - useLayoutEffect(() => { - updatePosition(); - window.addEventListener('resize', updatePosition); - - return () => { - window.removeEventListener('resize', updatePosition); - }; - }, [updatePosition]); - - return { position, mutate: updatePosition }; -} diff --git a/packages/console/src/components/FormField/index.tsx b/packages/console/src/components/FormField/index.tsx index e6f673c4f..a0447d216 100644 --- a/packages/console/src/components/FormField/index.tsx +++ b/packages/console/src/components/FormField/index.tsx @@ -29,7 +29,7 @@ const FormField = ({ title, children, isRequired, className, tooltip }: Props) = {tooltip && (
- +
)} diff --git a/packages/console/src/components/Tooltip/index.module.scss b/packages/console/src/components/Tooltip/index.module.scss index 371d0558d..d7ec1bc97 100644 --- a/packages/console/src/components/Tooltip/index.module.scss +++ b/packages/console/src/components/Tooltip/index.module.scss @@ -1,12 +1,11 @@ @use '@/scss/underscore' as _; -.container { +.tooltip { position: absolute; - transform: translate(-50%, -100%); border-radius: 8px; - background: #191c1d; - color: #e0e3e3; - box-shadow: var(--light-shadow-s1); + background: var(--color-on-background); + color: var(--color-background); + box-shadow: var(--shadow-1); padding: _.unit(2) _.unit(3); font: var(--font-body-medium); @@ -14,12 +13,16 @@ content: ''; display: block; position: absolute; - border-width: 4px; - border-style: solid; - border-color: #191c1d transparent transparent; - left: 50%; + width: 10px; + height: 10px; top: 100%; - transform: translateX(-50%); - box-shadow: var(--light-shadow-s1); + left: 50%; + background-color: inherit; + border-radius: _.unit(0.5) 0 _.unit(0.5); + transform: translate(-50%, -50%) rotate(45deg); + } + + &.arrowUp::after { + top: 0%; } } diff --git a/packages/console/src/components/Tooltip/index.tsx b/packages/console/src/components/Tooltip/index.tsx index a260fce06..e1c8258fa 100644 --- a/packages/console/src/components/Tooltip/index.tsx +++ b/packages/console/src/components/Tooltip/index.tsx @@ -1,57 +1,67 @@ -import { Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; -import React, { ReactNode, RefObject, useEffect, useState } from 'react'; +import React, { ReactNode, RefObject, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import usePosition from '@/hooks/use-position'; + import * as styles from './index.module.scss'; -type Props = { +type Props = { content: ReactNode; - domRef: RefObject>; + anchorRef: RefObject; className?: string; - behavior?: 'visibleOnHover' | 'visibleByDefault'; + isKeepOpen?: boolean; }; -type Position = { - top: number; - left: number; -}; - -const Tooltip = ({ - content, - domRef, - className, - behavior = 'visibleOnHover', -}: Props) => { +const Tooltip = ({ content, anchorRef, className, isKeepOpen = false }: Props) => { const [tooltipDom, setTooltipDom] = useState(); - const [position, setPosition] = useState(); - const positionCaculated = position !== undefined; + const tooltipRef = useRef(null); + + const { position, positionState, mutate } = usePosition({ + verticalAlign: 'top', + horizontalAlign: 'center', + offset: { vertical: 12, horizontal: 0 }, + anchorRef, + overlayRef: tooltipRef, + }); + + const [showUp, setShowUp] = useState(false); useEffect(() => { - if (!domRef.current) { + if (!showUp) { return; } - const dom = domRef.current; + const mutateAnimationFrame = requestAnimationFrame(() => { + mutate(); + }); - if (behavior === 'visibleByDefault') { - const { top, left, width } = domRef.current.getBoundingClientRect(); - const { scrollTop, scrollLeft } = document.documentElement; - setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 }); + return () => { + cancelAnimationFrame(mutateAnimationFrame); + }; + }, [showUp, mutate]); + + useEffect(() => { + if (!anchorRef.current) { + return; + } + + if (isKeepOpen) { + setShowUp(true); return; } + const dom = anchorRef.current; + const enterHandler = () => { - if (domRef.current) { - const { top, left, width } = domRef.current.getBoundingClientRect(); - const { scrollTop, scrollLeft } = document.documentElement; - setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 }); + if (!showUp) { + setShowUp(true); } }; const leaveHandler = () => { - setPosition(undefined); + setShowUp(false); }; dom.addEventListener('mouseenter', enterHandler); @@ -61,10 +71,10 @@ const Tooltip = ({ dom.removeEventListener('mouseenter', enterHandler); dom.removeEventListener('mouseleave', leaveHandler); }; - }, [domRef, behavior]); + }, [anchorRef, showUp, isKeepOpen]); useEffect(() => { - if (!positionCaculated) { + if (!showUp) { if (tooltipDom) { tooltipDom.remove(); setTooltipDom(undefined); @@ -80,14 +90,24 @@ const Tooltip = ({ } return () => tooltipDom?.remove(); - }, [positionCaculated, tooltipDom]); + }, [showUp, tooltipDom]); - if (!tooltipDom || !position) { + useLayoutEffect(() => { + mutate(); + }, [content, mutate]); + + if (!tooltipDom) { return null; } + const isArrowUp = positionState.verticalAlign === 'bottom'; + return createPortal( -
+
{content}
, tooltipDom diff --git a/packages/console/src/hooks/use-position.ts b/packages/console/src/hooks/use-position.ts new file mode 100644 index 000000000..819fd9653 --- /dev/null +++ b/packages/console/src/hooks/use-position.ts @@ -0,0 +1,216 @@ +import { RefObject, useCallback, useEffect, useState } from 'react'; + +export type VerticalAlignment = 'top' | 'bottom'; + +export type HorizontalAlignment = 'start' | 'center' | 'end'; + +type Offset = { + vertical: number; + horizontal: number; +}; + +type Props = { + verticalAlign: VerticalAlignment; + horizontalAlign: HorizontalAlignment; + offset: Offset; + anchorRef: RefObject; + overlayRef: RefObject; +}; + +type Position = { + top: number; + left: number; +}; + +// Leave space for box-shadow effect. +const windowSafePadding = 12; + +const selectVerticalAlignment = ({ + verticalAlign, + verticalTop, + verticalBottom, + overlayHeight, +}: { + verticalAlign: VerticalAlignment; + verticalTop: number; + verticalBottom: number; + overlayHeight: number; +}) => { + const minY = windowSafePadding; + const maxY = window.innerHeight - windowSafePadding; + + const isTopAllowed = verticalTop >= minY; + const isBottomAllowed = verticalBottom + overlayHeight <= maxY; + + if (verticalAlign === 'top') { + if (isTopAllowed) { + return 'top'; + } + + return isBottomAllowed ? 'bottom' : 'top'; + } + + // Mark: VerticalAlign === 'bottom' + if (isBottomAllowed) { + return 'bottom'; + } + + return isTopAllowed ? 'top' : 'bottom'; +}; + +const selectHorizontalAlignment = ({ + horizontalAlign, + horizontalStart, + horizontalCenter, + horizontalEnd, + overlayWidth, +}: { + horizontalAlign: HorizontalAlignment; + horizontalStart: number; + horizontalCenter: number; + horizontalEnd: number; + overlayWidth: number; +}) => { + const minX = windowSafePadding; + const maxX = window.innerWidth - windowSafePadding; + + const isStartAllowed = horizontalStart + overlayWidth <= maxX; + const isCenterAllowed = + horizontalCenter - overlayWidth / 2 >= minX && horizontalCenter + overlayWidth / 2 <= maxX; + const isEndAllowed = horizontalEnd >= minX; + + switch (horizontalAlign) { + case 'start': { + if (isStartAllowed) { + return 'start'; + } + + if (isEndAllowed) { + return 'end'; + } + + if (isCenterAllowed) { + return 'center'; + } + + return horizontalAlign; + } + + case 'center': { + if (isCenterAllowed) { + return 'center'; + } + + if (isStartAllowed) { + return 'start'; + } + + if (isEndAllowed) { + return 'end'; + } + + return horizontalAlign; + } + + case 'end': { + if (isEndAllowed) { + return 'end'; + } + + if (isStartAllowed) { + return 'start'; + } + + if (isCenterAllowed) { + return 'center'; + } + + return horizontalAlign; + } + + default: { + return horizontalAlign; + } + } +}; + +export default function usePosition({ + verticalAlign, + horizontalAlign, + offset, + anchorRef, + overlayRef, +}: Props) { + const [position, setPosition] = useState(); + const [currentVerticalAlign, setCurrentVerticalAlign] = useState(verticalAlign); + const [currentHorizontalAlign, setCurrentHorizontalAlign] = useState(horizontalAlign); + + const updatePosition = useCallback(() => { + if (!anchorRef.current || !overlayRef.current) { + return; + } + + const anchorRect = anchorRef.current.getBoundingClientRect(); + const overlayRect = overlayRef.current.getBoundingClientRect(); + const { scrollTop, scrollLeft } = document.documentElement; + + const verticalTop = anchorRect.y - overlayRect.height + scrollTop - offset.vertical; + const verticalBottom = anchorRect.y + anchorRect.height + scrollTop + offset.vertical; + + const verticalPositionMap = { top: verticalTop, bottom: verticalBottom }; + + const horizontalStart = anchorRect.x + scrollLeft + offset.horizontal; + const horizontalCenter = + anchorRect.x + anchorRect.width / 2 - overlayRect.width / 2 + scrollLeft + offset.horizontal; + const horizontalEnd = + anchorRect.x + anchorRect.width - overlayRect.width + scrollLeft + offset.horizontal; + + const horizontalPositionMap = { + start: horizontalStart, + center: horizontalCenter, + end: horizontalEnd, + }; + + const selectedVerticalAlign = selectVerticalAlignment({ + verticalAlign, + verticalTop, + verticalBottom, + overlayHeight: overlayRect.height, + }); + + const selectedHorizontalAlign = selectHorizontalAlignment({ + horizontalAlign, + horizontalStart, + horizontalCenter, + horizontalEnd, + overlayWidth: overlayRect.width, + }); + + setCurrentVerticalAlign(selectedVerticalAlign); + setCurrentHorizontalAlign(selectedHorizontalAlign); + setPosition({ + top: verticalPositionMap[selectedVerticalAlign], + left: horizontalPositionMap[selectedHorizontalAlign], + }); + }, [anchorRef, horizontalAlign, offset.vertical, offset.horizontal, overlayRef, verticalAlign]); + + useEffect(() => { + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + }, [updatePosition]); + + return { + position, + mutate: updatePosition, + positionState: { + verticalAlign: currentVerticalAlign, + horizontalAlign: currentHorizontalAlign, + }, + }; +} diff --git a/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.module.scss b/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.module.scss index a049f1d81..ba7a2d2e7 100644 --- a/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.module.scss +++ b/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.module.scss @@ -25,11 +25,6 @@ color: var(--color-caption); } -div.successTooltip { - background: #008a71; - color: #fff; - - &::after { - border-top-color: #008a71; - } +.successfulTooltip { + background-color: var(--color-success-60); } diff --git a/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx b/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx index 383ca58f7..0a0dcc002 100644 --- a/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx +++ b/packages/console/src/pages/ConnectorDetails/components/SenderTester/index.tsx @@ -102,9 +102,9 @@ const SenderTester = ({ connectorType }: Props) => {
{showTooltip && ( )} diff --git a/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx b/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx index e149bcd99..58e463b67 100644 --- a/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx +++ b/packages/console/src/pages/GetStarted/components/GetStartedProgress/index.tsx @@ -42,7 +42,7 @@ const GetStartedProgress = () => { anchorRef={anchorRef} className={styles.dropdown} isOpen={showDropDown} - horizontalAlign="right" + horizontalAlign="end" title={t('get_started.progress_dropdown_title')} titleClassName={styles.dropdownTitle} onClose={() => {