UNPKG

@fluentui/react

Version:

Reusable React components for building web experiences.

353 lines 18.2 kB
import { __assign } from "tslib"; import * as React from 'react'; import { divProperties, getNativeProps } from '../../Utilities'; import { ResizeGroupDirection } from './ResizeGroup.types'; import { useConst, useMergedRefs, useAsync, useOnEvent, useWarnings } from '@fluentui/react-hooks'; import { useWindow } from '../../WindowProvider'; var RESIZE_DELAY = 16; /** * Returns a simple object is able to store measurements with a given key. */ export var getMeasurementCache = function () { var measurementsCache = {}; return { /** * Checks if the provided data has a cacheKey. If it has a cacheKey and there is a * corresponding entry in the measurementsCache, then it will return that value. * Returns undefined otherwise. */ getCachedMeasurement: function (data) { if (data && data.cacheKey && measurementsCache.hasOwnProperty(data.cacheKey)) { return measurementsCache[data.cacheKey]; } return undefined; }, /** * Should be called whenever there is a new measurement associated with a given data object. * If the data has a cacheKey, store that measurement in the measurementsCache. */ addMeasurementToCache: function (data, measurement) { if (data.cacheKey) { measurementsCache[data.cacheKey] = measurement; } }, }; }; /** * Returns a function that is able to compute the next state for the ResizeGroup given the current * state and any measurement updates. */ export var getNextResizeGroupStateProvider = function (measurementCache) { if (measurementCache === void 0) { measurementCache = getMeasurementCache(); } var _measurementCache = measurementCache; var _containerDimension; /** * Gets the width/height of the data rendered in a hidden div. * @param measuredData - The data corresponding to the measurement we wish to take. * @param getElementToMeasureDimension - A function that returns the measurement of the rendered data. * Only called when the measurement is not in the cache. */ function _getMeasuredDimension(measuredData, getElementToMeasureDimension) { var cachedDimension = _measurementCache.getCachedMeasurement(measuredData); if (cachedDimension !== undefined) { return cachedDimension; } var measuredDimension = getElementToMeasureDimension(); _measurementCache.addMeasurementToCache(measuredData, measuredDimension); return measuredDimension; } /** * Will get the next IResizeGroupState based on the current data while trying to shrink contents * to fit in the container. * @param data - The initial data point to start measuring. * @param onReduceData - Function that transforms the data into something that should render with less width/height. * @param getElementToMeasureDimension - A function that returns the measurement of the rendered data. * Only called when the measurement is not in the cache. */ function _shrinkContentsUntilTheyFit(data, onReduceData, getElementToMeasureDimension) { var dataToMeasure = data; var measuredDimension = _getMeasuredDimension(data, getElementToMeasureDimension); while (measuredDimension > _containerDimension) { var nextMeasuredData = onReduceData(dataToMeasure); // We don't want to get stuck in an infinite render loop when there are no more // scaling steps, so implementations of onReduceData should return undefined when // there are no more scaling states to apply. if (nextMeasuredData === undefined) { return { renderedData: dataToMeasure, resizeDirection: undefined, dataToMeasure: undefined, }; } measuredDimension = _measurementCache.getCachedMeasurement(nextMeasuredData); // If the measurement isn't in the cache, we need to re-render with some data in a hidden div if (measuredDimension === undefined) { return { dataToMeasure: nextMeasuredData, resizeDirection: 'shrink', }; } dataToMeasure = nextMeasuredData; } return { renderedData: dataToMeasure, resizeDirection: undefined, dataToMeasure: undefined, }; } /** * This function should be called when the state changes in a manner that might allow for more content to fit * on the screen, such as the window width/height growing. * @param data - The initial data point to start measuring. * @param onGrowData - Function that transforms the data into something that may take up more space when rendering. * @param getElementToMeasureDimension - A function that returns the measurement of the rendered data. * Only called when the measurement is not in the cache. */ function _growDataUntilItDoesNotFit(data, onGrowData, getElementToMeasureDimension, onReduceData) { var dataToMeasure = data; var measuredDimension = _getMeasuredDimension(data, getElementToMeasureDimension); while (measuredDimension < _containerDimension) { var nextMeasuredData = onGrowData(dataToMeasure); // We don't want to get stuck in an infinite render loop when there are no more // scaling steps, so implementations of onGrowData should return undefined when // there are no more scaling states to apply. if (nextMeasuredData === undefined) { return { renderedData: dataToMeasure, resizeDirection: undefined, dataToMeasure: undefined, }; } measuredDimension = _measurementCache.getCachedMeasurement(nextMeasuredData); // If the measurement isn't in the cache, we need to re-render with some data in a hidden div if (measuredDimension === undefined) { return { dataToMeasure: nextMeasuredData, }; } dataToMeasure = nextMeasuredData; } // Once the loop is done, we should now shrink until the contents fit. return __assign({ resizeDirection: 'shrink' }, _shrinkContentsUntilTheyFit(dataToMeasure, onReduceData, getElementToMeasureDimension)); } /** * Handles an update to the container width/height. * Should only be called when we knew the previous container width/height. * @param newDimension - The new width/height of the container. * @param fullDimensionData - The initial data passed in as a prop to resizeGroup. * @param renderedData - The data that was rendered prior to the container size changing. * @param onGrowData - Set to true if the Resize group has an onGrowData function. */ function _updateContainerDimension(newDimension, fullDimensionData, renderedData, onGrowData) { var nextState; if (newDimension > _containerDimension) { if (onGrowData) { nextState = { resizeDirection: 'grow', dataToMeasure: onGrowData(renderedData), }; } else { nextState = { resizeDirection: 'shrink', dataToMeasure: fullDimensionData, }; } } else { nextState = { resizeDirection: 'shrink', dataToMeasure: renderedData, }; } _containerDimension = newDimension; return __assign(__assign({}, nextState), { measureContainer: false }); } function getNextState(props, currentState, getElementToMeasureDimension, newContainerDimension) { // If there is no new container width/height or data to measure, there is no need for a new state update if (newContainerDimension === undefined && currentState.dataToMeasure === undefined) { return undefined; } if (newContainerDimension) { // If we know the last container size and we rendered data at that width/height, we can do an optimized render if (_containerDimension && currentState.renderedData && !currentState.dataToMeasure) { return __assign(__assign({}, currentState), _updateContainerDimension(newContainerDimension, props.data, currentState.renderedData, props.onGrowData)); } // If we are just setting the container width/height for the first time, we can't do any optimizations _containerDimension = newContainerDimension; } var nextState = __assign(__assign({}, currentState), { measureContainer: false }); if (currentState.dataToMeasure) { if (currentState.resizeDirection === 'grow' && props.onGrowData) { nextState = __assign(__assign({}, nextState), _growDataUntilItDoesNotFit(currentState.dataToMeasure, props.onGrowData, getElementToMeasureDimension, props.onReduceData)); } else { nextState = __assign(__assign({}, nextState), _shrinkContentsUntilTheyFit(currentState.dataToMeasure, props.onReduceData, getElementToMeasureDimension)); } } return nextState; } /** Function that determines if we need to render content for measurement based on the measurement cache contents. */ function shouldRenderDataForMeasurement(dataToMeasure) { if (!dataToMeasure || _measurementCache.getCachedMeasurement(dataToMeasure) !== undefined) { return false; } return true; } function getInitialResizeGroupState(data) { return { dataToMeasure: __assign({}, data), resizeDirection: 'grow', measureContainer: true, }; } return { getNextState: getNextState, shouldRenderDataForMeasurement: shouldRenderDataForMeasurement, getInitialResizeGroupState: getInitialResizeGroupState, }; }; // Provides a context property that (if true) tells any child components that // they are only being used for measurement purposes and will not be visible. export var MeasuredContext = React.createContext({ isMeasured: false }); // Styles for the hidden div used for measurement var hiddenDivStyles = { position: 'fixed', visibility: 'hidden' }; var hiddenParentStyles = { position: 'relative' }; var COMPONENT_NAME = 'ResizeGroup'; /** * Use useReducer instead of userState because React is not batching the state updates * when state is set in callbacks of setTimeout or requestAnimationFrame. * See issue: https://github.com/facebook/react/issues/14259 */ function resizeDataReducer(state, action) { var _a; switch (action.type) { case 'resizeData': return __assign({}, action.value); case 'dataToMeasure': return __assign(__assign({}, state), { dataToMeasure: action.value, resizeDirection: 'grow', measureContainer: true }); default: return __assign(__assign({}, state), (_a = {}, _a[action.type] = action.value, _a)); } } function useResizeState(props, nextResizeGroupStateProvider, rootRef) { var initialStateData = useConst(function () { return nextResizeGroupStateProvider.getInitialResizeGroupState(props.data); }); var _a = React.useReducer(resizeDataReducer, initialStateData), resizeData = _a[0], dispatchResizeDataAction = _a[1]; // Reset state when new data is provided React.useEffect(function () { dispatchResizeDataAction({ type: 'dataToMeasure', value: props.data, }); }, [props.data]); // Because it's possible that we may force more than one re-render per animation frame, we // want to make sure that the RAF request is using the most recent data. var stateRef = React.useRef(initialStateData); stateRef.current = __assign({}, resizeData); var updateResizeState = React.useCallback(function (nextState) { if (nextState) { dispatchResizeDataAction({ type: 'resizeData', value: nextState, }); } }, []); var remeasure = React.useCallback(function () { if (rootRef.current) { dispatchResizeDataAction({ type: 'measureContainer', value: true, }); } }, [rootRef]); return [stateRef, updateResizeState, remeasure]; } function useResizingBehavior(props, rootRef) { var nextResizeGroupStateProvider = useConst(getNextResizeGroupStateProvider); // A div that can be used for the initial measurement so that we can avoid mounting a second instance // of the component being measured for the initial render. var initialHiddenDiv = React.useRef(null); // A hidden div that is used for mounting a new instance of the component for measurement in a hidden // div without unmounting the currently visible content. var updateHiddenDiv = React.useRef(null); // Tracks if any content has been rendered to the user. This enables us to do some performance optimizations // for the initial render. var hasRenderedContent = React.useRef(false); var async = useAsync(); var _a = useResizeState(props, nextResizeGroupStateProvider, rootRef), stateRef = _a[0], updateResizeState = _a[1], remeasure = _a[2]; React.useEffect(function () { var _a; if (stateRef.current.renderedData) { hasRenderedContent.current = true; (_a = props.dataDidRender) === null || _a === void 0 ? void 0 : _a.call(props, stateRef.current.renderedData); } }); React.useEffect(function () { async.requestAnimationFrame(function () { var containerDimension = undefined; if (stateRef.current.measureContainer && rootRef.current) { var boundingRect = rootRef.current.getBoundingClientRect(); containerDimension = props.direction === ResizeGroupDirection.vertical ? boundingRect.height : boundingRect.width; } var nextState = nextResizeGroupStateProvider.getNextState(props, stateRef.current, function () { var refToMeasure = !hasRenderedContent.current ? initialHiddenDiv : updateHiddenDiv; if (!refToMeasure.current) { return 0; } var measuredBoundingRect = refToMeasure.current.getBoundingClientRect(); return props.direction === ResizeGroupDirection.vertical ? measuredBoundingRect.height : measuredBoundingRect.width; }, containerDimension); updateResizeState(nextState); }, rootRef.current); }); var win = useWindow(); useOnEvent(win, 'resize', async.debounce(remeasure, RESIZE_DELAY, { leading: true })); var dataNeedsMeasuring = nextResizeGroupStateProvider.shouldRenderDataForMeasurement(stateRef.current.dataToMeasure); var isInitialMeasure = !hasRenderedContent.current && dataNeedsMeasuring; return [ stateRef.current.dataToMeasure, stateRef.current.renderedData, remeasure, initialHiddenDiv, updateHiddenDiv, dataNeedsMeasuring, isInitialMeasure, ]; } function useDebugWarnings(props) { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line react-hooks/rules-of-hooks -- build-time conditional useWarnings({ name: COMPONENT_NAME, props: props, deprecations: { styles: 'className' }, }); } } var measuredContextValue = { isMeasured: true }; export var ResizeGroupBase = React.forwardRef(function (props, forwardedRef) { var rootRef = React.useRef(null); // The root div which is the container inside of which we are trying to fit content. var mergedRootRef = useMergedRefs(rootRef, forwardedRef); var _a = useResizingBehavior(props, rootRef), dataToMeasure = _a[0], renderedData = _a[1], remeasure = _a[2], initialHiddenDiv = _a[3], updateHiddenDiv = _a[4], dataNeedsMeasuring = _a[5], isInitialMeasure = _a[6]; React.useImperativeHandle(props.componentRef, function () { return ({ remeasure: remeasure }); }, [remeasure]); useDebugWarnings(props); var className = props.className, onRenderData = props.onRenderData; var divProps = getNativeProps(props, divProperties, ['data']); // We only ever render the final content to the user. All measurements are done in a hidden div. // For the initial render, we want this to be as fast as possible, so we need to make sure that we only mount one // version of the component for measurement and the final render. For renders that update what is on screen, we // want to make sure that there are no jarring effects such as the screen flashing as we apply scaling steps for // measurement. In the update case, we mount a second version of the component just for measurement purposes and // leave the rendered content untouched until we know the next state to show to the user. return (React.createElement("div", __assign({}, divProps, { className: className, ref: mergedRootRef }), React.createElement("div", { style: hiddenParentStyles }, dataNeedsMeasuring && !isInitialMeasure && (React.createElement("div", { style: hiddenDivStyles, ref: updateHiddenDiv }, React.createElement(MeasuredContext.Provider, { value: measuredContextValue }, onRenderData(dataToMeasure)))), React.createElement("div", { ref: initialHiddenDiv, style: isInitialMeasure ? hiddenDivStyles : undefined, "data-automation-id": "visibleContent" }, isInitialMeasure ? onRenderData(dataToMeasure) : renderedData && onRenderData(renderedData))))); }); ResizeGroupBase.displayName = 'ResizeGroupBase'; //# sourceMappingURL=ResizeGroup.base.js.map