@atlaskit/navigation-system
Version:
The latest navigation system for Atlassian apps.
679 lines (656 loc) • 29.9 kB
JavaScript
/* panel-splitter.tsx generated by @compiled/babel-plugin v3.0.2 */
import _extends from "@babel/runtime/helpers/extends";
import "./panel-splitter.compiled.css";
import * as React from 'react';
import { ax, ix } from "@compiled/react/runtime";
import { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal, flushSync } from 'react-dom';
import { bind } from 'bind-event-listener';
import invariant from 'tiny-invariant';
import { useId } from '@atlaskit/ds-lib/use-id';
import useStableRef from '@atlaskit/ds-lib/use-stable-ref';
import { useOpenLayerObserver } from '@atlaskit/layering/use-open-layer-observer';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/adapter/element-adapter';
import { blockDraggingToIFrames } from '@atlaskit/pragmatic-drag-and-drop/utils/block-dragging-to-iframes';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/utils/combine';
import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/utils/disable-native-drag-preview';
import { preventUnhandled } from '@atlaskit/pragmatic-drag-and-drop/utils/prevent-unhandled';
import Tooltip from '@atlaskit/tooltip/Tooltip';
import TooltipContainer from '@atlaskit/tooltip/TooltipContainer';
import { requestClosePopovers } from '@atlaskit/top-layer/page-surface/request-close-popovers';
import VisuallyHidden from '@atlaskit/visually-hidden/visually-hidden';
import { contentInsetBlockStart } from '../constants';
import { LayoutAreaSizingContext } from '../layout-area-sizing-context';
import { convertResizeBoundToPixels } from './convert-resize-bound-to-pixels';
import { getPanelGrowthSide } from './get-panel-growth-side';
import { getPercentageWithinPixelBounds } from './get-percentage-within-pixel-bounds';
import { getPixelWidth } from './get-pixel-width';
import { getWidthFromDragLocation } from './get-width-from-drag-location';
import { isPanelSplitterDragData } from './is-panel-splitter-drag-data';
import { createKeyboardResizeManager } from './keyboard-resize-manager';
import { OnDoubleClickContext } from './on-double-click-context';
import { PanelSplitterContext } from './panel-splitter-context';
import { panelSplitterDragDataSymbol } from './panel-splitter-drag-symbol';
function signPanelSplitterDragData(data) {
return {
...data,
[panelSplitterDragDataSymbol]: true
};
}
const containerStyles = {
root: "_12ji1r31 _1qu2glyw _12y31o36 _1e0cglyw _kqswstnw _u7coidpf _152tidpf _1pbykb7n _181n1ule",
positionEnd: "_rjxpftgi",
positionStart: "_1e02zeo2"
};
const grabAreaStyles = {
root: "_1bsbl52n _4t3i1osq _kqswstnw _1q51ze3t _y4tize3t _85i5ze3t _bozgze3t _syaz1j28 _bfhk1j28 _1oec6ebc _1lww188d _6fl41ytf _k8m01ylx _d0aluf7j _vp7g6x5g _30l36x5g _1gglglyw _9h8h12zz",
rootFinesse: "_vp7g1ihb _30l31ihb _9h8hq98m"
};
const lineStyles = {
root: "_kqswstnw _1e0c1ule _1bsb1l7b _4t3i1osq _syaz1kw7 _bfhk1r31 _1e021v6z"
};
const tooltipStyles = {
root: "_ahbq196n _1bsb1ris",
placedLeft: "_ahbqze3t _12l2196n"
};
const PanelSplitterTooltip = /*#__PURE__*/forwardRef(({
children,
className,
...props
}, ref) => {
const style = useMemo(() => {
if (!props.style || !props.style.transform) {
return props.style;
}
// We cannot just match against integers safely, as browsers may introduce non-integer values due to scaling
// In the future we can probably use `CSSStyleValue.parse()` to get the browser to handle parsing for us,
// but there's no Firefox support yet.
const [translateX, translateY] = props.style.transform.matchAll(/(?:-|\+)?\d*\.?\d+px/g);
if (!translateY) {
// If we can't extract the translateY value we bail out and return the original style
return props.style;
}
/**
* Adjusts the translate Y to keep the tooltip within the main content area,
* so that it does not appear over the banner or top navigation.
*/
const newTranslateY = `max(calc(${contentInsetBlockStart} + ${"var(--ds-space-100, 8px)"}), ${translateY})`;
const newTransform = `translate3d(${translateX}, ${newTranslateY}, 0)`;
return {
...props.style,
transform: newTransform
};
}, [props.style]);
return /*#__PURE__*/React.createElement(TooltipContainer, _extends({}, props, {
ref: ref
// Must be statically passed
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-classname-prop, @atlaskit/design-system/no-unsafe-style-overrides
,
className: ax([tooltipStyles.root, props.placement === 'left' && tooltipStyles.placedLeft, className])
// eslint-disable-next-line @atlaskit/design-system/no-unsafe-style-overrides
,
// eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop
style: style
}), children);
});
/**
* A wrapper component that renders a tooltip if the tooltipContent or shortcut is provided.
*/
const MaybeTooltip = ({
tooltipContent,
shortcut,
mousePosition,
children,
testId
}) => {
if (tooltipContent) {
return /*#__PURE__*/React.createElement(Tooltip, {
testId: testId,
content: tooltipContent,
shortcut: shortcut,
position: "mouse-y",
mousePosition: mousePosition,
isScreenReaderAnnouncementDisabled: true,
component: PanelSplitterTooltip,
shouldAlwaysFadeIn: true,
shouldRenderToParent: true
}, children);
}
return children;
};
// Browsers increase an RTL range input's value on ArrowLeft, so the arrow key pointing towards the
// panel's growth side widens it.
const rangeInputDirs = {
left: 'rtl',
right: 'ltr'
};
function getTextDirection(element) {
const {
direction
} = window.getComputedStyle(element);
return direction === 'rtl' ? 'rtl' : 'ltr';
}
const PortaledPanelSplitter = ({
label,
onResizeStart,
onResizeEnd,
testId,
panelId,
panelWidth,
onCompleteResize,
onResizeStartInternal,
onResizeInternal,
prepareResize,
getResizeBounds,
panel,
portal,
resizingCssVar,
resizingElementRef,
position,
tooltipContent,
shortcut
}) => {
var _useContext;
const splitterRef = useRef(null);
const registerSplitter = (_useContext = useContext(LayoutAreaSizingContext)) === null || _useContext === void 0 ? void 0 : _useContext.registerSplitter;
const unregisterSplitter = useRef(undefined);
const setSplitterRef = useCallback(element => {
var _unregisterSplitter$c;
(_unregisterSplitter$c = unregisterSplitter.current) === null || _unregisterSplitter$c === void 0 ? void 0 : _unregisterSplitter$c.call(unregisterSplitter);
splitterRef.current = element;
unregisterSplitter.current = element && prepareResize ? registerSplitter === null || registerSplitter === void 0 ? void 0 : registerSplitter(element) : undefined;
}, [prepareResize, registerSplitter]);
const labelId = useId();
const panelGrowthSide = useMemo(() => fg('platform-dst-panel-splitter-resize-direction') ? getPanelGrowthSide({
position,
textDirection: getTextDirection(panel)
}) : undefined, [position, panel]);
// Separate state used for the input range width to remove the UI's dependency on the "persisted" layout state value being updated
const [rangeInputValue, setRangeInputValue] = useState(panelWidth);
/**
* We are using state to store the resize bounds _converted into pixel units_, so that we can update them when the window is resized.
* Otherwise they can become out of sync with the actual viewport size, and cause a broken experience (inaccurate width percentage)
* when the user tries to resize using the keyboard.
*/
const [rangeInputBounds, setRangeInputBounds] = useState({
// Using placeholder values here for the initial render. These will be calculated and updated when the range input is focused.
min: 200,
max: 500
});
const openLayerObserver = useOpenLayerObserver({
isEnabled: !fg('platform-dst-top-layer')
});
/**
* This is a temporary workaround to enable the `SideNavPanelSplitter` component
* to collapse the side nav on double click, without exposing the `onDoubleClick` prop on `PanelSplitter`.
* Once `PanelSplitter` supports an `onDoubleClick` prop directly, this context can be removed.
*/
const onDoubleClick = useContext(OnDoubleClickContext);
// Storing the initial `clientX` on `mousedown` events, to workaround a bug caused by some browser extensions
// where the `dragstart` event incorrectly returns `0` for the `clientX` location.
const initialClientXRef = useRef(null);
// Live allocation changes panelWidth on every frame. Only the legacy path should
// rebind the drag listeners when this value changes.
const legacyPanelWidth = fg('platform-dst-chat-panel-layout') || onResizeInternal ? undefined : panelWidth;
useEffect(() => {
const splitter = splitterRef.current;
invariant(splitter, 'Splitter ref must be set');
let lastManagedResizeWidth;
return combine(blockDraggingToIFrames({
element: splitter
}),
/**
* Capturing the initial `clientX` from the mousedown, before the drag starts.
*
* ⚠️ Note: We are not using pragmatic-drag-and-drop's `onDragStart` for this as some browser
* extensions can cause the client location (e.g. clientX) to incorrectly return 0 during the `dragstart` event.
*
* We are also not using pragmatic-drag-and-drop's `onDrag` here as it is throttled, which means
* fast mouse movements can cause the real first drag event to be missed, causing a different clientX
* to be captured.
*
* I also tried only binding an event listener inside pragmatic-drag-and-drop's `onDragStart`, which seemd to work
* but did not feel as robust, and might have timing issues as it happens slightly later.
*/
bind(splitter, {
type: 'mousedown',
listener: event => {
initialClientXRef.current = event.clientX;
}
}), draggable({
element: splitter,
onGenerateDragPreview: ({
nativeSetDragImage
}) => {
// We will be moving the line to indicate a drag. We can disable the native drag preview
disableNativeDragPreview({
nativeSetDragImage
});
// We don't want any native drop animation for when the user does not drop on a drop target. We want the drag to finish immediately
preventUnhandled.start();
},
getInitialData() {
const initialWidth = getPixelWidth(panel);
/**
* The drag calculations require the actual computed width of the element
* For example, if the panel has loaded with a width of 2000px, but the max bound is 1000px and is visually 1000px (due to the CSS `clamp()`),
* the drag calculations should use the actual width of 1000px, not the width in state of 2000px.
*/
return signPanelSplitterDragData({
panelId,
initialWidth,
resizingWidth: `${initialWidth}px`,
resizeBounds: getResizeBounds(),
// Only computing text direction when we need it, just as the drag is starting.
// Recomputing text direction on each new drag in case the text direction
// has changed. This is unlikely, but being safe.
direction: getTextDirection(panel)
});
},
onDragStart({
source
}) {
/**
* ⚠️ Note: We are not using the client locations (e.g. clientX) during `onDragStart`
* because some browser extensions can cause the event properties to incorrectly return 0.
*/
invariant(isPanelSplitterDragData(source.data));
const resizeStartData = {
initialWidth: source.data.initialWidth
};
// The layout allocation system needs to know which area is actively being
// resized before the first drag update. Committing this synchronously keeps
// the actively dragged area protected from automatic compression.
if (onResizeStartInternal) {
flushSync(() => onResizeStartInternal(resizeStartData));
}
onResizeStart === null || onResizeStart === void 0 ? void 0 : onResizeStart(resizeStartData);
// Close any open layers when the user starts resizing
// Not gated. Top-layer tooltips (`platform-dst-top-layer-tooltip`) are not known to the observer.
requestClosePopovers();
openLayerObserver === null || openLayerObserver === void 0 ? void 0 : openLayerObserver.closeLayers();
},
onDrag({
location,
source
}) {
var _initialClientXRef$cu;
/**
* ⚠️ Note: We are not using the location.initial.input client locations because some browser extensions
* can cause the client locations (e.g. clientX) in the `dragstart` event to incorrectly return 0.
*/
invariant(isPanelSplitterDragData(source.data));
const {
initialWidth,
resizeBounds,
direction
} = source.data;
invariant(initialClientXRef.current !== null, 'initialClientX must be set');
/**
* How wide the element would be if there were no width constraints,
* based on the pointer's position.
*/
const targetWidth = getWidthFromDragLocation({
initialWidth,
location,
// The fallback of 0 won't be used due to the invariant, however we require one to satisfy the type.
initialClientX: (_initialClientXRef$cu = initialClientXRef.current) !== null && _initialClientXRef$cu !== void 0 ? _initialClientXRef$cu : 0,
direction,
position
});
const resizingWidth = `clamp(${resizeBounds.min}, ${targetWidth}px, ${resizeBounds.max})`;
if (onResizeInternal) {
// The allocator needs below-minimum requests for the local-to-chat cascade.
const currentBounds = getResizeBounds();
const width = Math.max(0, Math.min(targetWidth, prepareResize ? window.innerWidth : convertResizeBoundToPixels(currentBounds.max)));
if (width !== lastManagedResizeWidth) {
lastManagedResizeWidth = width;
onResizeInternal(width);
}
} else {
var _resizingElementRef$c;
panel.style.setProperty(resizingCssVar, resizingWidth);
resizingElementRef === null || resizingElementRef === void 0 ? void 0 : (_resizingElementRef$c = resizingElementRef.current) === null || _resizingElementRef$c === void 0 ? void 0 : _resizingElementRef$c.style.setProperty(resizingCssVar, resizingWidth);
}
source.data.resizingWidth = resizingWidth;
},
async onDrop({
source
}) {
var _resizingElementRef$c2;
invariant(isPanelSplitterDragData(source.data));
preventUnhandled.stop();
if (prepareResize) {
if (!(await prepareResize()) || !panel.isConnected) {
return;
}
}
// PDD may deliver its final throttled drag update immediately before drop.
// Commit that update once before measuring; do not persist a previous frame.
if (onResizeInternal && lastManagedResizeWidth !== undefined) {
const width = lastManagedResizeWidth;
flushSync(() => onResizeInternal(width));
lastManagedResizeWidth = undefined;
}
const finalWidth = getPixelWidth(panel);
// Persist the final preferred width before removing the imperative drag
// variable, so the rendered width is continuous across pointer release.
if (fg('platform-dst-chat-panel-layout') || onResizeInternal) {
flushSync(() => onCompleteResize(finalWidth));
} else {
onCompleteResize(finalWidth);
}
onResizeEnd === null || onResizeEnd === void 0 ? void 0 : onResizeEnd({
initialWidth: source.data.initialWidth,
finalWidth
});
panel.style.removeProperty(resizingCssVar);
resizingElementRef === null || resizingElementRef === void 0 ? void 0 : (_resizingElementRef$c2 = resizingElementRef.current) === null || _resizingElementRef$c2 === void 0 ? void 0 : _resizingElementRef$c2.style.removeProperty(resizingCssVar);
}
}));
}, [onCompleteResize, onResizeStartInternal, onResizeInternal, prepareResize, onResizeStart, onResizeEnd, panel, resizingCssVar, resizingElementRef, legacyPanelWidth, position, openLayerObserver, panelId, getResizeBounds]);
const onResizeStartStableRef = useStableRef(onResizeStart);
const onResizeEndStableRef = useStableRef(onResizeEnd);
const [keyboardResizeManager] = useState(() => createKeyboardResizeManager({
onResizeStart: (...args) => {
var _onResizeStartStableR;
// Close any open layers when the user starts resizing with keyboard
// Not gated. Top-layer tooltips (`platform-dst-top-layer-tooltip`) are not known to the observer.
requestClosePopovers();
openLayerObserver === null || openLayerObserver === void 0 ? void 0 : openLayerObserver.closeLayers();
(_onResizeStartStableR = onResizeStartStableRef.current) === null || _onResizeStartStableR === void 0 ? void 0 : _onResizeStartStableR.call(onResizeStartStableRef, ...args);
},
onResizeEnd: (...args) => {
var _onResizeEndStableRef;
return (_onResizeEndStableRef = onResizeEndStableRef.current) === null || _onResizeEndStableRef === void 0 ? void 0 : _onResizeEndStableRef.call(onResizeEndStableRef, ...args);
}
}));
const focusSequence = useRef(0);
const keyboardBoundsReady = useRef(false);
const pendingKeyboardWidth = useRef(null);
const handleSliderInputChange = useCallback(async (event, pendingKey) => {
const isManagedResize = onResizeInternal;
const sequence = focusSequence.current;
let value = isManagedResize ? parseFloat(event.currentTarget.value) : parseInt(event.currentTarget.value);
if (prepareResize) {
if (!(await prepareResize()) || !panel.isConnected || sequence !== focusSequence.current) {
return;
}
}
if (isManagedResize) {
// The available region may have changed since focus, including without a
// window resize. Use the allocator's current bounds for the public payload.
const bounds = getResizeBounds();
const min = convertResizeBoundToPixels(bounds.min);
const max = convertResizeBoundToPixels(bounds.max);
if (pendingKey) {
var _pendingKeyboardWidth;
// Native range inputs would apply these keys to the placeholder bounds.
// Preserve the intent until the chunk provides the real bounds instead.
const current = (_pendingKeyboardWidth = pendingKeyboardWidth.current) !== null && _pendingKeyboardWidth !== void 0 ? _pendingKeyboardWidth : getPixelWidth(panel);
const doesArrowRightIncrease = panelGrowthSide ? panelGrowthSide === 'right' : getTextDirection(panel) === 'ltr';
const increase = pendingKey === 'ArrowUp' || pendingKey === (doesArrowRightIncrease ? 'ArrowRight' : 'ArrowLeft') || pendingKey === 'PageUp';
const step = pendingKey.startsWith('Page') ? Math.max(20, (max - min) / 10) : 20;
value = pendingKey === 'End' ? max : pendingKey === 'Home' ? min : current + (increase ? step : -step);
}
value = Math.max(min, Math.min(value, max));
setRangeInputBounds({
min,
max
});
}
pendingKeyboardWidth.current = value;
setRangeInputValue(value);
/**
* We are intentionally _not_ debouncing `onCompleteResize` as we want it to be called anytime the user changes the range input value.
* It is an internal-only callback - it's provided by `PanelSplitterProvider`, and used by layout slots to update their layout width state.
*/
onCompleteResize(value);
keyboardResizeManager.onResize({
initialWidth: panelWidth,
finalWidth: value
});
}, [getResizeBounds, onCompleteResize, onResizeInternal, panelWidth, keyboardResizeManager, panel, prepareResize, panelGrowthSide]);
const handleSliderKeyDown = useCallback(event => {
if (prepareResize && !keyboardBoundsReady.current && ['Home', 'End', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'].includes(event.key)) {
event.preventDefault();
void handleSliderInputChange(event, event.key);
}
}, [handleSliderInputChange, prepareResize]);
const resizeEventListenerCleanupFn = useRef(null);
const handleSliderFocus = useCallback(async () => {
var _resizeEventListenerC;
const sequence = ++focusSequence.current;
keyboardBoundsReady.current = false;
pendingKeyboardWidth.current = null;
if (prepareResize) {
if (!(await prepareResize()) || !panel.isConnected || sequence !== focusSequence.current) {
return;
}
}
/**
* We are only updating the range input's properties when the element is focused, as a performance optimisation.
* It isn't necessary to recalculate these values until the element is going to be used.
*/
// Clean up any left over listeners in case `focus` was triggered again without `blur`.
(_resizeEventListenerC = resizeEventListenerCleanupFn.current) === null || _resizeEventListenerC === void 0 ? void 0 : _resizeEventListenerC.call(resizeEventListenerCleanupFn);
// Update the range input value to match the current panel width.
const width = prepareResize ? getPixelWidth(panel) : panelWidth;
pendingKeyboardWidth.current = width;
setRangeInputValue(width);
const resizeBounds = getResizeBounds();
// Update the range input bounds to ensure they are accurate (in case the viewport was previously resized)
setRangeInputBounds({
min: convertResizeBoundToPixels(resizeBounds.min),
max: convertResizeBoundToPixels(resizeBounds.max)
});
keyboardBoundsReady.current = true;
/**
* _While the slider element is focused_, add a window resize event listener to update the range input bounds
* when the window is resized. This is needed to ensure the min and max widths accurately reflect the viewport
* size - in case they are provided in vw units.
*
* This listener is cleaned up when the element is blurred.
*/
resizeEventListenerCleanupFn.current = bind(window, {
type: 'resize',
listener: function updateState() {
const resizeBounds = getResizeBounds();
setRangeInputBounds({
min: convertResizeBoundToPixels(resizeBounds.min),
max: convertResizeBoundToPixels(resizeBounds.max)
});
}
});
}, [panelWidth, getResizeBounds, panel, prepareResize]);
const handleSliderBlur = useCallback(() => {
var _resizeEventListenerC2;
focusSequence.current++;
keyboardBoundsReady.current = false;
// Remove the resize event listener when the range input is blurred.
(_resizeEventListenerC2 = resizeEventListenerCleanupFn.current) === null || _resizeEventListenerC2 === void 0 ? void 0 : _resizeEventListenerC2.call(resizeEventListenerCleanupFn);
}, []);
useEffect(() => {
return function cleanup() {
var _resizeEventListenerC3;
// Cleanup the resize event listener when the component is unmounted.
(_resizeEventListenerC3 = resizeEventListenerCleanupFn.current) === null || _resizeEventListenerC3 === void 0 ? void 0 : _resizeEventListenerC3.call(resizeEventListenerCleanupFn);
};
}, []);
const ariaValueText = useMemo(() => `${getPercentageWithinPixelBounds({
currentWidth: rangeInputValue,
resizeBounds: rangeInputBounds
})}% width`, [rangeInputValue, rangeInputBounds]);
return /*#__PURE__*/createPortal( /*#__PURE__*/React.createElement("div", {
"data-testid": testId ? `${testId}-container` : undefined,
className: ax([containerStyles.root, position === 'start' && containerStyles.positionStart, position === 'end' && containerStyles.positionEnd])
}, /*#__PURE__*/React.createElement(MaybeTooltip, {
tooltipContent: tooltipContent,
shortcut: shortcut,
mousePosition: panelGrowthSide !== null && panelGrowthSide !== void 0 ? panelGrowthSide : 'right',
testId: testId ? `${testId}-tooltip` : undefined
}, /*#__PURE__*/React.createElement("div", {
ref: setSplitterRef,
"data-testid": testId,
onDoubleClick: onDoubleClick,
className: ax([grabAreaStyles.root, fg('platform-dst-tokens-finesse') && grabAreaStyles.rootFinesse])
}, /*#__PURE__*/React.createElement(VisuallyHidden, null, /*#__PURE__*/React.createElement("input", {
type: "range",
dir: panelGrowthSide ? rangeInputDirs[panelGrowthSide] : undefined,
value: rangeInputValue,
step: 20,
min: rangeInputBounds.min,
max: rangeInputBounds.max,
"aria-valuetext": ariaValueText,
"aria-labelledby": labelId,
onChange: handleSliderInputChange,
onKeyDown: prepareResize ? handleSliderKeyDown : undefined,
onFocus: handleSliderFocus,
onBlur: handleSliderBlur
}), /*#__PURE__*/React.createElement("span", {
id: labelId
}, label)), /*#__PURE__*/React.createElement("span", {
className: ax([lineStyles.root])
})))), portal);
};
/**
* _PanelSplitter_
*
* A component that allows the user to resize a layout area.
* It can be used within layout areas like `Panel`, and `Aside`.
* For the `SideNav`, use the `SideNavPanelSplitter` component instead, as it provides additional functionality
* such as double clicking to collapse the side nav.
*
* Example usage:
* ```tsx
* <Aside>
* <!-- other side nav content -->
* <PanelSplitter label="Resize Side Nav" />
* </Aside>
* ```
*/
export const PanelSplitter = ({
label,
onResizeStart,
onResizeEnd,
testId,
tooltipContent
}) => {
const [panel, setPanel] = useState(null);
const [portal, setPortal] = useState(null);
const context = useContext(PanelSplitterContext);
invariant(context, 'Panel splitter context not set');
const {
panelRef,
portalRef,
isEnabled,
panelId,
panelWidth,
onCompleteResize,
onResizeStartInternal,
onResizeInternal,
getResizeBounds,
prepareResize,
resizingCssVar,
resizingElementRef,
position,
shortcut
} = context;
/**
* **Explanation**
*
* _How React Suspense works_
*
* When a component is suspended:
*
* - it is _not_ "unmounted" and effects are _not_ cleaned up
* - refs to react managed elements are cleared
* - react adds `display:none !important` as an inline style to hide the element
* - state is preserved
*
* When a suspended component is resumed:
* - `display:none !important` inline style is removed
* - it will re-render (without the refs set)
* - refs will be set
* - `useEffect` will _not_ re-run
*
* [More details](https://x.com/alexandereardon/status/1944617494569992440)
*
* _What does this mean for the panel splitter?_
*
* - The panel splitter renders into content into a react managed element (eg managed by Aside)
* - When UI is suspended the portal ref is cleared
* - After a suspense, we will get a render with cleared refs
* - 🔥 This is problematic if we want to portal into that ref during the render
*
* _Approach: initial render_
*
* - We cannot portal the panel splitter until after the refs are initially set
* - Effects run after refs being set. In an effect we set the ref values (which are populated),
* trigger a re-render and then portal into the refs.
*
* _Approach: suspense_
*
* - We put the refs into react `state` after every render
* - The refs in `state` will not be cleared by suspending as effects are not run, or cleaned up,
* when components are suspended / resumed.
* - After the post suspense render, we can use the `portalRef` (which was captured in an effect)
* to continue to portal into.
*
* _Why not store `portalRef` in `state` in `<PanelSplitterProvider>`?_
*
* - The `portalRef` is set high in the tree, so a re-render will cause a
* large amount of components to re-render (eg the whole sidebar)
* - After a suspense, we would need to do _another_ large re-render to propagate the new refs
* - With our current approach, any changes to `portalRef` will cause a re-render low in the tree
* (just in the `<PanelSplitter>`)
*/
// We want this effect to run after each render (see above)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
// Don't need to do anything
if (!isEnabled) {
return;
}
// It is safe to access refs in useEffect, even with Suspense
const panelEl = panelRef.current;
const portalEl = portalRef.current;
invariant(panelEl, 'panelRef not set');
invariant(portalEl, 'portal element not set');
// Doing our own check, as this results in fewer re-renders than react
// (which also does it's own checks to see if a re-render should occur)
if (panelEl === panel && portalEl === portal) {
return;
}
// If refs don't change, react won't cause infinite loops
setPanel(panelEl);
setPortal(portalEl);
});
if (!isEnabled) {
return null;
}
if (!portal || !panel) {
return null;
}
return /*#__PURE__*/React.createElement(PortaledPanelSplitter, {
label: label,
onResizeStart: onResizeStart,
onResizeEnd: onResizeEnd,
testId: testId,
panelId: panelId,
panel: panel,
portal: portal,
panelWidth: panelWidth,
onCompleteResize: onCompleteResize,
onResizeStartInternal: onResizeStartInternal,
onResizeInternal: onResizeInternal,
prepareResize: prepareResize,
getResizeBounds: getResizeBounds,
resizingCssVar: resizingCssVar,
resizingElementRef: resizingElementRef,
position: position,
tooltipContent: tooltipContent,
shortcut: shortcut
});
};