Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/elements/content-sidebar/ContentSidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ $sidebarDefaultErrorIncreasedWidth: $sidebarContentIncreasedWidth - 16px;

.be {
&.bcs {
position: relative;
display: flex;
width: auto;
min-width: $sidebarTabsWidth;
Expand All @@ -17,6 +18,16 @@ $sidebarDefaultErrorIncreasedWidth: $sidebarContentIncreasedWidth - 16px;
&.bcs-is-wider {
max-width: $sidebarIncreasedWidth;
}

// When resizable, the default min-width is the un-resized sidebar width.
// Inline style on the aside element overrides the default max-width to allow grow.
&.bcs-is-resizable.bcs-is-open {
min-width: $sidebarWidth;

&.bcs-is-wider {
min-width: $sidebarIncreasedWidth;
}
}
}

.bcs-loading {
Expand Down Expand Up @@ -61,11 +72,20 @@ $sidebarDefaultErrorIncreasedWidth: $sidebarContentIncreasedWidth - 16px;
min-width: 0;
max-width: none;
max-height: 48px;
transition: max-height .5s ease-in-out 0s;
transition: max-height 0.5s ease-in-out 0s;

&.bcs-is-wider {
max-width: none;
}

// Neutralize resizable overrides on small screens — sidebar is a bottom sheet here
&.bcs-is-resizable.bcs-is-open {
min-width: 0;

&.bcs-is-wider {
min-width: 0;
}
}
}

&.bcs-is-open {
Expand Down
54 changes: 52 additions & 2 deletions src/elements/content-sidebar/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import { withRouter } from 'react-router-dom';
import type { Location, RouterHistory } from 'react-router-dom';
import LoadingIndicator from '../../components/loading-indicator/LoadingIndicator';
import LocalStore from '../../utils/LocalStore';
import withMediaQuery from '../../components/media-query/withMediaQuery';
import { VIEW_SIZE_TYPE } from '../../components/media-query/constants';
import SidebarNav from './SidebarNav';
import SidebarPanels from './SidebarPanels';
import SidebarResizeHandle from './SidebarResizeHandle';
import SidebarUtils from './SidebarUtils';
// $FlowFixMe TypeScript file
import ThemingStyles from '../common/theming';
Expand Down Expand Up @@ -74,19 +77,29 @@ type Props = {
/** When true, enables data fetching. When false, defers data fetching. Used to prioritize preview loading. */
shouldFetchSidebarData?: boolean,
signSidebarProps: SignSidebarProps,
size: $Values<typeof VIEW_SIZE_TYPE>,
theme?: Theme,
versionsSidebarProps: VersionsSidebarProps,
viewWidth: number,
};

type State = {
isDirty: boolean,
width: ?number,
};

export const SIDEBAR_FORCE_KEY: 'bcs.force' = 'bcs.force';
export const SIDEBAR_FORCE_VALUE_CLOSED: 'closed' = 'closed';
export const SIDEBAR_FORCE_VALUE_OPEN: 'open' = 'open';
export const SIDEBAR_SELECTED_PANEL_KEY: 'sidebar-selected-panel' = 'sidebar-selected-panel';

// Default widths mirror the hardcoded SCSS values ($sidebarTabsWidth + $sidebarContent[Increased]Width).
// When the resizable feature flag is on, these become the minimum drag-to-resize widths.
const SIDEBAR_DEFAULT_WIDTH = 400;
const SIDEBAR_DEFAULT_WIDTH_WIDER = 440;
// Cap dragged width at this fraction of the current viewport width.
const SIDEBAR_MAX_WIDTH_RATIO = 0.5;

class Sidebar extends React.Component<Props, State> {
static defaultProps = {
annotatorState: {},
Expand All @@ -111,11 +124,24 @@ class Sidebar extends React.Component<Props, State> {

this.state = {
isDirty: this.getLocationState('open') || false,
width: null,
};

this.setForcedByLocation();
}

/**
* Default sidebar width based on whether the "wider" (Box AI) variant is active.
* Mirrors the SCSS fallback so flipping the flag on doesn't change the rendered width at rest.
*/
getDefaultWidth(hasNativeBoxAISidebar: boolean, hasCustomBoxAISidebar: boolean): number {
return hasNativeBoxAISidebar || hasCustomBoxAISidebar ? SIDEBAR_DEFAULT_WIDTH_WIDER : SIDEBAR_DEFAULT_WIDTH;
}

handleResize = (width: number): void => {
this.setState({ width });
};

componentDidMount() {
const { file, api, metadataSidebarProps, docGenSidebarProps, onOpenChange = noop }: Props = this.props;
// if docgen feature is enabled, load metadata to check whether file is a docgen template
Expand Down Expand Up @@ -304,6 +330,7 @@ class Sidebar extends React.Component<Props, State> {
customSidebarPanels = [],
detailsSidebarProps,
docGenSidebarProps,
features,
file,
fileId,
getPreview,
Expand All @@ -317,9 +344,12 @@ class Sidebar extends React.Component<Props, State> {
onAnnotationSelect,
onVersionChange,
signSidebarProps,
size,
theme,
versionsSidebarProps,
viewWidth,
}: Props = this.props;
const { width }: State = this.state;
const isOpen = this.isOpen();

const hasCustomBoxAISidebar = customSidebarPanels.some(panel => panel.id === SIDEBAR_VIEW_BOXAI);
Expand All @@ -331,14 +361,34 @@ class Sidebar extends React.Component<Props, State> {
const hasMetadata = SidebarUtils.shouldRenderMetadataSidebar(this.props, metadataEditors);
const hasSkills = SidebarUtils.shouldRenderSkillsSidebar(this.props, file);
const onVersionHistoryClick = hasVersions ? this.handleVersionHistoryClick : this.props.onVersionHistoryClick;

const isViewportWideEnoughToResize = size === VIEW_SIZE_TYPE.large || size === VIEW_SIZE_TYPE.xlarge;
const isResizable =
isFeatureEnabled(features, 'contentSidebar.resizable.enabled') && isViewportWideEnoughToResize;
const minWidth = this.getDefaultWidth(hasNativeBoxAISidebar, hasCustomBoxAISidebar);
const maxWidth = Math.max(minWidth, Math.round(viewWidth * SIDEBAR_MAX_WIDTH_RATIO));
const currentWidth = width != null ? Math.min(Math.max(width, minWidth), maxWidth) : minWidth;
// Only force inline width once the user has actually dragged — otherwise leave the SCSS defaults in place.
const shouldApplyInlineWidth = isResizable && isOpen && width != null;
const inlineStyle = shouldApplyInlineWidth ? { width: currentWidth, maxWidth: currentWidth } : undefined;

const styleClassName = classNames('be bcs', className, {
'bcs-is-open': isOpen,
'bcs-is-resizable': isResizable,
'bcs-is-wider': hasNativeBoxAISidebar || hasCustomBoxAISidebar,
});
const defaultPanel = this.getDefaultPanel();

return (
<aside id={this.id} className={styleClassName} data-testid="preview-sidebar">
<aside id={this.id} className={styleClassName} data-testid="preview-sidebar" style={inlineStyle}>
{isResizable && isOpen && !isLoading && (
<SidebarResizeHandle
maxWidth={maxWidth}
minWidth={minWidth}
onResize={this.handleResize}
width={currentWidth}
/>
)}
<ThemingStyles theme={theme} />
{isLoading ? (
<div className="bcs-loading">
Expand Down Expand Up @@ -404,4 +454,4 @@ class Sidebar extends React.Component<Props, State> {
}

export { Sidebar as SidebarComponent };
export default flow([withCurrentUser, withFeatureConsumer, withRouter])(Sidebar);
export default flow([withCurrentUser, withFeatureConsumer, withMediaQuery, withRouter])(Sidebar);
9 changes: 9 additions & 0 deletions src/elements/content-sidebar/SidebarContent.scss
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ $sidebarScrollContentIncreasedWidth: $sidebarScrollContentWidth + 40px;
}
}

// When resizable, let .bcs-content and its scroll children fill the resized aside
.bcs-is-resizable .bcs-content {
width: 100%;

.bcs-scroll-content {
width: 100%;
}
}

@include breakpoint($medium-screen) {
.bcs-content,
.bcs-is-wider .bcs-content {
Expand Down
82 changes: 82 additions & 0 deletions src/elements/content-sidebar/SidebarResizeHandle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* @flow
* @file Sidebar Resize Handle — drag-to-resize grip on the left edge of the sidebar.
* @author Box
*/

import * as React from 'react';
import './SidebarResizeHandle.scss';

type Props = {
maxWidth: number,
minWidth: number,
onResize: (width: number) => void,
width: number,
};

const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max);

const SidebarResizeHandle = ({ maxWidth, minWidth, onResize, width }: Props) => {
const startXRef = React.useRef<number>(0);
const startWidthRef = React.useRef<number>(width);
const [isDragging, setIsDragging] = React.useState(false);

const handlePointerMove = React.useCallback(
(event: PointerEvent) => {
// Sidebar lives on the RIGHT edge of the viewport, and the handle is on its LEFT edge.
// Dragging LEFT (smaller clientX) should GROW the sidebar.
const deltaX = startXRef.current - event.clientX;
const nextWidth = clamp(startWidthRef.current + deltaX, minWidth, maxWidth);
onResize(nextWidth);
},
[maxWidth, minWidth, onResize],
);

const handlePointerUp = React.useCallback(
(event: PointerEvent) => {
setIsDragging(false);
const { target } = event;
// Flow lib types pointer-capture methods as `(string)`; the real DOM API takes a number.
// Flow's Element doesn't declare `hasPointerCapture` either, so cast for that one call.
const pointerId = ((event.pointerId: any): string);
if (target instanceof Element && (target: any).hasPointerCapture(pointerId)) {
target.releasePointerCapture(pointerId);
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
},
[handlePointerMove],
);

const handlePointerDown = (event: SyntheticPointerEvent<HTMLDivElement>) => {
event.preventDefault();
startXRef.current = event.clientX;
startWidthRef.current = width;
setIsDragging(true);
// Flow lib types setPointerCapture as `(string)`; the real DOM API takes a number.
// jsdom doesn't implement setPointerCapture, so guard the call.
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(((event.pointerId: any): string));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
};

React.useEffect(() => {
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
};
}, [handlePointerMove, handlePointerUp]);

return (
<div
aria-hidden="true"
className={`bcs-resize-handle${isDragging ? ' bcs-resize-handle-is-dragging' : ''}`}
data-testid="sidebar-resize-handle"
onPointerDown={handlePointerDown}
/>
);
};

export default SidebarResizeHandle;
18 changes: 18 additions & 0 deletions src/elements/content-sidebar/SidebarResizeHandle.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
@import '../common/variables';

.bcs-resize-handle {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 6px;
height: 100%;
cursor: col-resize;
background-color: transparent;
transition: background-color 0.15s ease;

&:hover,
&.bcs-resize-handle-is-dragging {
background-color: $bdl-box-blue;
}
}
89 changes: 89 additions & 0 deletions src/elements/content-sidebar/__tests__/Sidebar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,95 @@ describe('elements/content-sidebar/Sidebar', () => {
});
});

describe('resizable', () => {
const resizableProps = {
features: { contentSidebar: { resizable: { enabled: true } } },
size: 'large',
viewWidth: 1600,
};

test('renders the resize handle and bcs-is-resizable class when feature is on and viewport is large', () => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_OPEN),
setItem: jest.fn(),
}));
const wrapper = getWrapper(resizableProps);

expect(wrapper.hasClass('bcs-is-resizable')).toBe(true);
expect(wrapper.find('SidebarResizeHandle').exists()).toBe(true);
});

test('does not render the resize handle when the feature flag is off', () => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_OPEN),
setItem: jest.fn(),
}));
const wrapper = getWrapper({
...resizableProps,
features: { contentSidebar: { resizable: { enabled: false } } },
});

expect(wrapper.hasClass('bcs-is-resizable')).toBe(false);
expect(wrapper.find('SidebarResizeHandle').exists()).toBe(false);
});

