@atlaskit/page-layout
Version:
A collection of components which let you compose an application's page layout.
337 lines (328 loc) • 13 kB
JavaScript
/* 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';
const openBackdropStyles = css({
width: '100%',
height: '100%',
position: 'absolute',
background: `var(--ds-blanket, ${N100A})`,
opacity: 1
});
const 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 ${TRANSITION_DURATION}ms ${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)
*/
const LeftSidebar = props => {
const {
children,
width,
isFixed = true,
valueTextLabel,
resizeButtonLabel,
resizeGrabAreaLabel,
overrides,
onResizeStart,
onResizeEnd,
onFlyoutExpand,
onFlyoutCollapse,
testId,
id,
skipLinkTitle,
collapsedState
} = props;
const flyoutTimerRef = useRef();
const mouseOverEventRef = useRef();
const leftSideBarRef = useRef(null);
const {
leftSidebarState,
setLeftSidebarState
} = useContext(SidebarResizeContext);
const {
isFlyoutOpen,
isResizing,
flyoutLockCount,
isLeftSidebarCollapsed,
leftSidebarWidth,
lastLeftSidebarWidth,
hasInit
} = leftSidebarState;
const isLocked = flyoutLockCount > 0;
const isLockedRef = useRef(isLocked);
const mouseXRef = useRef(0);
const handlerRef = useRef(null);
useEffect(() => {
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 = event => {
mouseXRef.current = event.clientX;
};
document.addEventListener('mousemove', handlerRef.current);
}
if (!isLocked && handlerRef.current) {
if (mouseXRef.current >= lastLeftSidebarWidth) {
setLeftSidebarState(current => ({
...current,
isFlyoutOpen: false
}));
}
document.removeEventListener('mousemove', handlerRef.current);
handlerRef.current = null;
}
return () => {
if (handlerRef.current) {
document.removeEventListener('mousemove', handlerRef.current);
}
};
}, [isLocked, lastLeftSidebarWidth, setLeftSidebarState]);
const _width = Math.max(width || 0, DEFAULT_LEFT_SIDEBAR_WIDTH);
const collapsedStateOverrideOpen = collapsedState === 'expanded';
let 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(() => {
const cachedCollapsedState = !collapsedStateOverrideOpen && (collapsedState === 'collapsed' || getGridStateFromStorage('isLeftSidebarCollapsed') || false);
const cachedGridState = getGridStateFromStorage('gridState') || {};
let leftSidebarWidth = !width && cachedGridState[VAR_LEFT_SIDEBAR_FLYOUT] ? Math.max(cachedGridState[VAR_LEFT_SIDEBAR_FLYOUT], DEFAULT_LEFT_SIDEBAR_WIDTH) : _width;
const 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,
isLeftSidebarCollapsed: cachedCollapsedState,
leftSidebarWidth,
lastLeftSidebarWidth,
flyoutLockCount: 0,
isFixed,
hasInit: true
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Every time other than mount,
// update the local storage and css variables.
const notFirstRun = useRef(false);
useEffect(() => {
if (notFirstRun.current) {
publishGridState({
[VAR_LEFT_SIDEBAR_WIDTH]: leftSidebarWidth || leftSidebarWidthOnMount,
[VAR_LEFT_SIDEBAR_FLYOUT]: lastLeftSidebarWidth
});
mergeGridStateIntoStorage('isLeftSidebarCollapsed', isLeftSidebarCollapsed);
}
if (!notFirstRun.current) {
notFirstRun.current = true;
}
return () => {
removeMouseOverListener();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLeftSidebarCollapsed, leftSidebarWidth, id]);
const onMouseOver = event => {
const isMouseOnResizeButton = event.target.matches(`[${RESIZE_BUTTON_SELECTOR}]`) || event.target.matches(`[${RESIZE_BUTTON_SELECTOR}] *`);
if (isFlyoutOpen || isMouseOnResizeButton || !isLeftSidebarCollapsed) {
return;
}
event.persist();
flyoutTimerRef.current && clearTimeout(flyoutTimerRef.current);
if (!mouseOverEventRef.current) {
mouseOverEventRef.current = event => {
const leftSidebar = leftSideBarRef.current;
if (leftSidebar === null || isLockedRef.current) {
return;
}
if (!leftSidebar.contains(event.target)) {
flyoutTimerRef.current && clearTimeout(flyoutTimerRef.current);
onFlyoutCollapse && onFlyoutCollapse();
setLeftSidebarState(current => ({
...current,
isFlyoutOpen: false
}));
removeMouseOverListener();
}
};
}
document.addEventListener('mouseover', mouseOverEventRef.current, {
capture: true,
passive: true
});
flyoutTimerRef.current = setTimeout(() => {
setLeftSidebarState(current => ({
...current,
isFlyoutOpen: true
}));
onFlyoutExpand && onFlyoutExpand();
}, FLYOUT_DELAY);
};
const removeMouseOverListener = () => {
mouseOverEventRef.current && document.removeEventListener('mouseover', mouseOverEventRef.current, {
capture: true,
passive: true
});
};
useSkipLink(id, skipLinkTitle);
const onMouseLeave = event => {
const isMouseOnResizeButton = event.target.matches(`[${RESIZE_BUTTON_SELECTOR}]`) || event.target.matches(`[${RESIZE_BUTTON_SELECTOR}] *`);
if (isMouseOnResizeButton || !isLeftSidebarCollapsed || flyoutLockCount > 0) {
return;
}
onFlyoutCollapse && onFlyoutCollapse();
setTimeout(() => {
setLeftSidebarState(current => ({
...current,
isFlyoutOpen: false
}));
}, FLYOUT_DELAY);
};
const mobileMediaQuery = useMediaQuery('below.sm');
const openMobileFlyout = useCallback(() => {
if (!(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches)) {
return;
}
setLeftSidebarState(current => {
if (current.isFlyoutOpen) {
return current;
}
return {
...current,
isFlyoutOpen: true
};
});
}, [setLeftSidebarState, mobileMediaQuery]);
const closeMobileFlyout = useCallback(() => {
if (!(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches)) {
return;
}
setLeftSidebarState(current => {
if (!current.isFlyoutOpen) {
return current;
}
return {
...current,
isFlyoutOpen: false
};
});
}, [setLeftSidebarState, mobileMediaQuery]);
useMediaQuery('below.sm', event => {
setLeftSidebarState(current => {
if (event.matches && !current.isLeftSidebarCollapsed) {
// Sidebar was previously open when resizing downwards, convert the sidebar being open to a flyout being open
return {
...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 {
...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
*/
const 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 && `${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;