Skip to content

feat(content-sidebar): Add drag-to-resize sidebar behind feature flag#4574

Open
mrscobbler wants to merge 6 commits into
box:masterfrom
mrscobbler:preview-sidebar-resizable
Open

feat(content-sidebar): Add drag-to-resize sidebar behind feature flag#4574
mrscobbler wants to merge 6 commits into
box:masterfrom
mrscobbler:preview-sidebar-resizable

Conversation

@mrscobbler
Copy link
Copy Markdown
Collaborator

@mrscobbler mrscobbler commented May 20, 2026

Summary

  • Adds a drag-to-resize handle on the left edge of the content sidebar, gated behind contentSidebar.resizable.enabled feature flag
  • Persists user-chosen width to LocalStorage (bcs.sidebar.width) and restores on next load
  • Caps max width at 50% of viewport; only enables on large/xlarge viewports (bottom-sheet mode on small screens is unaffected)
  • Adds CSS overrides so sidebar content (activity feed, federated panels) fills the resized width

Video:

Screen.Recording.2026-05-20.at.12.10.29.PM.mov

Test plan

  • Verify resize handle appears on large viewports when contentSidebar.resizable.enabled is true
  • Verify drag left grows sidebar, drag right shrinks, respects min/max bounds
  • Verify width persists across page refreshes
  • Verify no resize handle on small/medium viewports
  • Verify no resize handle when sidebar is closed
  • Verify Box AI "wider" sidebar variant uses correct min-width
  • Verify activity feed and federated module panels fill resized width
  • Run yarn test — all 14 changed-file test suites pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Sidebar can now be resized by dragging a handle.
    • Resized sidebar widths are automatically saved and restored across sessions.
    • Resizing is available on larger screens.
  • Tests

    • Added test coverage for sidebar resizing functionality and user interactions.

Review Change Stack

jmcbgaston and others added 6 commits May 14, 2026 17:48
Adds a pointer- and keyboard-accessible resize handle on the left edge
of the sidebar. Current default width becomes the minimum; maximum is
clamped at 50% of the viewport. Width state lives on the `Sidebar`
component and is session-only (no persistence).

The new `SidebarResizeHandle` component is a `role="separator"` with
live `aria-valuenow` / `aria-valuemin` / `aria-valuemax`, supports
ArrowLeft / ArrowRight / Home / End for keyboard resize, and uses
pointer capture during drag.

Gated by `isFeatureEnabled(features, 'contentSidebar.resizable.enabled')`
and additionally restricted to large / x-large viewports via the
existing `withMediaQuery` HOC — small / medium viewports keep the
original bottom-sheet layout. SCSS overrides for `.bcs-content` and
`.bcs-activity-feed` are scoped under `.bcs-is-resizable` so flag-off
behavior is unchanged.
- Make `maxWidth` reactive by reading `viewWidth` from withMediaQuery
  (previously read `window.innerWidth` at render time, leaving the cap
  stale across browser resizes)
- Drop unused `onResizeStart` / `onResizeEnd` props from
  `SidebarResizeHandle` — no caller passes them
- Simplify SCSS selectors: `.bcs-is-resizable.bcs-is-wider .bcs-content`
  was redundant with `.bcs-is-resizable .bcs-content` (same specificity,
  source order wins)
- Replace TS-style React event types with Flow's Synthetic* and replace
  optional-chain calls with explicit guards so Flow typecheck passes
- Strip keyboard interaction for MVP; mark the handle `aria-hidden="true"`
  so AT users aren't shown an unfocusable widget
- Add `SidebarResizeHandle.test.js` covering render, drag-grow,
  drag-shrink, min/max clamping, dragging class, and listener cleanup
- Add a `resizable` describe block to `Sidebar.test.js` covering FF gate,
  viewport gate, open-state gate, inline-width application, and
  maxWidth-from-viewport behavior
Flow's lib doesn't refine `EventTarget` to a type with pointer-capture
methods, so `typeof === 'function'` checks weren't enough. Switch to an
`instanceof Element` refinement (which Flow understands) for
`releasePointerCapture`, with a small `(target: any)` escape for
`hasPointerCapture` — Flow's `Element` lib declares the former but not
the latter.

Also cast `event.pointerId` (number) to string when passing to the
pointer-capture methods: Flow types them as `(string)` even though the
real DOM API takes a number.

Keep the `typeof setPointerCapture === 'function'` guard at runtime —
jsdom doesn't implement it, so dropping the guard breaks unit tests.
…dle cleanup

- Persist resized width to localStorage on pointerup, restore on mount
- Add [role='tabpanel'] { width: 100% } so CSS Modules panels (Box AI) fill the resized sidebar
- Remove blue hover/drag highlight, bump z-index to 10, widen hit target to 8px
- Add onResizeEnd callback for clean separation of drag vs commit events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mrscobbler mrscobbler requested review from a team as code owners May 20, 2026 19:15
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

