UNPKG

@fluentui/react

Version:

Reusable React components for building web experiences.

422 lines 22.3 kB
var _a; import { __assign } from "tslib"; import * as React from 'react'; import { DirectionalHint } from '../../common/DirectionalHint'; import { css, divProperties, elementContains, focusFirstChild, getNativeProps, on, shallowCompare, getPropsWithDefaults, } from '../../Utilities'; import { positionCallout, getMaxHeight, RectangleEdge, positionCard, getBoundsFromTargetWindow, } from '../../Positioning'; import { Popup } from '../../Popup'; import { classNamesFunction } from '../../Utilities'; import { AnimationClassNames } from '../../Styling'; import { useMergedRefs, useAsync, useConst, useTarget } from '@fluentui/react-hooks'; var ANIMATIONS = (_a = {}, _a[RectangleEdge.top] = AnimationClassNames.slideUpIn10, _a[RectangleEdge.bottom] = AnimationClassNames.slideDownIn10, _a[RectangleEdge.left] = AnimationClassNames.slideLeftIn10, _a[RectangleEdge.right] = AnimationClassNames.slideRightIn10, _a); var getClassNames = classNamesFunction({ disableCaching: true, }); 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.bottomAutoEdge, }; /** * Returns 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 cachedBounds = React.useRef(); var getBounds = React.useCallback(function () { if (!cachedBounds.current) { var currentBounds = typeof bounds === 'function' ? (targetWindow ? bounds(target, targetWindow) : undefined) : bounds; if (!currentBounds && targetWindow) { currentBounds = 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; } return cachedBounds.current; }, [bounds, minPagePadding, target, targetRef, targetWindow]); return getBounds; } /** * Returns the maximum available height for the Callout to render into */ function useMaxHeight(_a, targetRef, getBounds) { var beakWidth = _a.beakWidth, coverTarget = _a.coverTarget, directionalHint = _a.directionalHint, directionalHintFixed = _a.directionalHintFixed, gapSpace = _a.gapSpace, isBeakVisible = _a.isBeakVisible, hidden = _a.hidden; var _b = React.useState(), maxHeight = _b[0], setMaxHeight = _b[1]; var async = useAsync(); // Updating targetRef won't re-render the component, but it's recalculated (if needed) with every render // If it mutates, we want to re-run the effect var currentTarget = targetRef.current; React.useEffect(function () { var _a; if (!maxHeight && !hidden) { if (directionalHintFixed && currentTarget) { // Since the callout cannot measure it's border size it must be taken into account here. Otherwise it will // overlap with the target. var totalGap_1 = (gapSpace !== null && gapSpace !== void 0 ? gapSpace : 0) + (isBeakVisible && beakWidth ? beakWidth : 0); async.requestAnimationFrame(function () { if (targetRef.current) { setMaxHeight(getMaxHeight(targetRef.current, directionalHint, totalGap_1, getBounds(), coverTarget)); } }); } else { setMaxHeight((_a = getBounds()) === null || _a === void 0 ? void 0 : _a.height); } } else if (hidden) { setMaxHeight(undefined); } }, [ targetRef, currentTarget, gapSpace, beakWidth, getBounds, hidden, async, coverTarget, directionalHint, directionalHintFixed, isBeakVisible, maxHeight, ]); return maxHeight; } /** * Returns the height offset of the callout element and updates it each frame to approach the configured finalHeight */ function useHeightOffset(_a, calloutElement) { var finalHeight = _a.finalHeight, hidden = _a.hidden; var _b = React.useState(0), heightOffset = _b[0], setHeightOffset = _b[1]; var async = useAsync(); var setHeightOffsetTimer = React.useRef(); var setHeightOffsetEveryFrame = React.useCallback(function () { if (calloutElement.current && finalHeight) { setHeightOffsetTimer.current = async.requestAnimationFrame(function () { var _a; var calloutMainElem = (_a = calloutElement.current) === null || _a === void 0 ? void 0 : _a.lastChild; if (!calloutMainElem) { return; } var cardScrollHeight = calloutMainElem.scrollHeight; var cardCurrHeight = calloutMainElem.offsetHeight; var scrollDiff = cardScrollHeight - cardCurrHeight; setHeightOffset(function (currentHeightOffset) { return currentHeightOffset + scrollDiff; }); if (calloutMainElem.offsetHeight < finalHeight) { setHeightOffsetEveryFrame(); } else { async.cancelAnimationFrame(setHeightOffsetTimer.current, calloutElement.current); } }, calloutElement.current); } }, [async, calloutElement, finalHeight]); React.useEffect(function () { if (!hidden) { setHeightOffsetEveryFrame(); } }, [finalHeight, hidden, setHeightOffsetEveryFrame]); return heightOffset; } /** * Get the position information for the callout. If the callout does not fit in the given orientation, * a new position is calculated for the next frame, up to 5 attempts */ function usePositions(props, hostElement, calloutElement, targetRef, getBounds) { var _a = React.useState(), positions = _a[0], setPositions = _a[1]; var positionAttempts = React.useRef(0); var async = useAsync(); var hidden = props.hidden, target = props.target, finalHeight = props.finalHeight, onPositioned = props.onPositioned, directionalHint = props.directionalHint; React.useEffect(function () { if (!hidden) { var timerId_1 = async.requestAnimationFrame(function () { // If we expect a target element to position against, we need to wait until `targetRef.current` // is resolved. Otherwise we can try to position. var expectsTarget = !!target; if (hostElement.current && calloutElement.current && (!expectsTarget || targetRef.current)) { var currentProps = __assign(__assign({}, props), { target: targetRef.current, bounds: getBounds() }); // 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 ? positionCard(currentProps, hostElement.current, calloutElement.current, positions) : positionCallout(currentProps, hostElement.current, calloutElement.current, positions); // Set the new position only when the positions are not exists or one of the new callout positions // are 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.current); return function () { return async.cancelAnimationFrame(timerId_1); }; } }, [ hidden, directionalHint, async, calloutElement, hostElement, targetRef, finalHeight, getBounds, onPositioned, positions, props, target, ]); 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 = useAsync(); var hasPositions = !!positions; React.useEffect(function () { if (!hidden && setInitialFocus && hasPositions && calloutElement.current) { var timerId_2 = async.requestAnimationFrame(function () { return focusFirstChild(calloutElement.current); }, calloutElement.current); 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, shouldDismissOnWindowFocus = _a.shouldDismissOnWindowFocus, preventDismissOnEvent = _a.preventDismissOnEvent; var isMouseDownOnPopup = React.useRef(false); var async = useAsync(); var mouseDownHandlers = 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) { onDismiss === null || onDismiss === void 0 ? void 0 : onDismiss(ev); } }; var dismissOnLostFocus = function (ev) { if (!preventDismissOnLostFocus) { dismissOnClickOrScroll(ev); } }; var dismissOnClickOrScroll = function (ev) { var target = ev.target; var isEventTargetOutsideCallout = hostElement.current && !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 || !preventDismissOnEvent || (preventDismissOnEvent && !preventDismissOnEvent(ev)) || (target !== targetRef.current && !elementContains(targetRef.current, target))))) { 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 = [ on(targetWindow, 'scroll', dismissOnScroll, true), on(targetWindow, 'resize', dismissOnResize, true), on(targetWindow.document.documentElement, 'focus', dismissOnLostFocus, true), on(targetWindow.document.documentElement, 'click', dismissOnLostFocus, true), 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, preventDismissOnLostFocus, preventDismissOnResize, preventDismissOnScroll, positionsExists, preventDismissOnEvent, ]); return mouseDownHandlers; } var COMPONENT_NAME = 'CalloutContentBase'; export var CalloutContentBase = React.memo(React.forwardRef(function (propsWithoutDefaults, forwardedRef) { var props = 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, 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; var hostElement = React.useRef(null); var calloutElement = React.useRef(null); var rootRef = useMergedRefs(hostElement, forwardedRef); var _c = useTarget(props.target, calloutElement), targetRef = _c[0], targetWindow = _c[1]; var getBounds = useBounds(props, targetRef, targetWindow); var maxHeight = useMaxHeight(props, targetRef, getBounds); var heightOffset = useHeightOffset(props, calloutElement); var positions = usePositions(props, hostElement, calloutElement, targetRef, getBounds); var _d = useDismissHandlers(props, positions, hostElement, targetRef, targetWindow), mouseDownOnPopup = _d[0], mouseUpOnPopup = _d[1]; 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 getContentMaxHeight = maxHeight ? maxHeight + heightOffset : undefined; var contentMaxHeight = calloutMaxHeight && getContentMaxHeight && calloutMaxHeight < getContentMaxHeight ? calloutMaxHeight : getContentMaxHeight; 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, }); var overflowStyle = __assign(__assign(__assign({}, style), { maxHeight: contentMaxHeight }), (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. var content = (React.createElement("div", { ref: rootRef, className: classNames.container, style: visibilityStyle }, React.createElement("div", __assign({}, getNativeProps(props, divProperties, ARIA_ROLE_ATTRIBUTES), { className: css(classNames.root, positions && positions.targetEdge && ANIMATIONS[positions.targetEdge]), style: positions ? positions.elementPosition : 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: calloutElement }), beakVisible && React.createElement("div", { className: classNames.beak, style: getBeakPosition(positions) }), beakVisible && React.createElement("div", { className: classNames.beakCurtain }), React.createElement(Popup, __assign({}, getNativeProps(props, ARIA_ROLE_ATTRIBUTES), { ariaLabel: ariaLabel, onRestoreFocus: props.onRestoreFocus, ariaDescribedBy: ariaDescribedBy, ariaLabelledBy: ariaLabelledBy, className: classNames.calloutMain, onDismiss: props.onDismiss, onScroll: onScroll, shouldRestoreFocus: shouldRestoreFocus, style: overflowStyle, onMouseDown: mouseDownOnPopup, onMouseUp: mouseUpOnPopup }), children)))); return content; }), function (previousProps, nextProps) { if (!nextProps.shouldUpdateWhenHidden && previousProps.hidden && nextProps.hidden) { // Do not update when hidden. return true; } return shallowCompare(previousProps, nextProps); }); CalloutContentBase.displayName = COMPONENT_NAME; function getBeakPosition(positions) { var _a; var beakPositionStyle = __assign({}, (_a = positions === null || positions === void 0 ? void 0 : positions.beakPosition) === null || _a === void 0 ? void 0 : _a.elementPosition); if (!beakPositionStyle.top && !beakPositionStyle.bottom && !beakPositionStyle.left && !beakPositionStyle.right) { beakPositionStyle.left = BEAK_ORIGIN_POSITION.left; beakPositionStyle.top = BEAK_ORIGIN_POSITION.top; } return beakPositionStyle; } function arePositionsEqual(positions, newPosition) { return (comparePositions(positions.elementPosition, newPosition.elementPosition) && comparePositions(positions.beakPosition.elementPosition, newPosition.beakPosition.elementPosition)); } function comparePositions(oldPositions, newPositions) { for (var key in newPositions) { if (newPositions.hasOwnProperty(key)) { var oldPositionEdge = oldPositions[key]; var newPositionEdge = newPositions[key]; if (oldPositionEdge !== undefined && newPositionEdge !== undefined) { if (oldPositionEdge.toFixed(2) !== newPositionEdge.toFixed(2)) { return false; } } else { return false; } } } return true; } //# sourceMappingURL=CalloutContent.base.js.map