test.each(['small', 'medium'])(
'does not render the resize handle on %s viewports even when the flag is on',
size => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_OPEN),
setItem: jest.fn(),
}));
const wrapper = getWrapper({ ...resizableProps, size });

expect(wrapper.hasClass('bcs-is-resizable')).toBe(false);
expect(wrapper.find('SidebarResizeHandle').exists()).toBe(false);
},
);

test('does not render the resize handle when the sidebar is closed', () => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_CLOSED),
setItem: jest.fn(),
}));
const wrapper = getWrapper(resizableProps);

expect(wrapper.find('SidebarResizeHandle').exists()).toBe(false);
});

test('does not apply inline width until the user has dragged', () => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_OPEN),
setItem: jest.fn(),
}));
const wrapper = getWrapper(resizableProps);

expect(wrapper.find('aside').prop('style')).toBeUndefined();
});

test('applies inline width once handleResize has been called', () => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_OPEN),
setItem: jest.fn(),
}));
const wrapper = getWrapper(resizableProps);
wrapper.instance().handleResize(550);
wrapper.update();

expect(wrapper.find('aside').prop('style')).toEqual({ width: 550, maxWidth: 550 });
});

test('caps maxWidth at 50% of the viewport width', () => {
LocalStore.mockImplementationOnce(() => ({
getItem: jest.fn(() => SIDEBAR_FORCE_VALUE_OPEN),
setItem: jest.fn(),
}));
const wrapper = getWrapper({ ...resizableProps, viewWidth: 1200 });

expect(wrapper.find('SidebarResizeHandle').prop('maxWidth')).toBe(600);
});
});

describe('on panel change', () => {
const mockSetItem = jest.fn();
const mockPanelName = 'activity';
Expand Down
Loading
Loading