@atlaskit/page-layout
Version:
A collection of components which let you compose an application's page layout.
364 lines (351 loc) • 16.1 kB
JavaScript
import _extends from "@babel/runtime/helpers/extends";
/**
* @jsxRuntime classic
* @jsx jsx
*/
import { Fragment, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-global-styles, @atlaskit/ui-styling-standard/use-compiled -- Ignored via go/DSP-18766
import { css, Global, jsx } from '@emotion/react';
import { bindAll } from 'bind-event-listener';
import rafSchd from 'raf-schd';
import { UNSAFE_useMediaQuery as useMediaQuery } from '@atlaskit/primitives/responsive';
import { COLLAPSED_LEFT_SIDEBAR_WIDTH, DEFAULT_LEFT_SIDEBAR_WIDTH, IS_SIDEBAR_DRAGGING, MIN_LEFT_SIDEBAR_DRAG_THRESHOLD, RESIZE_CONTROL_SELECTOR, VAR_LEFT_SIDEBAR_WIDTH } from '../../common/constants';
import { getLeftPanelWidth, getLeftSidebarPercentage } from '../../common/utils';
import { SidebarResizeContext } from '../../controllers/sidebar-resize-context';
/* import useUpdateCssVar from '../../controllers/use-update-css-vars'; */
import GrabArea from './grab-area';
import ResizeButton from './resize-button';
import Shadow from './shadow';
const cssSelector = {
[RESIZE_CONTROL_SELECTOR]: true
};
const resizeControlStyles = css({
position: 'absolute',
insetBlockEnd: 0,
insetBlockStart: 0,
insetInlineStart: '100%',
outline: 'none'
});
const showResizeButtonStyles = css({
'--ds--resize-button--opacity': 1
});
// @ts-expect-error adding `!important` to style rules is currently a type error
const globalResizingStyles = css({
// eslint-disable-next-line @atlaskit/design-system/no-nested-styles, @atlaskit/ui-styling-standard/no-nested-selectors -- Ignored via go/DSP-18766
'*': {
// Setting the cursor to be `ew-resize` on all elements so that even if the user
// pointer slips off the resize handle, the cursor will still be the resize cursor
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-important-styles -- Ignored via go/DSP-18766
cursor: 'ew-resize !important',
// Blocking selection while resizing
// Notes:
// - This prevents a user selection being caused by resizing
// - Safari + Firefox → all good
// - Chrome → This will undo the current selection while resizing (not ideal)
// - The current selection will resume after resizing
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-important-styles -- Ignored via go/DSP-18766
userSelect: 'none !important'
},
// eslint-disable-next-line @atlaskit/design-system/no-nested-styles, @atlaskit/ui-styling-standard/no-nested-selectors -- Ignored via go/DSP-18766
iframe: {
// Disabling pointer events on iframes when resizing
// as iframes will swallower user events when the user is over them
// eslint-disable-next-line @atlaskit/ui-styling-standard/no-important-styles -- Ignored via go/DSP-18766
pointerEvents: 'none !important'
}
// Note: We _could_ also disable `pointer-events` on all elements during resizing.
// However, to minimize risk we are just disabling `pointer-events` on iframes
// as that change is actually needed to fix resizing with iframes
});
const ResizeControl = ({
testId,
overrides,
resizeButtonLabel = 'Current project sidebar',
valueTextLabel = 'Width',
resizeGrabAreaLabel = 'Resize Current Project Sidebar',
onResizeStart,
onResizeEnd
}) => {
const {
toggleLeftSidebar,
collapseLeftSidebar,
leftSidebarState,
setLeftSidebarState
} = useContext(SidebarResizeContext);
const {
isLeftSidebarCollapsed
} = leftSidebarState;
const sidebarWidth = useRef(leftSidebarState[VAR_LEFT_SIDEBAR_WIDTH]);
// Distance of mouse from left sidebar onMouseDown
const offset = useRef(0);
const keyboardEventTimeout = useRef();
const [isGrabAreaFocused, setIsGrabAreaFocused] = useState(false);
const unbindEvents = useRef(null);
const mobileMediaQuery = useMediaQuery('below.sm');
// Used in some cases to ensure function references don't have to change
// TODO: more functions could use `stableSidebarState` rather than `leftSidebarState`
const stableSidebarState = useRef(leftSidebarState);
useEffect(() => {
stableSidebarState.current = leftSidebarState;
}, [leftSidebarState]);
const toggleSideBar = useCallback(event => {
// don't cascade down to the LeftSidebarOuter
event === null || event === void 0 ? void 0 : event.stopPropagation();
toggleLeftSidebar();
}, [toggleLeftSidebar]);
const onMouseDown = event => {
if (isLeftSidebarCollapsed) {
return;
}
// Only allow left (primary) clicks to trigger resize as we've received
// bug reports about right click unexpectedly beginning a resize.
if (event.button !== 0) {
return;
}
// It is possible for a mousedown to fire during a resize
// Example: the user presses another pointer button while dragging
if (leftSidebarState.isResizing) {
// the resize will be cancelled by our global event listeners
return;
}
offset.current = event.clientX - leftSidebarState[VAR_LEFT_SIDEBAR_WIDTH] - getLeftPanelWidth();
unbindEvents.current = bindAll(window, [{
type: 'mousemove',
listener: function (event) {
onUpdateResize({
clientX: event.clientX
});
}
}, {
type: 'mouseup',
listener: onFinishResizing
}, {
type: 'mousedown',
// this mousedown event listener is being added in the bubble phase
// on a higher event target than the resize handle.
// This means that the original mousedown event that triggers a resize
// can hit this mousedown handler. To get around that, we only call
// `onFinishResizing` after an animation frame so we don't pick up the original event
// Alternatives:
// 1. Add the window 'mousedown' event listener in the capture phase
// 👎 A 'mousedown' during a resize would trigger a new resize to start
// 2. Do 1. and call `event.preventDefault()`, then check for `event.defaultPrevented` inside
// the grab handle `onMouseDown`
// 👎 Not ideal to cancel events if we don't have to
listener: (() => {
let hasFramePassed = false;
requestAnimationFrame(() => {
hasFramePassed = true;
});
return function listener() {
if (hasFramePassed) {
onFinishResizing();
}
};
})()
}, {
type: 'visibilitychange',
listener: onFinishResizing
},
// A 'click' event should never be hit as the 'mouseup' will come first and cause
// these event listeners to be unbound. I just added 'click' for extreme safety (paranoia)
{
type: 'click',
listener: onFinishResizing
}, {
type: 'keydown',
listener: event => {
// Can cancel resizing by pressing "Escape"
// Will return sidebar to the same size it was before the resizing started
if (event.key === 'Escape') {
sidebarWidth.current = Math.max(leftSidebarState.lastLeftSidebarWidth, COLLAPSED_LEFT_SIDEBAR_WIDTH);
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${sidebarWidth.current}px`);
onFinishResizing();
}
}
}]);
document.documentElement.setAttribute(IS_SIDEBAR_DRAGGING, 'true');
const newLeftbarState = {
...leftSidebarState,
isResizing: true
};
setLeftSidebarState(newLeftbarState);
onResizeStart && onResizeStart(newLeftbarState);
};
const onResizeOffLeftOfScreen = () => {
var _unbindEvents$current;
onUpdateResize.cancel();
(_unbindEvents$current = unbindEvents.current) === null || _unbindEvents$current === void 0 ? void 0 : _unbindEvents$current.call(unbindEvents);
unbindEvents.current = null;
document.documentElement.removeAttribute(IS_SIDEBAR_DRAGGING);
offset.current = 0;
collapseLeftSidebar(undefined, true);
};
// It is important that `onUpdateResize` is a stable function reference, so that:
// 1. we ensure we are correctly throttling with `requestAnimationFrame`
// 2. that a `onUpdateResize` will cancel the one and only pending frame
// To help ensure `onUpdateResize` is stable, we are putting the last state into a ref
const [onUpdateResize] = useState(() => rafSchd(({
clientX
}) => {
// Allow the sidebar to be 50% of the available page width
const maxWidth = Math.round(window.innerWidth / 2);
const leftPanelWidth = getLeftPanelWidth();
const leftSidebarWidth = stableSidebarState.current.leftSidebarWidth;
const hasResizedOffLeftOfScreen = clientX < 0;
if (hasResizedOffLeftOfScreen) {
onResizeOffLeftOfScreen();
return;
}
const delta = Math.max(Math.min(clientX - leftSidebarWidth - leftPanelWidth, maxWidth - leftSidebarWidth - leftPanelWidth), COLLAPSED_LEFT_SIDEBAR_WIDTH - leftSidebarWidth - leftPanelWidth);
sidebarWidth.current = Math.max(leftSidebarWidth + delta - offset.current, COLLAPSED_LEFT_SIDEBAR_WIDTH);
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${sidebarWidth.current}px`);
}));
const onFinishResizing = () => {
var _unbindEvents$current2;
if (isLeftSidebarCollapsed) {
return;
}
document.documentElement.removeAttribute(IS_SIDEBAR_DRAGGING);
// TODO: the control flow is pretty strange as the first codepath which calls `collapseLeftSidebar()`
// does not return an updated state snapshot.
let updatedLeftSidebarState = null;
// If it is dragged to below the threshold,
// collapse the navigation
if (sidebarWidth.current < MIN_LEFT_SIDEBAR_DRAG_THRESHOLD) {
// TODO: for this codepath, `onCollapse` occurs before `onResizeEnd` which seems wrong
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${COLLAPSED_LEFT_SIDEBAR_WIDTH}px`);
collapseLeftSidebar(undefined, true);
}
// If it is dragged to position in between the
// min threshold and default width
// expand the nav to the default width
else if (sidebarWidth.current > MIN_LEFT_SIDEBAR_DRAG_THRESHOLD && sidebarWidth.current < DEFAULT_LEFT_SIDEBAR_WIDTH) {
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${DEFAULT_LEFT_SIDEBAR_WIDTH}px`);
updatedLeftSidebarState = {
...leftSidebarState,
isResizing: false,
[VAR_LEFT_SIDEBAR_WIDTH]: DEFAULT_LEFT_SIDEBAR_WIDTH,
lastLeftSidebarWidth: DEFAULT_LEFT_SIDEBAR_WIDTH
};
setLeftSidebarState(updatedLeftSidebarState);
} else {
// otherwise resize it to the desired width
updatedLeftSidebarState = {
...leftSidebarState,
isResizing: false,
[VAR_LEFT_SIDEBAR_WIDTH]: sidebarWidth.current,
lastLeftSidebarWidth: sidebarWidth.current
};
setLeftSidebarState(updatedLeftSidebarState);
}
(_unbindEvents$current2 = unbindEvents.current) === null || _unbindEvents$current2 === void 0 ? void 0 : _unbindEvents$current2.call(unbindEvents);
unbindEvents.current = null;
onUpdateResize.cancel();
sidebarWidth.current = 0;
offset.current = 0;
// TODO: no idea why this is in an animation frame
requestAnimationFrame(() => {
var _updatedLeftSidebarSt;
setIsGrabAreaFocused(false);
// Note: the `collapseSidebar` codepath does not return state, so we need to pull it from the ref
onResizeEnd === null || onResizeEnd === void 0 ? void 0 : onResizeEnd((_updatedLeftSidebarSt = updatedLeftSidebarState) !== null && _updatedLeftSidebarSt !== void 0 ? _updatedLeftSidebarSt : stableSidebarState.current);
});
};
const onKeyDown = event => {
if (isLeftSidebarCollapsed || !isGrabAreaFocused) {
return false;
}
const {
key
} = event;
const isLeftOrTopArrow = key === 'ArrowLeft' || key === 'ArrowUp' || key === 'Left' || key === 'Up';
const isRightOrBottomArrow = key === 'ArrowRight' || key === 'ArrowDown' || key === 'Right' || key === 'Down';
const isSpaceOrEnter = key === 'Enter' || key === 'Spacebar' || key === ' ';
if (isSpaceOrEnter) {
toggleSideBar(event);
event.preventDefault();
}
if (isLeftOrTopArrow || isRightOrBottomArrow) {
event.preventDefault(); // prevent content scroll
onResizeStart && onResizeStart(leftSidebarState);
const step = 10;
const stepValue = isLeftOrTopArrow ? -step : step;
const {
leftSidebarWidth
} = leftSidebarState;
const maxWidth = Math.round(window.innerWidth / 2) - getLeftPanelWidth();
const hasModifierKey = event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;
let width = leftSidebarWidth + stepValue;
if (width <= DEFAULT_LEFT_SIDEBAR_WIDTH) {
width = DEFAULT_LEFT_SIDEBAR_WIDTH;
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${DEFAULT_LEFT_SIDEBAR_WIDTH - 20}px`);
} else if (width > maxWidth) {
width = maxWidth;
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${maxWidth + 20}px`);
} else if (hasModifierKey) {
width = isRightOrBottomArrow ? maxWidth : DEFAULT_LEFT_SIDEBAR_WIDTH;
}
// Nesting the setTimeout within requestAnimationFrame helps
// the browser schedule the setTimeout call in an efficient manner
requestAnimationFrame(() => {
keyboardEventTimeout.current = window.setTimeout(() => {
keyboardEventTimeout.current && clearTimeout(keyboardEventTimeout.current);
document.documentElement.style.setProperty(`--${VAR_LEFT_SIDEBAR_WIDTH}`, `${width}px`);
const updatedLeftSidebarState = {
...leftSidebarState,
[VAR_LEFT_SIDEBAR_WIDTH]: width,
lastLeftSidebarWidth: width
};
setLeftSidebarState(updatedLeftSidebarState);
onResizeEnd && onResizeEnd(updatedLeftSidebarState);
}, 50);
});
}
};
const onFocus = useCallback(() => {
setIsGrabAreaFocused(true);
}, []);
const onBlur = useCallback(() => {
setIsGrabAreaFocused(false);
}, []);
const resizeButton = {
render: (Component, props) => jsx(Component, props),
...(overrides && overrides.ResizeButton)
};
// This width is calculated once only on mount.
// This means resizing the window will cause this value to be incorrect for screen reader users,
// however this comes with a substantial performance gain and so is considered acceptable.
const maxAriaWidth = useMemo(() => {
const innerWidth = typeof window === 'undefined' ? 0 : window.innerWidth;
return Math.round(innerWidth / 2) - getLeftPanelWidth();
}, []);
const leftSidebarPercentageExpanded = getLeftSidebarPercentage(leftSidebarState.leftSidebarWidth, maxAriaWidth);
return jsx(Fragment, null, jsx("div", _extends({}, cssSelector, {
css: [resizeControlStyles, (isGrabAreaFocused || isLeftSidebarCollapsed) && showResizeButtonStyles]
}), jsx(Shadow, {
testId: testId && `${testId}-shadow`
}),
// Only show the GrabArea if we're not on the mobile viewport
!(mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches) && jsx(GrabArea, {
isDisabled: isLeftSidebarCollapsed,
isLeftSidebarCollapsed: isLeftSidebarCollapsed,
label: resizeGrabAreaLabel,
valueTextLabel: valueTextLabel,
leftSidebarPercentageExpanded: leftSidebarPercentageExpanded,
onBlur: onBlur,
onFocus: onFocus,
onKeyDown: onKeyDown,
onMouseDown: onMouseDown,
testId: testId && `${testId}-grab-area`
}), resizeButton.render(ResizeButton, {
isLeftSidebarCollapsed: mobileMediaQuery !== null && mobileMediaQuery !== void 0 && mobileMediaQuery.matches ? !leftSidebarState.isFlyoutOpen : isLeftSidebarCollapsed,
label: resizeButtonLabel,
onClick: toggleSideBar,
testId: testId && `${testId}-resize-button`
})), leftSidebarState.isResizing ? jsx(Global, {
styles: globalResizingStyles
}) : null);
};
// eslint-disable-next-line @repo/internal/react/require-jsdoc
export default ResizeControl;