@fluentui/react
Version:
Reusable React components for building web experiences.
286 lines • 15.1 kB
JavaScript
var _a;
import { __assign } from "tslib";
import * as React from 'react';
import { getClassNames } from './PositioningContainer.styles';
import { ZIndexes } from '../../../Styling';
import { Layer } from '../../../Layer';
// Utilites/Helpers
import { DirectionalHint } from '../../../common/DirectionalHint';
import { css, elementContains, focusFirstChild, EventGroup, getPropsWithDefaults } from '../../../Utilities';
import { getMaxHeight, positionElement, RectangleEdge } from '../../../Positioning';
import { AnimationClassNames, mergeStyles } from '../../../Styling';
import { useMergedRefs, useAsync, useTarget } from '@fluentui/react-hooks';
import { useDocumentEx, useWindowEx } from '../../../utilities/dom';
var OFF_SCREEN_STYLE = { opacity: 0 };
// In order for some of the max height logic to work properly we need to set the border.
// The value is arbitrary.
var BORDER_WIDTH = 1;
var SLIDE_ANIMATIONS = (_a = {},
_a[RectangleEdge.top] = 'slideUpIn20',
_a[RectangleEdge.bottom] = 'slideDownIn20',
_a[RectangleEdge.left] = 'slideLeftIn20',
_a[RectangleEdge.right] = 'slideRightIn20',
_a);
var DEFAULT_PROPS = {
preventDismissOnScroll: false,
offsetFromTarget: 0,
minPagePadding: 8,
directionalHint: DirectionalHint.bottomAutoEdge,
};
function useBounds(props, targetWindow) {
/** The bounds used when determining if and where the PositioningContainer should be placed. */
var getBounds = function () {
var currentBounds = props.bounds;
if (!currentBounds) {
currentBounds = {
top: 0 + props.minPagePadding,
left: 0 + props.minPagePadding,
right: targetWindow.innerWidth - props.minPagePadding,
bottom: targetWindow.innerHeight - props.minPagePadding,
width: targetWindow.innerWidth - props.minPagePadding * 2,
height: targetWindow.innerHeight - props.minPagePadding * 2,
};
}
return currentBounds;
};
return getBounds;
}
function usePositionState(props, positionedHost, contentHost, targetRef, getCachedBounds) {
var async = useAsync();
var doc = useDocumentEx();
var win = useWindowEx();
/**
* Current set of calculated positions for the outermost parent container.
*/
var _a = React.useState(), positions = _a[0], setPositions = _a[1];
var positionAttempts = React.useRef(0);
var updateAsyncPosition = function () {
async.requestAnimationFrame(function () { return updatePosition(); });
};
var updatePosition = function () {
var offsetFromTarget = props.offsetFromTarget, onPositioned = props.onPositioned;
var hostElement = positionedHost.current;
var positioningContainerElement = contentHost.current;
if (hostElement && positioningContainerElement) {
var currentProps = __assign({}, props);
currentProps.bounds = getCachedBounds();
currentProps.target = targetRef.current;
var target = currentProps.target;
if (target) {
// Check if the target is an Element or a MouseEvent and the document contains it
// or don't check anything else if the target is a Point or Rectangle
if ((!target.getBoundingClientRect && !target.preventDefault) ||
(doc === null || doc === void 0 ? void 0 : doc.body.contains(target))) {
currentProps.gapSpace = offsetFromTarget;
var newPositions = positionElement(currentProps, hostElement, positioningContainerElement, undefined, win);
// Set the new position only when the positions are not exists or one of the new positioningContainer
// 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 positioningContainer 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);
onPositioned === null || onPositioned === void 0 ? void 0 : onPositioned(newPositions);
}
else {
positionAttempts.current = 0;
onPositioned === null || onPositioned === void 0 ? void 0 : onPositioned(newPositions);
}
}
else if (positions !== undefined) {
setPositions(undefined);
}
}
else if (positions !== undefined) {
setPositions(undefined);
}
}
};
React.useEffect(updateAsyncPosition);
return [positions, updateAsyncPosition];
}
function useSetInitialFocus(_a, contentHost, positions) {
var setInitialFocus = _a.setInitialFocus;
var didSetInitialFocus = React.useRef(false);
React.useEffect(function () {
if (!didSetInitialFocus.current && contentHost.current && setInitialFocus && positions) {
didSetInitialFocus.current = true;
focusFirstChild(contentHost.current);
}
});
}
function useMaxHeight(_a, targetRef, getCachedBounds) {
var directionalHintFixed = _a.directionalHintFixed, offsetFromTarget = _a.offsetFromTarget, directionalHint = _a.directionalHint, target = _a.target;
/**
* The maximum height the PositioningContainer can grow to
* without going beyond the window or target bounds
*/
var maxHeight = React.useRef();
var win = useWindowEx();
// If the target element changed, reset the max height. If we are tracking
// target with class name, always reset because we do not know if
// fabric has rendered a new element and disposed the old element.
if (typeof target === 'string') {
maxHeight.current = undefined;
}
React.useEffect(function () {
maxHeight.current = undefined;
}, [target, offsetFromTarget]);
/**
* Return the maximum height the container can grow to
* without going out of the specified bounds
*/
var getCachedMaxHeight = function () {
if (!maxHeight.current) {
if (directionalHintFixed && targetRef.current) {
var gapSpace = offsetFromTarget ? offsetFromTarget : 0;
maxHeight.current = getMaxHeight(targetRef.current, directionalHint, gapSpace, getCachedBounds(), undefined, win);
}
else {
maxHeight.current = getCachedBounds().height - BORDER_WIDTH * 2;
}
}
return maxHeight.current;
};
return getCachedMaxHeight;
}
function useAutoDismissEvents(_a, positionedHost, targetWindow, targetRef, positions, updateAsyncPosition) {
var onDismiss = _a.onDismiss, preventDismissOnScroll = _a.preventDismissOnScroll;
var async = useAsync();
var onResize = React.useCallback(function (ev) {
if (onDismiss) {
onDismiss(ev);
}
else {
updateAsyncPosition();
}
}, [onDismiss, updateAsyncPosition]);
var dismissOnLostFocus = React.useCallback(function (ev) {
var target = ev.target;
var clickedOutsideCallout = positionedHost.current && !elementContains(positionedHost.current, target);
if ((!targetRef.current && clickedOutsideCallout) ||
(ev.target !== targetWindow &&
clickedOutsideCallout &&
(targetRef.current.stopPropagation ||
!targetRef.current ||
(target !== targetRef.current && !elementContains(targetRef.current, target))))) {
onResize(ev);
}
}, [onResize, positionedHost, targetRef, targetWindow]);
var dismissOnScroll = React.useCallback(function (ev) {
if (positions && !preventDismissOnScroll) {
dismissOnLostFocus(ev);
}
}, [dismissOnLostFocus, positions, preventDismissOnScroll]);
React.useEffect(function () {
var events = new EventGroup({});
// This is added so the positioningContainer will dismiss when the window is scrolled
// but not when something inside the positioningContainer 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 positioningContainer.
async.setTimeout(function () {
var _a, _b;
events.on(targetWindow, 'scroll', async.throttle(dismissOnScroll, 10), true);
events.on(targetWindow, 'resize', async.throttle(onResize, 10), true);
events.on((_a = targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.document) === null || _a === void 0 ? void 0 : _a.body, 'focus', dismissOnLostFocus, true);
events.on((_b = targetWindow === null || targetWindow === void 0 ? void 0 : targetWindow.document) === null || _b === void 0 ? void 0 : _b.body, 'click', dismissOnLostFocus, true);
}, 0);
return function () { return events.dispose(); };
// eslint-disable-next-line react-hooks/exhaustive-deps -- should only run on mount
}, [dismissOnScroll]);
}
export function useHeightOffset(_a, contentHost) {
var finalHeight = _a.finalHeight;
/**
* Tracks the current height offset and updates during
* the height animation when props.finalHeight is specified.
* State stored as object to ensure re-render even if the value does not change.
* See https://github.com/microsoft/fluentui/issues/23545
*/
var _b = React.useState({ value: 0 }), heightOffset = _b[0], setHeightOffset = _b[1];
var async = useAsync();
var setHeightOffsetTimer = React.useRef(0);
/** Animates the height if finalHeight was given. */
var setHeightOffsetEveryFrame = function () {
if (contentHost && finalHeight) {
setHeightOffsetTimer.current = async.requestAnimationFrame(function () {
if (!contentHost.current) {
return;
}
var positioningContainerMainElem = contentHost.current.lastChild;
var cardScrollHeight = positioningContainerMainElem.scrollHeight;
var cardCurrHeight = positioningContainerMainElem.offsetHeight;
var scrollDiff = cardScrollHeight - cardCurrHeight;
setHeightOffset({ value: heightOffset.value + scrollDiff });
if (positioningContainerMainElem.offsetHeight < finalHeight) {
setHeightOffsetEveryFrame();
}
else {
async.cancelAnimationFrame(setHeightOffsetTimer.current);
}
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- should only re-run if finalHeight changes
React.useEffect(setHeightOffsetEveryFrame, [finalHeight]);
return heightOffset.value;
}
export var PositioningContainer = React.forwardRef(function (propsWithoutDefaults, forwardedRef) {
var props = getPropsWithDefaults(DEFAULT_PROPS, propsWithoutDefaults);
// @TODO rename to reflect the name of this class
var contentHost = React.useRef(null);
/**
* The primary positioned div.
*/
var positionedHost = React.useRef(null);
var rootRef = useMergedRefs(forwardedRef, positionedHost);
var _a = useTarget(props.target, positionedHost), targetRef = _a[0], targetWindow = _a[1];
var getCachedBounds = useBounds(props, targetWindow);
var _b = usePositionState(props, positionedHost, contentHost, targetRef, getCachedBounds), positions = _b[0], updateAsyncPosition = _b[1];
var getCachedMaxHeight = useMaxHeight(props, targetRef, getCachedBounds);
var heightOffset = useHeightOffset(props, contentHost);
useSetInitialFocus(props, contentHost, positions);
useAutoDismissEvents(props, positionedHost, targetWindow, targetRef, positions, updateAsyncPosition);
// eslint-disable-next-line react-hooks/exhaustive-deps -- should only run on initial render
React.useEffect(function () { var _a; return (_a = props.onLayerMounted) === null || _a === void 0 ? void 0 : _a.call(props); }, []);
// 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 className = props.className, doNotLayer = props.doNotLayer, positioningContainerWidth = props.positioningContainerWidth, positioningContainerMaxHeight = props.positioningContainerMaxHeight, children = props.children;
var styles = getClassNames();
var directionalClassName = positions && positions.targetEdge ? AnimationClassNames[SLIDE_ANIMATIONS[positions.targetEdge]] : '';
var getContentMaxHeight = getCachedMaxHeight() + heightOffset;
var contentMaxHeight = positioningContainerMaxHeight && positioningContainerMaxHeight > getContentMaxHeight
? getContentMaxHeight
: positioningContainerMaxHeight;
var content = (React.createElement("div", { ref: rootRef, className: css('ms-PositioningContainer', styles.container) },
React.createElement("div", { className: mergeStyles('ms-PositioningContainer-layerHost', styles.root, className, directionalClassName, !!positioningContainerWidth && { width: positioningContainerWidth }, doNotLayer && { zIndex: ZIndexes.Layer }), 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: contentHost },
children,
// @TODO apply to the content container
contentMaxHeight)));
return doNotLayer ? content : React.createElement(Layer, __assign({}, props.layerProps), content);
});
PositioningContainer.displayName = 'PositioningContainer';
function arePositionsEqual(positions, newPosition) {
return comparePositions(positions.elementPosition, newPosition.elementPosition);
}
function comparePositions(oldPositions, newPositions) {
for (var key in newPositions) {
if (newPositions.hasOwnProperty(key)) {
var oldPositionEdge = oldPositions[key];
var newPositionEdge = newPositions[key];
if (oldPositionEdge && newPositionEdge) {
if (oldPositionEdge.toFixed(2) !== newPositionEdge.toFixed(2)) {
return false;
}
}
}
}
return true;
}
//# sourceMappingURL=PositioningContainer.js.map