@atlaskit/page-layout
Version:
A collection of components which let you compose an application's page layout.
333 lines (324 loc) • 15.1 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
/* eslint-disable @repo/internal/dom-events/no-unsafe-event-listeners */
/**
* @jsxRuntime classic
* @jsx jsx
*/
import { Fragment, useCallback, useContext, useEffect, useRef } from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { css, jsx } from '@emotion/react';
import useCloseOnEscapePress from '@atlaskit/ds-lib/use-close-on-escape-press';
import { easeOut } from '@atlaskit/motion';
import { UNSAFE_useMediaQuery as useMediaQuery } from '@atlaskit/primitives/responsive';
import { N100A } from '@atlaskit/theme/colors';
import { COLLAPSED_LEFT_SIDEBAR_WIDTH, DEFAULT_LEFT_SIDEBAR_WIDTH, FLYOUT_DELAY, MOBILE_COLLAPSED_LEFT_SIDEBAR_WIDTH, RESIZE_BUTTON_SELECTOR, TRANSITION_DURATION, VAR_LEFT_SIDEBAR_FLYOUT, VAR_LEFT_SIDEBAR_WIDTH } from '../../common/constants';
import { getGridStateFromStorage, mergeGridStateIntoStorage, resolveDimension } from '../../common/utils';
import { publishGridState, SidebarResizeContext, useSkipLink } from '../../controllers';
import ResizeControl from '../resize-control';
import LeftSidebarInner from './internal/left-sidebar-inner';
import LeftSidebarOuter from './internal/left-sidebar-outer';
import ResizableChildrenWrapper from './internal/resizable-children-wrapper';
import SlotDimensions from './slot-dimensions';
var openBackdropStyles = css({
width: '100%',
height: '100%',
position: 'absolute',
background: "var(--ds-blanket, ".concat(N100A, ")"),
opacity: 1
});
var hiddenBackdropStyles = css({
opacity: 0,
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-imported-style-values, @atlaskit/ui-styling-standard/no-unsafe-values -- Ignored via go/DSP-18766
transition: "opacity ".concat(TRANSITION_DURATION, "ms ").concat(easeOut, " 0s")
});
/**
* __Left sidebar__
*
* Provides a slot for a left sidebar within the PageLayout.
*
* On smaller viewports, the left sidebar can no longer be expanded. Instead, expanding it will
* put it into our "flyout mode" to lay overtop (which in desktop is explicitly a hover state).
* This ensures the contents behind do not reflow oddly and allows for a better experience
* resizing between mobile and desktop.
*
* - [Examples](https://atlassian.design/components/page-layout/examples)
* - [Code](https://atlassian.design/components/page-layout/code)
*/
var LeftSidebar = function LeftSidebar(props) {
var children = props.children,
width = props.width,
_props$isFixed = props.isFixed,
isFixed = _props$isFixed === void 0 ? true : _props$isFixed,
valueTextLabel = props.valueTextLabel,
resizeButtonLabel = props.resizeButtonLabel,
resizeGrabAreaLabel = props.resizeGrabAreaLabel,
overrides = props.overrides,
onResizeStart = props.onResizeStart,
onResizeEnd = props.onResizeEnd,
onFlyoutExpand = props.onFlyoutExpand,
onFlyoutCollapse = props.onFlyoutCollapse,
testId = props.testId,
id = props.id,
skipLinkTitle = props.skipLinkTitle,
collapsedState = props.collapsedState;
var flyoutTimerRef = useRef();
var mouseOverEventRef = useRef();
var leftSideBarRef = useRef(null);
var _useContext = useContext(SidebarResizeContext),
leftSidebarState = _useContext.leftSidebarState,
setLeftSidebarState = _useContext.setLeftSidebarState;
var isFlyoutOpen = leftSidebarState.isFlyoutOpen,
isResizing = leftSidebarState.isResizing,
flyoutLockCount = leftSidebarState.flyoutLockCount,
isLeftSidebarCollapsed = leftSidebarState.isLeftSidebarCollapsed,
leftSidebarWidth = leftSidebarState.leftSidebarWidth,
lastLeftSidebarWidth = leftSidebarState.lastLeftSidebarWidth,
hasInit = leftSidebarState.hasInit;
var isLocked = flyoutLockCount > 0;
var isLockedRef = useRef(isLocked);
var mouseXRef = useRef(0);
var handlerRef = useRef(null);
useEffect(function () {
isLockedRef.current = isLocked;
// I tried a one-time `mousemove` handler that gets attached
// when the lock releases. This is not robust because
// the mouse can stay still after release (e.g. using keyboard)
// and the sidebar will erroneously stay open.
//
// The following solution is likely less performant but more robust.
if (isLocked && !handlerRef.current) {
handlerRef.current = function (event) {
mouseXRef.current = event.clientX;
};
document.addEventListener('mousemove', handlerRef.current);
}
if (!isLocked && handlerRef.current) {
if (mouseXRef.current >= lastLeftSidebarWidth) {
setLeftSidebarState(function (current) {
return _objectSpread(_objectSpread({}, current), {}, {
isFlyoutOpen: false
});
});
}
document.removeEventListener('mousemove', handlerRef.current);
handlerRef.current = null;
}
return function () {
if (handlerRef.current) {
document.removeEventListener('mousemove', handlerRef.current);
}
};
}, [isLocked, lastLeftSidebarWidth, setLeftSidebarState]);
var _width = Math.max(width || 0, DEFAULT_LEFT_SIDEBAR_WIDTH);
var collapsedStateOverrideOpen = collapsedState === 'expanded';
var leftSidebarWidthOnMount;
if (collapsedStateOverrideOpen) {
leftSidebarWidthOnMount = resolveDimension(VAR_LEFT_SIDEBAR_FLYOUT, _width, !width);
} else if (isLeftSidebarCollapsed || collapsedState === 'collapsed') {
leftSidebarWidthOnMount = COLLAPSED_LEFT_SIDEBAR_WIDTH;
} else {
leftSidebarWidthOnMount = resolveDimension(VAR_LEFT_SIDEBAR_WIDTH, _width, !width || !collapsedStateOverrideOpen && getGridStateFromStorage('isLeftSidebarCollapsed'));
}
// Update state from cache on mount
useEffect(function () {
var cachedCollapsedState = !collapsedStateOverrideOpen && (collapsedState === 'collapsed' || getGridStateFromStorage('isLeftSidebarCollapsed') || false);
var cachedGridState = getGridStateFromStorage('gridState') || {};
var leftSidebarWidth = !width && cachedGridState[VAR_LEFT_SIDEBAR_FLYOUT] ? Math.max(cachedGridState[VAR_LEFT_SIDEBAR_FLYOUT], DEFAULT_LEFT_SIDEBAR_WIDTH) : _width;
var lastLeftSidebarWidth = !width && cachedGridState[VAR_LEFT_SIDEBAR_FLYOUT] ? Math.max(cachedGridState[VAR_LEFT_SIDEBAR_FLYOUT], DEFAULT_LEFT_SIDEBAR_WIDTH) : _width;
if (cachedCollapsedState) {
leftSidebarWidth = COLLAPSED_LEFT_SIDEBAR_WIDTH;
}
setLeftSidebarState({
isFlyoutOpen: false,
isResizing: isResizing,
isLeftSidebarCollapsed: cachedCollapsedState,
leftSidebarWidth: leftSidebarWidth,
lastLeftSidebarWidth: lastLeftSidebarWidth,
flyoutLockCount: 0,
isFixed: isFixed,
hasInit: true
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Every time other than mount,
// update the local storage and css variables.
var notFirstRun = useRef(false);
useEffect(function () {
if (notFirstRun.current) {
publishGridState(_defineProperty(_defineProperty({}, VAR_LEFT_SIDEBAR_WIDTH, leftSidebarWidth || leftSidebarWidthOnMount), VAR_LEFT_SIDEBAR_FLYOUT, lastLeftSidebarWidth));
mergeGridStateIntoStorage('isLeftSidebarCollapsed', isLeftSidebarCollapsed);
}
if (!notFirstRun.current) {
notFirstRun.current = true;
}
return function () {
removeMouseOverListener();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLeftSidebarCollapsed, leftSidebarWidth, id]);
var onMouseOver = function onMouseOver(event) {
var isMouseOnResizeButton = event.target.matches("[".concat(RESIZE_BUTTON_SELECTOR, "]")) || event.target.matches("[".concat(RESIZE_BUTTON_SELECTOR, "] *"));
if (isFlyoutOpen || isMouseOnResizeButton || !isLeftSidebarCollapsed) {
return;
}
event.persist();
flyoutTimerRef.current && clearTimeout(flyoutTimerRef.current);
if (!mouseOverEventRef.current) {
mouseOverEventRef.current = function (event) {
var leftSidebar = leftSideBarRef.current;
if (leftSidebar === null || isLockedRef.current) {
return;
}
if (!leftSidebar.contains(event.target)) {
flyoutTimerRef.current && clearTimeout(flyoutTimerRef.current);
onFlyoutCollapse && onFlyoutCollapse();
setLeftSidebarState(function (current) {
return _objectSpread(_objectSpread({}, current), {}, {
isFlyoutOpen: false
});
});
removeMouseOverListener();
}
};
}
document.addEventListener('mouseover', mouseOverEventRef.current, {
capture: true,
passive: true
});
flyoutTimerRef.current = setTimeout(function () {
setLeftSidebarState(function (current) {
return _objectSpread(_objectSpread({}, current), {}, {
isFlyoutOpen: true
});
});
onFlyoutExpand && onFlyoutExpand();
}, FLYOUT_DELAY);
};
var removeMouseOverListener = function removeMouseOverListener() {
mouseOverEventRef.current && document.removeEventListener('mouseover', mouseOverEventRef.current, {
capture: true,
passive: true
});
};
useSkipLink(id, skipLinkTitle);
var onMouseLeave = function onMouseLeave(event) {
var isMouseOnResizeButton = event.target.matches("[".concat(RESIZE_BUTTON_SELECTOR, "]")) || event.target.matches("[".concat(RESIZE_BUTTON_SELECTOR, "] *"));
if (isMouseOnResizeButton || !isLeftSidebarCollapsed || flyoutLockCount > 0) {
return;
}
onFlyoutCollapse && onFlyoutCollapse();
setTimeout(function () {
setLeftSidebarState(function (current) {
return _objectSpread(_objectSpread({}, current), {}, {
isFlyoutOpen: false
});
});
}, FLYOUT_DELAY);
};
var mobileMediaQuery = useMediaQuery('below.sm');
var openMobileFlyout = useCallback(function () {
if (!(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches)) {
return;
}
setLeftSidebarState(function (current) {
if (current.isFlyoutOpen) {
return current;
}
return _objectSpread(_objectSpread({}, current), {}, {
isFlyoutOpen: true
});
});
}, [setLeftSidebarState, mobileMediaQuery]);
var closeMobileFlyout = useCallback(function () {
if (!(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches)) {
return;
}
setLeftSidebarState(function (current) {
if (!current.isFlyoutOpen) {
return current;
}
return _objectSpread(_objectSpread({}, current), {}, {
isFlyoutOpen: false
});
});
}, [setLeftSidebarState, mobileMediaQuery]);
useMediaQuery('below.sm', function (event) {
setLeftSidebarState(function (current) {
if (event.matches && !current.isLeftSidebarCollapsed) {
// Sidebar was previously open when resizing downwards, convert the sidebar being open to a flyout being open
return _objectSpread(_objectSpread({}, current), {}, {
isResizing: false,
isLeftSidebarCollapsed: true,
leftSidebarWidth: COLLAPSED_LEFT_SIDEBAR_WIDTH,
lastLeftSidebarWidth: current.leftSidebarWidth,
isFlyoutOpen: true
});
} else if (!event.matches && current.isFlyoutOpen) {
// The user is resizing "upwards", eg. going from mobile to desktop.
// Flyout was previously open, let's keep it open by moving to the un-collapsed sidebar instead
return _objectSpread(_objectSpread({}, current), {}, {
isResizing: false,
isLeftSidebarCollapsed: false,
leftSidebarWidth: Math.max(current.lastLeftSidebarWidth, DEFAULT_LEFT_SIDEBAR_WIDTH),
isFlyoutOpen: false
});
}
return current;
});
});
// Close the flyout when the "escape" key is pressed.
useCloseOnEscapePress({
onClose: closeMobileFlyout,
isDisabled: !isFlyoutOpen
});
/**
* We use both the state and our effect-based ref to protect animation until initialized fully
*/
var isReady = hasInit && notFirstRun.current;
return jsx(Fragment, null, (mobileMediaQuery === null || mobileMediaQuery === void 0 ? void 0 : mobileMediaQuery.matches) &&
/**
* On desktop, the `onClick` handlers controls the temporary flyout behavior.
* This is an intentionally mouse-only experience, it may even be disruptive with keyboard navigation.
*
* On mobile, the `onClick` handler controls the toggled flyout behaviour.
* This is not intended to be how you use this with a keyboard, there is a ResizeButton for this intentionally instead.
*/
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions, @atlassian/a11y/interactive-element-not-keyboard-focusable
jsx("div", {
css: [hiddenBackdropStyles, isFlyoutOpen && openBackdropStyles],
onClick: closeMobileFlyout
}), jsx(LeftSidebarOuter, {
ref: leftSideBarRef,
testId: testId,
onMouseOver: !(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches) ? onMouseOver : undefined,
onMouseLeave: !(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches) ? onMouseLeave : undefined,
onClick: mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches ? openMobileFlyout : undefined,
id: id,
isFixed: isFixed
}, jsx(SlotDimensions, {
variableName: VAR_LEFT_SIDEBAR_WIDTH,
value: isReady ? leftSidebarWidth : leftSidebarWidthOnMount,
mobileValue: MOBILE_COLLAPSED_LEFT_SIDEBAR_WIDTH
}), jsx(LeftSidebarInner, {
isFixed: isFixed,
isFlyoutOpen: isFlyoutOpen
}, jsx(ResizableChildrenWrapper, {
isFlyoutOpen: isFlyoutOpen,
isLeftSidebarCollapsed: isLeftSidebarCollapsed,
hasCollapsedState: !isReady && collapsedState === 'collapsed',
testId: testId && "".concat(testId, "-resize-children-wrapper")
}, children), jsx(ResizeControl, {
testId: testId,
valueTextLabel: valueTextLabel,
resizeGrabAreaLabel: resizeGrabAreaLabel,
resizeButtonLabel: resizeButtonLabel
// eslint-disable-next-line @repo/internal/react/no-unsafe-overrides
,
overrides: overrides,
onResizeStart: onResizeStart,
onResizeEnd: onResizeEnd
}))));
};
export default LeftSidebar;