feat(content-sidebar): Add drag-to-resize sidebar behind feature flag#4574
feat(content-sidebar): Add drag-to-resize sidebar behind feature flag#4574mrscobbler wants to merge 6 commits into
Conversation
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>
WalkthroughThis PR introduces sidebar width resizing with drag-to-resize handle, width persistence, and responsive styling. New ChangesResizable Content Sidebar
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/elements/content-sidebar/__tests__/SidebarResizeHandle.test.js (1)
32-137: ⚡ Quick winAdd 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
📒 Files selected for processing (8)
src/elements/content-sidebar/ContentSidebar.scsssrc/elements/content-sidebar/Sidebar.jssrc/elements/content-sidebar/SidebarContent.scsssrc/elements/content-sidebar/SidebarResizeHandle.jssrc/elements/content-sidebar/SidebarResizeHandle.scsssrc/elements/content-sidebar/__tests__/Sidebar.test.jssrc/elements/content-sidebar/__tests__/SidebarResizeHandle.test.jssrc/elements/content-sidebar/activity-feed/activity-feed/ActivityFeed.scss
| 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); | ||
| }; |
There was a problem hiding this comment.
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.
Summary
contentSidebar.resizable.enabledfeature flagbcs.sidebar.width) and restores on next loadVideo:
Screen.Recording.2026-05-20.at.12.10.29.PM.mov
Test plan
contentSidebar.resizable.enabledis trueyarn test— all 14 changed-file test suites pass🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests