diff --git a/libs/ui/lib/tooltip/Tooltip.tsx b/libs/ui/lib/tooltip/Tooltip.tsx index 82322fb07..4c2446be5 100644 --- a/libs/ui/lib/tooltip/Tooltip.tsx +++ b/libs/ui/lib/tooltip/Tooltip.tsx @@ -1,10 +1,31 @@ +import { + FloatingPortal, + arrow, + autoPlacement, + autoUpdate, + flip, + offset, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, +} from '@floating-ui/react-dom-interactions' +import type { Placement } from '@floating-ui/react-dom-interactions' import cn from 'classnames' -import { useCallback, useEffect, useRef, useState } from 'react' -import { usePopper } from 'react-popper' +import { useRef, useState } from 'react' -import { KEYS } from '../../util/keys' import './tooltip.css' +/** + * This component allows either auto or manual placement of the tooltip. When `auto` is used, the + * tooltip will be placed in the best position based on the available space. When any other placement + * is used, the tooltip will be placed in that position but will also be flipped if there is not enough + * space for it to be displayed in that position. + */ +type PlacementOrAuto = Placement | 'auto' + export interface TooltipProps { id: string children?: React.ReactNode @@ -12,99 +33,84 @@ export interface TooltipProps { content: string | React.ReactNode onClick?: React.MouseEventHandler definition?: boolean + /** Defaults to auto if not supplied */ + placement?: PlacementOrAuto } -const ARROW_SIZE = 12 - export const Tooltip = ({ - id, children, content, - onClick, + placement = 'auto', definition = false, }: TooltipProps) => { - const referenceElement = useRef(null) - const popperElement = useRef(null) - const arrowElement = useRef(null) - const [isOpen, setIsOpen] = useState(false) - - const { attributes, styles, update } = usePopper( - referenceElement.current, - popperElement.current, - { - modifiers: [ - { name: 'arrow', options: { element: arrowElement.current } }, - { - name: 'offset', - options: { - offset: [0, ARROW_SIZE], - }, - }, - // disable eventListeners when closed for optimization - // (could make difference with many Tooltips on a single page) - { name: 'eventListeners', enabled: isOpen }, - ], - } - ) - - const openTooltip = () => { - setIsOpen(true) - if (update) { - // Update popper position - // (position will need to update after scrolling, for example) - update() - } - } - const closeTooltip = useCallback(() => setIsOpen(false), [setIsOpen]) + const [open, setOpen] = useState(false) + const arrowRef = useRef(null) - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - const { key } = event - switch (key) { - case KEYS.escape: - event.preventDefault() - // Close tooltip on escape - closeTooltip() - break - } - } + const { + x, + y, + reference, + floating, + strategy, + context, + placement: finalPlacement, + middlewareData, + } = useFloating({ + open, + onOpenChange: setOpen, + placement: placement === 'auto' ? undefined : placement, + whileElementsMounted: autoUpdate, + middleware: [ + /** + * `autoPlacement` and `flip` are mututally excusive behaviors. If we manually provide a placement we want to make sure + * it flips to the other side if there is not enough space for it to be displayed in that position. + */ + placement === 'auto' ? autoPlacement() : flip(), + offset(12), + arrow({ element: arrowRef, padding: 12 }), + ], + }) - window.addEventListener('keydown', handleKeyDown) + const { x: arrowX, y: arrowY } = middlewareData.arrow || {} - return function cleanup() { - window.removeEventListener('keydown', handleKeyDown) - } - }, [closeTooltip]) + const { getReferenceProps, getFloatingProps } = useInteractions([ + useHover(context, { move: false }), + useFocus(context), + useDismiss(context), + useRole(context, { role: 'tooltip' }), + ]) return ( <> -