import {useMemo, useRef, useState} from 'react'; import {PopperProps, usePopper} from 'react-popper'; import {detectOverflow, Modifier} from '@popperjs/core'; import type {ArrowModifier} from '@popperjs/core/lib/modifiers/arrow'; import type {FlipModifier} from '@popperjs/core/lib/modifiers/flip'; import type {PreventOverflowModifier} from '@popperjs/core/lib/modifiers/preventOverflow'; import {useButton as useButtonAria} from '@react-aria/button'; import { AriaOverlayProps, OverlayTriggerProps, useOverlay as useOverlayAria, useOverlayTrigger as useOverlayTriggerAria, } from '@react-aria/overlays'; import {mergeProps} from '@react-aria/utils'; import { OverlayTriggerProps as OverlayTriggerStateProps, useOverlayTriggerState, } from '@react-stately/overlays'; /** * PopperJS modifier to change the popper element's width/height to prevent * overflowing. Based on * https://github.com/atomiks/popper.js/blob/master/src/modifiers/maxSize.js */ const maxSize: Modifier<'maxSize', NonNullable> = { name: 'maxSize', phase: 'main', requiresIfExists: ['offset', 'preventOverflow', 'flip'], enabled: false, // will be enabled when overlay is open fn({state, name, options}) { const overflow = detectOverflow(state, options); const {x, y} = state.modifiersData.preventOverflow ?? {x: 0, y: 0}; const {width, height} = state.rects.popper; const [basePlacement] = state.placement.split('-'); const widthSide = basePlacement === 'left' ? 'left' : 'right'; const heightSide = basePlacement === 'top' ? 'top' : 'bottom'; const flippedWidthSide = basePlacement === 'left' ? 'right' : 'left'; const flippedHeightSide = basePlacement === 'top' ? 'bottom' : 'top'; const maxHeight = ['left', 'right'].includes(basePlacement) ? // If the main axis is horizontal, then maxHeight = the boundary's height height - overflow.top - overflow.bottom : // Otherwise, set max height unless there is enough space on the other side to // flip the popper to Math.max(height - overflow[heightSide] - y, -overflow[flippedHeightSide]); // If there is enough space on the other side, then allow the popper to flip // without constraining its size const maxWidth = ['top', 'bottom'].includes(basePlacement) ? // If the main axis is vertical, then maxWidth = the boundary's width width - overflow.left - overflow.right : // Otherwise, set max width unless there is enough space on the other side to // flip the popper to Math.max(width - overflow[widthSide] - x, -overflow[flippedWidthSide]); state.modifiersData[name] = { width: maxWidth, height: maxHeight, }; }, }; const applyMaxSize: Modifier<'applyMaxSize', {}> = { name: 'applyMaxSize', phase: 'beforeWrite', requires: ['maxSize'], enabled: false, // will be enabled when overlay is open fn({state}) { const {width, height} = state.modifiersData.maxSize; state.styles.popper.maxHeight = height; state.styles.popper.maxWidth = width; }, }; export interface UseOverlayProps extends Partial, Partial, Partial { /** * Options to pass to the `arrow` modifier. */ arrowOptions?: ArrowModifier['options']; disableTrigger?: boolean; /** * Options to pass to the `flip` modifier. */ flipOptions?: FlipModifier['options']; /** * Offset value. If a single number, determines the _distance_ along the main axis. If * an array of two numbers, the first number determines the _skidding_ along the alt * axis, and the second determines the _distance_ along the main axis. */ offset?: number | [number, number]; /** * To be called when the overlay closes because of a user interaction (click) outside * the overlay. Note: this won't be called when the user presses Escape to dismiss. */ onInteractOutside?: () => void; /** * Position for the overlay. */ position?: PopperProps['placement']; /** * Options to pass to the `preventOverflow` modifier. */ preventOverflowOptions?: PreventOverflowModifier['options']; } function useOverlay({ isOpen, onClose, defaultOpen, onOpenChange, type = 'dialog', offset = 8, position = 'top', arrowOptions = {}, flipOptions = {}, preventOverflowOptions = {}, isDismissable = true, shouldCloseOnBlur = false, isKeyboardDismissDisabled, shouldCloseOnInteractOutside, onInteractOutside, disableTrigger, }: UseOverlayProps = {}) { // Callback refs for react-popper const [triggerElement, setTriggerElement] = useState(null); const [overlayElement, setOverlayElement] = useState(null); const [arrowElement, setArrowElement] = useState(null); // Initialize open state const openState = useOverlayTriggerState({ isOpen, defaultOpen, onOpenChange: open => { open && popperUpdate?.(); onOpenChange?.(open); }, }); // Ref objects for react-aria (useOverlayTrigger & useOverlay) const triggerRef = useMemo(() => ({current: triggerElement}), [triggerElement]); const overlayRef = useMemo(() => ({current: overlayElement}), [overlayElement]); const modifiers = useMemo( () => [ { name: 'hide', enabled: false, }, { name: 'computeStyles', options: { // Using the `transform` attribute causes our borders to get blurry // in chrome. See [0]. This just causes it to use `top` / `left` // positions, which should be fine. // // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders gpuAcceleration: false, }, }, { name: 'arrow', options: { element: arrowElement, // Set padding to avoid the arrow reaching the side of the tooltip // and overflowing out of the rounded border padding: 4, ...arrowOptions, }, }, { name: 'flip', options: { // Only flip on main axis flipVariations: false, ...flipOptions, }, }, { name: 'offset', options: { offset: Array.isArray(offset) ? offset : [0, offset], }, }, { name: 'preventOverflow', enabled: true, options: { padding: 16, ...preventOverflowOptions, }, }, { ...maxSize, enabled: openState.isOpen, options: { padding: 16, ...preventOverflowOptions, }, }, { ...applyMaxSize, enabled: openState.isOpen, }, ], [arrowElement, offset, arrowOptions, flipOptions, preventOverflowOptions, openState] ); const { styles: popperStyles, state: popperState, update: popperUpdate, } = usePopper(triggerElement, overlayElement, {modifiers, placement: position}); // Get props for trigger button const {triggerProps, overlayProps: overlayTriggerAriaProps} = useOverlayTriggerAria( {type}, openState, triggerRef ); const {buttonProps: triggerAriaProps} = useButtonAria( {...triggerProps, isDisabled: disableTrigger}, triggerRef ); // Get props for overlay element const interactedOutside = useRef(false); const interactOutsideTrigger = useRef(null); const {overlayProps: overlayAriaProps} = useOverlayAria( { onClose: () => { onClose?.(); if (interactedOutside.current) { onInteractOutside?.(); interactedOutside.current = false; interactOutsideTrigger.current?.click(); interactOutsideTrigger.current = null; } openState.close(); }, isOpen: openState.isOpen, isDismissable, shouldCloseOnBlur, isKeyboardDismissDisabled, shouldCloseOnInteractOutside: target => { if ( target && triggerRef.current !== target && !triggerRef.current?.contains(target) && (shouldCloseOnInteractOutside?.(target) ?? true) ) { // Check if the target is inside a different overlay trigger. If yes, then we // should activate that trigger after this overlay has closed (see the onClose // prop above). This allows users to quickly jump between adjacent overlays. const closestOverlayTrigger = target.closest?.( 'button[aria-expanded="false"]' ); if (closestOverlayTrigger && closestOverlayTrigger !== triggerRef.current) { interactOutsideTrigger.current = closestOverlayTrigger; } else { interactOutsideTrigger.current = null; } interactedOutside.current = true; return true; } return false; }, }, overlayRef ); return { isOpen: openState.isOpen, state: openState, update: popperUpdate, triggerRef, triggerProps: { ref: setTriggerElement, ...triggerAriaProps, }, overlayRef, overlayProps: { ref: setOverlayElement, style: popperStyles.popper, ...mergeProps(overlayTriggerAriaProps, overlayAriaProps), }, arrowProps: { ref: setArrowElement, style: popperStyles.arrow, placement: popperState?.placement, }, }; } export default useOverlay;