Walkthrough

This PR introduces sidebar width resizing with drag-to-resize handle, width persistence, and responsive styling. New SidebarResizeHandle component handles pointer events and width clamping. Sidebar integrates resizable state management, feature flagging, viewport size checks, and local storage persistence. Stylesheet updates accommodate resizable width across content hierarchy.

Changes

Resizable Content Sidebar

Layer / File(s) Summary
SidebarResizeHandle component
src/elements/content-sidebar/SidebarResizeHandle.js, src/elements/content-sidebar/SidebarResizeHandle.scss, src/elements/content-sidebar/__tests__/SidebarResizeHandle.test.js
New component tracks pointer drag state, computes width deltas clamped between minWidth and maxWidth, invokes onResize continuously during drag, and calls onResizeEnd on pointer release. Global pointermove and pointerup listeners attach on pointerdown and clean up on unmount. Styled as an 8px-wide, full-height handle with col-resize cursor. Tests cover drag left/right, clamping bounds, dragging-class lifecycle, listener cleanup, and onResizeEnd callback.
Sidebar state, resizable logic, and integration
src/elements/content-sidebar/Sidebar.js, src/elements/content-sidebar/__tests__/Sidebar.test.js
Adds width to component state, reads persisted width from LocalStore, and persists on resize-end. Imports withMediaQuery HOC and VIEW_SIZE_TYPE constants. Computes isResizable from feature flag features.contentSidebar.resizable.enabled and media-query size (large/xlarge only). Default width depends on Box AI sidebar variant. Render conditionally shows SidebarResizeHandle and applies inline width/maxWidth styles after drag. HOC wrapper updated to include withMediaQuery. Tests assert handle visibility by feature flag and viewport, inline style application after drag, and maxWidth capping at 50% of viewport width.
Sidebar hierarchy stylesheet updates
src/elements/content-sidebar/ContentSidebar.scss, src/elements/content-sidebar/SidebarContent.scss, src/elements/content-sidebar/activity-feed/activity-feed/ActivityFeed.scss
ContentSidebar.scss adds position: relative to base rule, explicit min-width handling for .bcs-is-resizable.bcs-is-open (and wider variant), [role='tabpanel'] width 100%, and small-screen breakpoint neutralizes resizable min-width overrides to 0. SidebarContent.scss sets .bcs-content and .bcs-scroll-content to 100% width under .bcs-is-resizable. ActivityFeed.scss sets .bcs-activity-feed to 100% width under resizable state.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

ready-to-merge

Suggested reviewers

  • tjiang-box
  • jpan-box
  • tjuanitas
  • Lindar90

Poem

📦 A sidebar grows wide and tall,
With gentle drags to resize it all,
The handle grips, the width persists,
Feature flags and state coexist. 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the main feature being added: a drag-to-resize sidebar component behind a feature flag.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description provides a clear summary of changes with a video demo and a comprehensive test plan checklist, closely matching the template's guidance on readiness to merge.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/elements/content-sidebar/__tests__/SidebarResizeHandle.test.js (1)

32-137: ⚡ Quick win

Add a regression test for pointer-id isolation.

Given window-level listeners, add a case where drag starts with pointer A and move/up from pointer B are ignored. It will protect against multi-pointer regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/elements/content-sidebar/__tests__/SidebarResizeHandle.test.js` around
lines 32 - 137, Add a regression test to ensure pointer-id isolation for
SidebarResizeHandle: simulate a pointerdown with pointerId=1 on the handle, then
dispatch window pointermove and pointerup events with a different pointerId
(e.g., 2) via dispatchWindowPointer and assert that onResize and onResizeEnd are
NOT called (or not called with changed widths) and that the dragging class
remains set only for the original pointer; also include a positive follow-up
asserting that events with the original pointerId do trigger
onResize/onResizeEnd. This verifies that the component's window listeners ignore
events from other pointerIds and cleans up correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/elements/content-sidebar/SidebarResizeHandle.js`:
- Around line 25-65: The resize handlers must track the active pointer and
ignore non-primary buttons: add a pointerIdRef and in handlePointerDown only
start when event.isPrimary or event.button === 0, store event.pointerId into
pointerIdRef, call setPointerCapture as before and attach listeners; in
handlePointerMove and handlePointerUp first check that event.pointerId ===
pointerIdRef.current (and optionally event.isPrimary) and return early if not;
in handlePointerUp clear pointerIdRef and release pointer capture before
removing window listeners, then compute finalWidth and call onResize/onResizeEnd
as currently done. Use the existing functions handlePointerDown,
handlePointerMove, handlePointerUp and existing refs
startXRef/startWidthRef/width/onResize/onResizeEnd to locate where to make these
changes.

---

