UNPKG

@fluentui/react

Version:

Reusable React components for building web experiences.

468 lines 28.3 kB
define(["require", "exports", "tslib", "react", "../../common/DirectionalHint", "../../Utilities", "../../utilities/positioning/positioning", "../../Positioning", "../../Popup", "../../Utilities", "../../Styling", "@fluentui/react-hooks", "../../utilities/dom"], function (require, exports, tslib_1, React, DirectionalHint_1, Utilities_1, positioning_1, Positioning_1, Popup_1, Utilities_2, Styling_1, react_hooks_1, dom_1) { "use strict"; var _a; Object.defineProperty(exports, "__esModule", { value: true }); exports.CalloutContentBase = void 0; var COMPONENT_NAME = 'CalloutContentBase'; var ANIMATIONS = (_a = {}, _a[Positioning_1.RectangleEdge.top] = Styling_1.AnimationClassNames.slideUpIn10, _a[Positioning_1.RectangleEdge.bottom] = Styling_1.AnimationClassNames.slideDownIn10, _a[Positioning_1.RectangleEdge.left] = Styling_1.AnimationClassNames.slideLeftIn10, _a[Positioning_1.RectangleEdge.right] = Styling_1.AnimationClassNames.slideRightIn10, _a); var BEAK_ORIGIN_POSITION = { top: 0, left: 0 }; // Microsoft Edge will overwrite inline styles if there is an animation pertaining to that style. // To help ensure that edge will respect the offscreen style opacity // filter needs to be added as an additional way to set opacity. // Also set pointer-events: none so that the callout will not occlude the element it is // going to be positioned against var OFF_SCREEN_STYLE = { opacity: 0, filter: 'opacity(0)', pointerEvents: 'none', }; // role and role description go hand-in-hand. Both would be included by spreading getNativeProps for a basic element // This constant array can be used to filter these out of native props spread on callout root and apply them together on // calloutMain (the Popup component within the callout) var ARIA_ROLE_ATTRIBUTES = ['role', 'aria-roledescription']; var DEFAULT_PROPS = { preventDismissOnLostFocus: false, preventDismissOnScroll: false, preventDismissOnResize: false, isBeakVisible: true, beakWidth: 16, gapSpace: 0, minPagePadding: 8, directionalHint: DirectionalHint_1.DirectionalHint.bottomAutoEdge, }; var getClassNames = (0, Utilities_2.classNamesFunction)({ disableCaching: true, // disabling caching because stylesProp.position mutates often }); /** * (Hook) to return a function to lazily fetch the bounds of the target element for the callout. */ function useBounds(_a, targetRef, targetWindow) { var bounds = _a.bounds, _b = _a.minPagePadding, minPagePadding = _b === void 0 ? DEFAULT_PROPS.minPagePadding : _b, target = _a.target; var _c = React.useState(false), targetWindowResized = _c[0], setTargetWindowResized = _c[1]; var cachedBounds = React.useRef(); var getBounds = React.useCallback(function () { if (!cachedBounds.current || targetWindowResized) { var currentBounds = typeof bounds === 'function' ? (targetWindow ? bounds(target, targetWindow) : undefined) : bounds; if (!currentBounds && targetWindow) { currentBounds = (0, Positioning_1.getBoundsFromTargetWindow)(targetRef.current, targetWindow); currentBounds = { top: currentBounds.top + minPagePadding, left: currentBounds.left + minPagePadding, right: currentBounds.right - minPagePadding, bottom: currentBounds.bottom - minPagePadding, width: currentBounds.width - minPagePadding * 2, height: currentBounds.height - minPagePadding * 2, }; } cachedBounds.current = currentBounds; targetWindowResized && setTargetWindowResized(false); } return cachedBounds.current; }, [bounds, minPagePadding, target, targetRef, targetWindow, targetWindowResized]); var async = (0, react_hooks_1.useAsync)(); (0, react_hooks_1.useOnEvent)(targetWindow, 'resize', async.debounce(function () { setTargetWindowResized(true); }, 500, { leading: true })); return getBounds; } /** * (Hook) to return the maximum available height for the Callout to render into. */ function useMaxHeight(_a, getBounds, targetRef, positions) { var _b; var calloutMaxHeight = _a.calloutMaxHeight, finalHeight = _a.finalHeight, directionalHint = _a.directionalHint, directionalHintFixed = _a.directionalHintFixed, hidden = _a.hidden, gapSpace = _a.gapSpace, beakWidth = _a.beakWidth, isBeakVisible = _a.isBeakVisible, coverTarget = _a.coverTarget; var _c = React.useState(), maxHeight = _c[0], setMaxHeight = _c[1]; var _d = (_b = positions === null || positions === void 0 ? void 0 : positions.elementPosition) !== null && _b !== void 0 ? _b : {}, top = _d.top, bottom = _d.bottom; var targetRect = (targetRef === null || targetRef === void 0 ? void 0 : targetRef.current) ? (0, positioning_1.getRectangleFromTarget)(targetRef.current) : undefined; React.useEffect(function () { var _a; var bounds = (_a = getBounds()) !== null && _a !== void 0 ? _a : {}; var topBounds = bounds.top; var bottomBounds = bounds.bottom; var calculatedHeight; // If aligned to top edge of target and not covering target, update bottom bounds to the // top of the target (accounting for gap space and beak) if ((positions === null || positions === void 0 ? void 0 : positions.targetEdge) === Positioning_1.RectangleEdge.top && (targetRect === null || targetRect === void 0 ? void 0 : targetRect.top) && !coverTarget) { bottomBounds = targetRect.top - (0, positioning_1.calculateGapSpace)(isBeakVisible, beakWidth, gapSpace); } if (typeof top === 'number' && bottomBounds) { calculatedHeight = bottomBounds - top; } else if (typeof bottom === 'number' && typeof topBounds === 'number' && bottomBounds) { calculatedHeight = bottomBounds - topBounds - bottom; } if ((!calloutMaxHeight && !hidden) || (calloutMaxHeight && calculatedHeight && calloutMaxHeight > calculatedHeight)) { setMaxHeight(calculatedHeight); } else if (calloutMaxHeight) { setMaxHeight(calloutMaxHeight); } else { setMaxHeight(undefined); } }, [ bottom, calloutMaxHeight, finalHeight, directionalHint, directionalHintFixed, getBounds, hidden, positions, top, gapSpace, beakWidth, isBeakVisible, targetRect, coverTarget, ]); return maxHeight; } /** * (Hook) to find the current position of Callout. If Callout is resized then a new position is calculated. */ function usePositions(props, hostElement, calloutElement, targetRef, getBounds, popupRef) { var _a = React.useState(), positions = _a[0], setPositions = _a[1]; var positionAttempts = React.useRef(0); var previousTarget = React.useRef(); var async = (0, react_hooks_1.useAsync)(); var hidden = props.hidden, target = props.target, finalHeight = props.finalHeight, calloutMaxHeight = props.calloutMaxHeight, onPositioned = props.onPositioned, directionalHint = props.directionalHint, hideOverflow = props.hideOverflow, preferScrollResizePositioning = props.preferScrollResizePositioning; var win = (0, dom_1.useWindowEx)(); var localRef = React.useRef(); var popupStyles; if (localRef.current !== popupRef.current) { localRef.current = popupRef.current; popupStyles = popupRef.current ? win === null || win === void 0 ? void 0 : win.getComputedStyle(popupRef.current) : undefined; } var popupOverflowY = popupStyles === null || popupStyles === void 0 ? void 0 : popupStyles.overflowY; React.useEffect(function () { if (!hidden) { var timerId_1 = async.requestAnimationFrame(function () { var _a, _b; if (hostElement.current && calloutElement) { var currentProps = tslib_1.__assign(tslib_1.__assign({}, props), { target: targetRef.current, bounds: getBounds() }); // duplicate calloutElement & remove useMaxHeight's maxHeight for position calc var dupeCalloutElement = calloutElement.cloneNode(true); dupeCalloutElement.style.maxHeight = calloutMaxHeight ? "".concat(calloutMaxHeight) : ''; dupeCalloutElement.style.visibility = 'hidden'; (_a = calloutElement.parentElement) === null || _a === void 0 ? void 0 : _a.appendChild(dupeCalloutElement); var previousPositions = previousTarget.current === target ? positions : undefined; // only account for scroll resizing if styles allow callout to scroll // (popup styles determine if callout will scroll) var isOverflowYHidden = hideOverflow || popupOverflowY === 'clip' || popupOverflowY === 'hidden'; var shouldScroll = preferScrollResizePositioning && !isOverflowYHidden; // If there is a finalHeight given then we assume that the user knows and will handle // additional positioning adjustments so we should call positionCard var newPositions = finalHeight ? (0, Positioning_1.positionCard)(currentProps, hostElement.current, dupeCalloutElement, previousPositions, win) : (0, Positioning_1.positionCallout)(currentProps, hostElement.current, dupeCalloutElement, previousPositions, shouldScroll, undefined, win); // clean up duplicate calloutElement (_b = calloutElement.parentElement) === null || _b === void 0 ? void 0 : _b.removeChild(dupeCalloutElement); // Set the new position only when the positions do not exist or one of the new callout positions // is different. The position should not change if the position is within 2 decimal places. if ((!positions && newPositions) || (positions && newPositions && !arePositionsEqual(positions, newPositions) && positionAttempts.current < 5)) { // We should not reposition the callout more than a few times, if it is then the content is likely resizing // and we should stop trying to reposition to prevent a stack overflow. positionAttempts.current++; setPositions(newPositions); } else if (positionAttempts.current > 0) { // Only call the onPositioned callback if the callout has been re-positioned at least once. positionAttempts.current = 0; onPositioned === null || onPositioned === void 0 ? void 0 : onPositioned(positions); } } }, calloutElement); previousTarget.current = target; return function () { async.cancelAnimationFrame(timerId_1); previousTarget.current = undefined; }; } else { // When the callout is hidden, clear position state so that it is not accidentally used next render. setPositions(undefined); positionAttempts.current = 0; } }, [ hidden, directionalHint, async, calloutElement, calloutMaxHeight, hostElement, targetRef, finalHeight, getBounds, onPositioned, positions, props, target, hideOverflow, preferScrollResizePositioning, popupOverflowY, win, ]); return positions; } /** * (Hook) to set up behavior to automatically focus the callout when it appears, if indicated by props. */ function useAutoFocus(_a, positions, calloutElement) { var hidden = _a.hidden, setInitialFocus = _a.setInitialFocus; var async = (0, react_hooks_1.useAsync)(); var hasPositions = !!positions; React.useEffect(function () { if (!hidden && setInitialFocus && hasPositions && calloutElement) { var timerId_2 = async.requestAnimationFrame(function () { return (0, Utilities_1.focusFirstChild)(calloutElement); }, calloutElement); return function () { return async.cancelAnimationFrame(timerId_2); }; } }, [hidden, hasPositions, async, calloutElement, setInitialFocus]); } /** * (Hook) to set up various handlers to dismiss the popup when it loses focus or the window scrolls or similar cases. */ function useDismissHandlers(_a, positions, hostElement, targetRef, targetWindow) { var hidden = _a.hidden, onDismiss = _a.onDismiss, // eslint-disable-next-line deprecation/deprecation preventDismissOnScroll = _a.preventDismissOnScroll, // eslint-disable-next-line deprecation/deprecation preventDismissOnResize = _a.preventDismissOnResize, // eslint-disable-next-line deprecation/deprecation preventDismissOnLostFocus = _a.preventDismissOnLostFocus, dismissOnTargetClick = _a.dismissOnTargetClick, shouldDismissOnWindowFocus = _a.shouldDismissOnWindowFocus, preventDismissOnEvent = _a.preventDismissOnEvent; var isMouseDownOnPopup = React.useRef(false); var async = (0, react_hooks_1.useAsync)(); var mouseDownHandlers = (0, react_hooks_1.useConst)([ function () { isMouseDownOnPopup.current = true; }, function () { isMouseDownOnPopup.current = false; }, ]); var positionsExists = !!positions; React.useEffect(function () { var dismissOnScroll = function (ev) { if (positionsExists && !preventDismissOnScroll) { dismissOnClickOrScroll(ev); } }; var dismissOnResize = function (ev) { if (!preventDismissOnResize && !(preventDismissOnEvent && preventDismissOnEvent(ev))) { onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss(ev); } }; var dismissOnLostFocus = function (ev) { if (!preventDismissOnLostFocus) { dismissOnClickOrScroll(ev); } }; var dismissOnClickOrScroll = function (ev) { var eventPaths = ev.composedPath ? ev.composedPath() : []; var target = eventPaths.length > 0 ? eventPaths[0] : ev.target; var isEventTargetOutsideCallout = hostElement.current && !(0, Utilities_1.elementContains)(hostElement.current, target); // If mouse is pressed down on callout but moved outside then released, don't dismiss the callout. if (isEventTargetOutsideCallout && isMouseDownOnPopup.current) { isMouseDownOnPopup.current = false; return; } if ((!targetRef.current && isEventTargetOutsideCallout) || (ev.target !== targetWindow && isEventTargetOutsideCallout && (!targetRef.current || 'stopPropagation' in targetRef.current || dismissOnTargetClick || (target !== targetRef.current && !(0, Utilities_1.elementContains)(targetRef.current, target))))) { if (preventDismissOnEvent && preventDismissOnEvent(ev)) { return; } onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss(ev); } }; var dismissOnTargetWindowBlur = function (ev) { // Do nothing if (!shouldDismissOnWindowFocus) { return; } if (((preventDismissOnEvent && !preventDismissOnEvent(ev)) || (!preventDismissOnEvent && !preventDismissOnLostFocus)) && !(targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.document.hasFocus()) && ev.relatedTarget === null) { onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss(ev); } }; // This is added so the callout will dismiss when the window is scrolled // but not when something inside the callout is scrolled. The delay seems // to be required to avoid React firing an async focus event in IE from // the target changing focus quickly prior to rendering the callout. var disposablesPromise = new Promise(function (resolve) { async.setTimeout(function () { if (!hidden && targetWindow) { var disposables_1 = [ (0, Utilities_1.on)(targetWindow, 'scroll', dismissOnScroll, true), (0, Utilities_1.on)(targetWindow, 'resize', dismissOnResize, true), (0, Utilities_1.on)(targetWindow.document.documentElement, 'focus', dismissOnLostFocus, true), (0, Utilities_1.on)(targetWindow.document.documentElement, 'click', dismissOnLostFocus, true), (0, Utilities_1.on)(targetWindow, 'blur', dismissOnTargetWindowBlur, true), ]; resolve(function () { disposables_1.forEach(function (dispose) { return dispose(); }); }); } }, 0); }); return function () { disposablesPromise.then(function (dispose) { return dispose(); }); }; }, [ hidden, async, hostElement, targetRef, targetWindow, onDismiss, shouldDismissOnWindowFocus, dismissOnTargetClick, preventDismissOnLostFocus, preventDismissOnResize, preventDismissOnScroll, positionsExists, preventDismissOnEvent, ]); return mouseDownHandlers; } exports.CalloutContentBase = React.memo(React.forwardRef(function (propsWithoutDefaults, forwardedRef) { var props = (0, Utilities_1.getPropsWithDefaults)(DEFAULT_PROPS, propsWithoutDefaults); var styles = props.styles, style = props.style, ariaLabel = props.ariaLabel, ariaDescribedBy = props.ariaDescribedBy, ariaLabelledBy = props.ariaLabelledBy, className = props.className, isBeakVisible = props.isBeakVisible, children = props.children, beakWidth = props.beakWidth, calloutWidth = props.calloutWidth, calloutMaxWidth = props.calloutMaxWidth, calloutMinWidth = props.calloutMinWidth, doNotLayer = props.doNotLayer, finalHeight = props.finalHeight, _a = props.hideOverflow, hideOverflow = _a === void 0 ? !!finalHeight : _a, backgroundColor = props.backgroundColor, calloutMaxHeight = props.calloutMaxHeight, onScroll = props.onScroll, // eslint-disable-next-line deprecation/deprecation _b = props.shouldRestoreFocus, // eslint-disable-next-line deprecation/deprecation shouldRestoreFocus = _b === void 0 ? true : _b, target = props.target, hidden = props.hidden, onLayerMounted = props.onLayerMounted, popupProps = props.popupProps; var hostElement = React.useRef(null); var popupRef = React.useRef(null); var mergedPopupRefs = (0, react_hooks_1.useMergedRefs)(popupRef, popupProps === null || popupProps === void 0 ? void 0 : popupProps.ref); var _c = React.useState(null), calloutElement = _c[0], setCalloutElement = _c[1]; var calloutCallback = React.useCallback(function (calloutEl) { setCalloutElement(calloutEl); }, []); var rootRef = (0, react_hooks_1.useMergedRefs)(hostElement, forwardedRef); var _d = (0, react_hooks_1.useTarget)(props.target, { current: calloutElement, }), targetRef = _d[0], targetWindow = _d[1]; var getBounds = useBounds(props, targetRef, targetWindow); var positions = usePositions(props, hostElement, calloutElement, targetRef, getBounds, mergedPopupRefs); var maxHeight = useMaxHeight(props, getBounds, targetRef, positions); var _e = useDismissHandlers(props, positions, hostElement, targetRef, targetWindow), mouseDownOnPopup = _e[0], mouseUpOnPopup = _e[1]; // do not set both top and bottom css props from positions // instead, use maxHeight var isForcedInBounds = (positions === null || positions === void 0 ? void 0 : positions.elementPosition.top) && (positions === null || positions === void 0 ? void 0 : positions.elementPosition.bottom); var cssPositions = tslib_1.__assign(tslib_1.__assign({}, positions === null || positions === void 0 ? void 0 : positions.elementPosition), { maxHeight: maxHeight }); if (isForcedInBounds) { cssPositions.bottom = undefined; } useAutoFocus(props, positions, calloutElement); React.useEffect(function () { if (!hidden) { onLayerMounted === null || onLayerMounted === void 0 ? void 0 : onLayerMounted(); } // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run if hidden changes }, [hidden]); // If there is no target window then we are likely in server side rendering and we should not render anything. if (!targetWindow) { return null; } var overflowYHidden = hideOverflow; var beakVisible = isBeakVisible && !!target; var classNames = getClassNames(styles, { theme: props.theme, className: className, overflowYHidden: overflowYHidden, calloutWidth: calloutWidth, positions: positions, beakWidth: beakWidth, backgroundColor: backgroundColor, calloutMaxWidth: calloutMaxWidth, calloutMinWidth: calloutMinWidth, doNotLayer: doNotLayer, }); var overflowStyle = tslib_1.__assign(tslib_1.__assign({ maxHeight: calloutMaxHeight ? calloutMaxHeight : '100%' }, style), (overflowYHidden && { overflowY: 'hidden' })); var visibilityStyle = props.hidden ? { visibility: 'hidden' } : undefined; // React.CSSProperties does not understand IRawStyle, so the inline animations will need to be cast as any for now. return (React.createElement("div", { ref: rootRef, className: classNames.container, style: visibilityStyle }, React.createElement("div", tslib_1.__assign({}, (0, Utilities_1.getNativeProps)(props, Utilities_1.divProperties, ARIA_ROLE_ATTRIBUTES), { className: (0, Utilities_1.css)(classNames.root, positions && positions.targetEdge && ANIMATIONS[positions.targetEdge]), style: positions ? tslib_1.__assign({}, cssPositions) : OFF_SCREEN_STYLE, // Safari and Firefox on Mac OS requires this to back-stop click events so focus remains in the Callout. // See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus tabIndex: -1, ref: calloutCallback }), beakVisible && React.createElement("div", { className: classNames.beak, style: getBeakPosition(positions) }), beakVisible && React.createElement("div", { className: classNames.beakCurtain }), React.createElement(Popup_1.Popup // don't use getNativeElementProps for role and roledescription because it will also // pass through data-* props (resulting in them being used in two places) , tslib_1.__assign({ // don't use getNativeElementProps for role and roledescription because it will also // pass through data-* props (resulting in them being used in two places) role: props.role, "aria-roledescription": props['aria-roledescription'], ariaDescribedBy: ariaDescribedBy, ariaLabel: ariaLabel, ariaLabelledBy: ariaLabelledBy, className: classNames.calloutMain, onDismiss: props.onDismiss, onMouseDown: mouseDownOnPopup, onMouseUp: mouseUpOnPopup, onRestoreFocus: props.onRestoreFocus, onScroll: onScroll, shouldRestoreFocus: shouldRestoreFocus, style: overflowStyle }, popupProps, { ref: mergedPopupRefs }), children)))); }), function (previousProps, nextProps) { if (!nextProps.shouldUpdateWhenHidden && previousProps.hidden && nextProps.hidden) { // Do not update when hidden. return true; } return (0, Utilities_1.shallowCompare)(previousProps, nextProps); }); /** * (Utility) to find and return the current `Callout` Beak position. * * @param positions */ function getBeakPosition(positions) { var _a, _b; var beakPositionStyle = tslib_1.__assign(tslib_1.__assign({}, (_a = positions === null || positions === void 0 ? void 0 : positions.beakPosition) === null || _a === void 0 ? void 0 : _a.elementPosition), { display: ((_b = positions === null || positions === void 0 ? void 0 : positions.beakPosition) === null || _b === void 0 ? void 0 : _b.hideBeak) ? 'none' : undefined }); if (!beakPositionStyle.top && !beakPositionStyle.bottom && !beakPositionStyle.left && !beakPositionStyle.right) { beakPositionStyle.left = BEAK_ORIGIN_POSITION.left; beakPositionStyle.top = BEAK_ORIGIN_POSITION.top; } return beakPositionStyle; } /** * (Utility) used to compare two different elementPositions to determine whether they are equal. * * @param prevElementPositions * @param newElementPosition */ function arePositionsEqual(prevElementPositions, newElementPosition) { return (comparePositions(prevElementPositions.elementPosition, newElementPosition.elementPosition) && comparePositions(prevElementPositions.beakPosition.elementPosition, newElementPosition.beakPosition.elementPosition)); } /** * (Utility) used in **arePositionsEqual** to compare two different elementPositions. * * @param prevElementPositions * @param newElementPositions */ function comparePositions(prevElementPositions, newElementPositions) { for (var key in newElementPositions) { if (newElementPositions.hasOwnProperty(key)) { var oldPositionEdge = prevElementPositions[key]; var newPositionEdge = newElementPositions[key]; if (oldPositionEdge !== undefined && newPositionEdge !== undefined) { if (oldPositionEdge.toFixed(2) !== newPositionEdge.toFixed(2)) { return false; } } else { return false; } } } return true; } exports.CalloutContentBase.displayName = COMPONENT_NAME; }); //# sourceMappingURL=CalloutContent.base.js.map