UNPKG

@atlaskit/page-layout

Version:

A collection of components which let you compose an application's page layout.

333 lines (324 loc) 15.1 kB
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;