Nitpick comments:
In `@src/elements/content-sidebar/__tests__/SidebarResizeHandle.test.js`:
- Around line 32-137: Add a regression test to ensure pointer-id isolation for
SidebarResizeHandle: simulate a pointerdown with pointerId=1 on the handle, then
dispatch window pointermove and pointerup events with a different pointerId
(e.g., 2) via dispatchWindowPointer and assert that onResize and onResizeEnd are
NOT called (or not called with changed widths) and that the dragging class
remains set only for the original pointer; also include a positive follow-up
asserting that events with the original pointerId do trigger
onResize/onResizeEnd. This verifies that the component's window listeners ignore
events from other pointerIds and cleans up correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 73201f4e-4fec-4070-96e7-3d85710bbd31

📥 Commits

Reviewing files that changed from the base of the PR and between fa9ccea and 6b7be61.

📒 Files selected for processing (8)
  • src/elements/content-sidebar/ContentSidebar.scss
  • src/elements/content-sidebar/Sidebar.js
  • src/elements/content-sidebar/SidebarContent.scss
  • src/elements/content-sidebar/SidebarResizeHandle.js
  • src/elements/content-sidebar/SidebarResizeHandle.scss
  • src/elements/content-sidebar/__tests__/Sidebar.test.js
  • src/elements/content-sidebar/__tests__/SidebarResizeHandle.test.js
  • src/elements/content-sidebar/activity-feed/activity-feed/ActivityFeed.scss

Comment on lines +25 to +65
const handlePointerMove = React.useCallback(
(event: PointerEvent) => {
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;
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);

const deltaX = startXRef.current - event.clientX;
const finalWidth = clamp(startWidthRef.current + deltaX, minWidth, maxWidth);
onResize(finalWidth);
if (onResizeEnd) {
onResizeEnd(finalWidth);
}
},
[handlePointerMove, maxWidth, minWidth, onResize, onResizeEnd],
);

const handlePointerDown = (event: SyntheticPointerEvent<HTMLDivElement>) => {
event.preventDefault();
startXRef.current = event.clientX;
startWidthRef.current = width;
setIsDragging(true);
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(((event.pointerId: any): string));
}
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Track the active pointer and ignore non-primary button drags.

Resize currently reacts to any pointermove/pointerup on window and starts on any mouse button. This can cause accidental width updates (multi-pointer or right-click interactions).

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

     const handlePointerMove = React.useCallback(
         (event: PointerEvent) => {
+            if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) {
+                return;
+            }
             const deltaX = startXRef.current - event.clientX;
             const nextWidth = clamp(startWidthRef.current + deltaX, minWidth, maxWidth);
             onResize(nextWidth);
@@
     const handlePointerUp = React.useCallback(
         (event: PointerEvent) => {
+            if (activePointerIdRef.current !== null && event.pointerId !== activePointerIdRef.current) {
+                return;
+            }
             setIsDragging(false);
@@
             window.removeEventListener('pointermove', handlePointerMove);
             window.removeEventListener('pointerup', handlePointerUp);
+            activePointerIdRef.current = null;
@@
     const handlePointerDown = (event: SyntheticPointerEvent<HTMLDivElement>) => {
+        if (event.button !== 0) {
+            return;
+        }
         event.preventDefault();
         startXRef.current = event.clientX;
         startWidthRef.current = width;
+        activePointerIdRef.current = event.pointerId;
         setIsDragging(true);
🧰 Tools
🪛 Biome (2.4.15)

[error] 26-26: Type annotations are a TypeScript only feature. Convert your file to a TypeScript file or remove the syntax.

(parse)


[error] 35-35: Type annotations are a TypeScript only feature. Convert your file to a TypeScript file or remove the syntax.

(parse)


[error] 38-38: expected ) but instead found :

(parse)


[error] 39-39: expected ) but instead found :

(parse)


[error] 55-55: Type annotations are a TypeScript only feature. Convert your file to a TypeScript file or remove the syntax.

(parse)


[error] 61-61: expected ) but instead found :

(parse)


[error] 61-61: Expected a semicolon or an implicit semicolon after a statement, but found none

(parse)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/elements/content-sidebar/SidebarResizeHandle.js` around lines 25 - 65,
The resize handlers must track the active pointer and ignore non-primary
buttons: add a pointerIdRef and in handlePointerDown only start when
event.isPrimary or event.button === 0, store event.pointerId into pointerIdRef,
call setPointerCapture as before and attach listeners; in handlePointerMove and
handlePointerUp first check that event.pointerId === pointerIdRef.current (and
optionally event.isPrimary) and return early if not; in handlePointerUp clear
pointerIdRef and release pointer capture before removing window listeners, then
compute finalWidth and call onResize/onResizeEnd as currently done. Use the
existing functions handlePointerDown, handlePointerMove, handlePointerUp and
existing refs startXRef/startWidthRef/width/onResize/onResizeEnd to locate where
to make these changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants