UNPKG

rsuite

Version:

A suite of react components

369 lines (356 loc) 12.9 kB
'use client'; import _extends from "@babel/runtime/helpers/esm/extends"; import React, { useRef, useEffect, useImperativeHandle, useCallback, useState, useMemo, isValidElement, cloneElement } from 'react'; import get from 'lodash/get'; import isNil from 'lodash/isNil'; import isUndefined from 'lodash/isUndefined'; import contains from 'dom-lib/contains'; import Overlay from "./Overlay.js"; import { useOverlay } from "./OverlayProvider.js"; import { usePortal, useControlled } from "../hooks/index.js"; import { createChainedFunction, isOneOf } from "../utils/index.js"; import { isFragment } from "../utils/index.js"; function mergeEvents(events = {}, props = {}) { const nextEvents = {}; Object.keys(events).forEach(eventName => { if (events[eventName]) { nextEvents[eventName] = createChainedFunction(events[eventName], props === null || props === void 0 ? void 0 : props[eventName]); } }); return nextEvents; } /** * The reason that triggers closing of an overlay * - Clicking outside of the overlay * - Direct invocation of triggerRef.current.close() */ export let OverlayCloseCause = /*#__PURE__*/function (OverlayCloseCause) { OverlayCloseCause[OverlayCloseCause["ClickOutside"] = 0] = "ClickOutside"; OverlayCloseCause[OverlayCloseCause["ImperativeHandle"] = 1] = "ImperativeHandle"; return OverlayCloseCause; }({}); /** * Useful for mouseover and mouseout. * In order to resolve the node entering the mouseover element, a mouseout event and a mouseover event will be triggered. * https://javascript.info/mousemove-mouseover-mouseout-mouseenter-mouseleave * @param handler * @param event */ function onMouseEventHandler(handler, event, relatedNative) { const target = event.currentTarget; const related = event.relatedTarget || get(event, ['nativeEvent', relatedNative]); if ((!related || related !== target) && !contains(target, related)) { handler(event); } } const defaultTrigger = ['hover', 'focus']; /** * OverlayTrigger is used to display floating elements on another component. * @private */ const OverlayTrigger = /*#__PURE__*/React.forwardRef((props, ref) => { const { overlayContainer } = useOverlay(); const { children, container = overlayContainer, controlId, defaultOpen, trigger = defaultTrigger, disabled, followCursor, readOnly, plaintext, open: openProp, delay, delayOpen: delayOpenProp, delayClose: delayCloseProp, enterable, placement = 'bottomStart', speaker, rootClose = true, overlayAs: OverlayComponent, onClick, onMouseOver, onMouseMove, onMouseOut, onContextMenu, onFocus, onBlur, onOpen, onClose, onExited, ...rest } = props; const { Portal, target: containerElement } = usePortal({ container }); const triggerRef = useRef(null); const overlayRef = useRef(null); const [open, setOpen] = useControlled(openProp, defaultOpen); const [cursorPosition, setCursorPosition] = useState(null); // Delay the timer to close/open the overlay // When the cursor moves from the trigger to the overlay, the overlay will be closed. // In order to keep the overlay open, a timer is used to delay the closing. const delayOpenTimer = useRef(null); const delayCloseTimer = useRef(null); const delayOpen = isNil(delayOpenProp) ? delay : delayOpenProp; const delayClose = isNil(delayCloseProp) ? delay : delayCloseProp; // Whether the cursor is on the overlay const isOnOverlay = useRef(false); // Whether the cursor is on the trigger const isOnTrigger = useRef(false); useEffect(() => { return () => { if (!isNil(delayOpenTimer.current)) { clearTimeout(delayOpenTimer.current); } if (!isNil(delayCloseTimer.current)) { clearTimeout(delayCloseTimer.current); } }; }, []); // Whether the cursor is on the overlay const mouseEnter = useRef(false); const handleOpenChange = useCallback((nextOpen, closeCause) => { // if the overlay open state is not changed, do not fire the event if (nextOpen === open) return; if (nextOpen) { onOpen === null || onOpen === void 0 || onOpen(); } else { onClose === null || onClose === void 0 || onClose(closeCause); } setOpen(nextOpen); }, [open, onOpen, onClose, setOpen]); const handleOpen = useCallback(delay => { const ms = isUndefined(delay) ? delayOpen : delay; if (ms && typeof ms === 'number') { return delayOpenTimer.current = setTimeout(() => { delayOpenTimer.current = null; if (mouseEnter.current) { handleOpenChange(true); } }, ms); } handleOpenChange(true); }, [delayOpen, handleOpenChange]); const handleClose = useCallback((delay, closeCause) => { const ms = isUndefined(delay) ? delayClose : delay; if (ms && typeof ms === 'number') { return delayCloseTimer.current = setTimeout(() => { delayCloseTimer.current = null; handleOpenChange(false, closeCause); }, ms); } handleOpenChange(false, closeCause); }, [delayClose, handleOpenChange]); const handleExited = useCallback(() => { setCursorPosition(null); }, []); useImperativeHandle(ref, () => ({ get root() { return triggerRef.current; }, get overlay() { var _overlayRef$current; return (_overlayRef$current = overlayRef.current) === null || _overlayRef$current === void 0 ? void 0 : _overlayRef$current.child; }, getState: () => ({ open }), open: handleOpen, close: delay => handleClose(delay, OverlayCloseCause.ImperativeHandle), updatePosition: () => { var _overlayRef$current2, _overlayRef$current2$; (_overlayRef$current2 = overlayRef.current) === null || _overlayRef$current2 === void 0 || (_overlayRef$current2$ = _overlayRef$current2.updatePosition) === null || _overlayRef$current2$ === void 0 || _overlayRef$current2$.call(_overlayRef$current2); } })); /** * Close after the cursor leaves. */ const handleCloseWhenLeave = useCallback(() => { // When the cursor is not on the overlay and not on the trigger, it is closed. if (!isOnOverlay.current && !isOnTrigger.current) { handleClose(undefined, OverlayCloseCause.ClickOutside); } }, [handleClose]); const handleDelayedOpen = useCallback(() => { mouseEnter.current = true; if (!enterable) { return handleOpen(); } isOnTrigger.current = true; if (!isNil(delayCloseTimer.current)) { clearTimeout(delayCloseTimer.current); delayCloseTimer.current = null; return handleOpen(); } if (open) { return; } handleOpen(); }, [enterable, open, handleOpen]); /** * Toggle open and closed state. */ const handleOpenState = useCallback(() => { if (open) { handleCloseWhenLeave(); } else { handleDelayedOpen(); } }, [open, handleCloseWhenLeave, handleDelayedOpen]); const handleDelayedClose = useCallback(() => { mouseEnter.current = false; if (!enterable) { return handleClose(); } isOnTrigger.current = false; if (!isNil(delayOpenTimer.current)) { clearTimeout(delayOpenTimer.current); delayOpenTimer.current = null; return; } if (!open || !isNil(delayCloseTimer.current)) { return; } delayCloseTimer.current = setTimeout(() => { if (!isNil(delayCloseTimer.current)) { clearTimeout(delayCloseTimer.current); delayCloseTimer.current = null; } handleCloseWhenLeave(); }, 200); }, [enterable, open, handleClose, handleCloseWhenLeave]); const handleSpeakerMouseEnter = useCallback(() => { isOnOverlay.current = true; }, []); const handleSpeakerMouseLeave = useCallback(() => { isOnOverlay.current = false; if (!isOneOf('click', trigger) && !isOneOf('contextMenu', trigger) && !isOneOf('active', trigger)) { handleCloseWhenLeave(); } }, [handleCloseWhenLeave, trigger]); const handledMoveOverlay = useCallback(event => { setCursorPosition(() => ({ top: event.pageY, left: event.pageX, clientTop: event.clientX, clientLeft: event.clientY })); }, []); const handleMouseOver = useCallback(event => { onMouseEventHandler(handleDelayedOpen, event, 'fromElement'); }, [handleDelayedOpen]); const handleMouseOut = useCallback(event => { onMouseEventHandler(handleDelayedClose, event, 'toElement'); }, [handleDelayedClose]); const preventDefault = useCallback(event => { event.preventDefault(); }, []); const triggerEvents = useMemo(() => { // Pass events by props const events = { onClick, onContextMenu, onMouseOver, onMouseOut, onFocus, onBlur, onMouseMove }; // When trigger is disabled, no predefined event listeners are added. if (disabled || readOnly || plaintext || trigger === 'none') { return events; } // Get the cursor position through onMouseMove. // https://rsuitejs.com/components/tooltip/#follow-cursor if (followCursor) { events.onMouseMove = createChainedFunction(handledMoveOverlay, onMouseMove); } // The `click` event is usually used in `toggle` scenarios. // The first click will open and the second click will close. if (isOneOf('click', trigger)) { events.onClick = createChainedFunction(handleOpenState, events.onClick); return events; } // The difference between it and the click event is that it does not trigger the close. if (isOneOf('active', trigger)) { events.onClick = createChainedFunction(handleDelayedOpen, events.onClick); return events; } if (isOneOf('hover', trigger)) { events.onMouseOver = createChainedFunction(handleMouseOver, events.onMouseOver); events.onMouseOut = createChainedFunction(handleMouseOut, events.onMouseOut); } if (isOneOf('focus', trigger)) { events.onFocus = createChainedFunction(handleDelayedOpen, events.onFocus); events.onBlur = createChainedFunction(handleDelayedClose, events.onBlur); } if (isOneOf('contextMenu', trigger)) { events.onContextMenu = createChainedFunction(preventDefault, handleOpenState, events.onContextMenu); } return events; }, [disabled, followCursor, handleDelayedClose, handleDelayedOpen, handleMouseOut, handleMouseOver, handleOpenState, handledMoveOverlay, onBlur, onClick, onContextMenu, onFocus, onMouseMove, onMouseOut, onMouseOver, plaintext, preventDefault, readOnly, trigger]); const renderOverlay = () => { const overlayProps = { ...rest, rootClose, triggerTarget: triggerRef, onClose: trigger !== 'none' ? () => handleClose(undefined, OverlayCloseCause.ClickOutside) : undefined, onExited: createChainedFunction(followCursor ? handleExited : undefined, onExited), placement, container: containerElement, open }; const speakerProps = { id: controlId }; // The purpose of adding mouse entry and exit events to the Overlay is to record whether the current cursor is on the Overlay. // When `trigger` is equal to `hover`, if the cursor leaves the `triggerTarget` and stays on the Overlay, // the Overlay will continue to remain open. if (trigger !== 'none' && enterable) { speakerProps.onMouseEnter = handleSpeakerMouseEnter; speakerProps.onMouseLeave = handleSpeakerMouseLeave; } return /*#__PURE__*/React.createElement(Overlay, _extends({}, overlayProps, { ref: overlayRef, childrenProps: speakerProps, followCursor: followCursor, cursorPosition: cursorPosition }), typeof speaker === 'function' ? (props, ref) => { return speaker({ ...props, onClose: handleClose }, ref); } : speaker); }; const triggerElement = useMemo(() => { if (typeof children === 'function') { return children(triggerEvents, triggerRef); } else if (isFragment(children) || ! /*#__PURE__*/isValidElement(children)) { return /*#__PURE__*/React.createElement("span", _extends({ ref: triggerRef, "aria-describedby": controlId }, triggerEvents), children); } const childElement = children; return /*#__PURE__*/cloneElement(childElement, { ref: triggerRef, 'aria-describedby': controlId, ...mergeEvents(triggerEvents, childElement.props) }); }, [children, controlId, triggerEvents]); return /*#__PURE__*/React.createElement(React.Fragment, null, triggerElement, OverlayComponent ? /*#__PURE__*/React.createElement(OverlayComponent, { open: open, onClose: handleClose, placement: "bottom", speaker: speaker }) : /*#__PURE__*/React.createElement(Portal, null, renderOverlay())); }); OverlayTrigger.displayName = 'OverlayTrigger'; export default OverlayTrigger;