Skip to content
Draft
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
13 changes: 12 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,7 +72,7 @@ $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;
Expand Down
47 changes: 46 additions & 1 deletion src/elements/content-sidebar/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import LoadingIndicator from '../../components/loading-indicator/LoadingIndicato
import LocalStore from '../../utils/LocalStore';
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 @@ -80,13 +81,20 @@ type Props = {

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';

// Resize constants — defaults mirror the hardcoded SCSS values ($sidebarTabsWidth + $sidebarContent[Increased]Width).
// When the resizable feature flag is on, these become the minimum drag-to-resize values.
const SIDEBAR_DEFAULT_WIDTH = 400;
const SIDEBAR_DEFAULT_WIDTH_WIDER = 440;
const SIDEBAR_MAX_WIDTH_RATIO = 0.6; // cap at 60% of viewport width

class Sidebar extends React.Component<Props, State> {
static defaultProps = {
annotatorState: {},
Expand All @@ -111,11 +119,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 @@ -320,6 +341,7 @@ class Sidebar extends React.Component<Props, State> {
theme,
versionsSidebarProps,
}: 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 +353,37 @@ 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;

// SPIKE: mocked true. Before shipping, destructure `features` from props and replace with
// `isFeatureEnabled(features, 'contentSidebar.resizable.enabled')`.
const isResizable = true;
const minWidth = this.getDefaultWidth(hasNativeBoxAISidebar, hasCustomBoxAISidebar);
const maxWidth =
typeof window !== 'undefined'
? Math.max(minWidth, Math.round(window.innerWidth * SIDEBAR_MAX_WIDTH_RATIO))
: minWidth;
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
109 changes: 109 additions & 0 deletions src/elements/content-sidebar/SidebarResizeHandle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @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,
onResizeEnd?: () => void,
onResizeStart?: () => void,
width: number,
};

const KEYBOARD_STEP = 16;

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

const SidebarResizeHandle = ({ maxWidth, minWidth, onResize, onResizeEnd, onResizeStart, 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;
if (target instanceof Element && target.hasPointerCapture?.(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
onResizeEnd?.();
},
[handlePointerMove, onResizeEnd],
);

const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
startXRef.current = event.clientX;
startWidthRef.current = width;
setIsDragging(true);
event.currentTarget.setPointerCapture?.(event.pointerId);
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
onResizeStart?.();
};

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
// ArrowLeft grows (same direction as dragging left), ArrowRight shrinks.
if (event.key === 'ArrowLeft') {
event.preventDefault();
onResize(clamp(width + KEYBOARD_STEP, minWidth, maxWidth));
} else if (event.key === 'ArrowRight') {
event.preventDefault();
onResize(clamp(width - KEYBOARD_STEP, minWidth, maxWidth));
} else if (event.key === 'Home') {
event.preventDefault();
onResize(minWidth);
} else if (event.key === 'End') {
event.preventDefault();
onResize(maxWidth);
}
};

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

// role="separator" with aria-valuenow is a focusable ARIA widget and needs both
// pointer + keyboard interactions on the element itself.
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
return (
<div
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemax={maxWidth}
aria-valuemin={minWidth}
aria-valuenow={width}
className={`bcs-resize-handle${isDragging ? ' bcs-resize-handle-is-dragging' : ''}`}
data-testid="sidebar-resize-handle"
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
role="separator"
tabIndex={0}
/>
);
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */
};

export default SidebarResizeHandle;
25 changes: 25 additions & 0 deletions src/elements/content-sidebar/SidebarResizeHandle.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@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,
&:focus-visible,
&.bcs-resize-handle-is-dragging {
background-color: $bdl-box-blue;
outline: none;
}

&:focus-visible {
outline: 2px solid $bdl-box-blue;
outline-offset: -2px;
}
}
Loading