feat(ui): add resizable sidebar panels with persistence
- use-panel-resize hook: Drag-to-resize with localStorage persistence - ResizeHandle component: Visual drag handle with hover effects - UnifiedShell: Flexbox layout with resizable left/right panels - RightPanel: Removed internal width handling (now controlled by shell)
This commit is contained in:
parent
ebd3ffcbbe
commit
6250335dc8
4 changed files with 157 additions and 26 deletions
64
src/components/shared/resize-handle.tsx
Normal file
64
src/components/shared/resize-handle.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import React, { useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface ResizeHandleProps {
|
||||
direction: 'left' | 'right';
|
||||
onResize: (delta: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
}
|
||||
|
||||
export function ResizeHandle({ direction, onResize, onResizeEnd }: ResizeHandleProps) {
|
||||
const isDragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
const delta = direction === 'left'
|
||||
? e.clientX - startX.current
|
||||
: startX.current - e.clientX;
|
||||
startX.current = e.clientX;
|
||||
onResize(delta);
|
||||
}, [direction, onResize]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging.current) {
|
||||
isDragging.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
onResizeEnd?.();
|
||||
}
|
||||
}, [onResizeEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
w-1 h-full cursor-col-resize
|
||||
bg-transparent hover:bg-[var(--ui-accent-info)]/30
|
||||
transition-colors duration-150
|
||||
flex-shrink-0 z-10
|
||||
group relative
|
||||
`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="absolute inset-y-0 -left-1 -right-1 group-hover:bg-[var(--ui-accent-info)]/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,18 +16,12 @@ export function RightPanel({ children, rail, isOpen: externalIsOpen }: RightPane
|
|||
const { rightPanel, toggleRightPanel } = useUrlState();
|
||||
|
||||
const isOpen = externalIsOpen ?? (rightPanel === 'open');
|
||||
|
||||
// Calculate width based on content (Standard 17rem vs Chat Mode ~26rem)
|
||||
// If rail is present, we are in "Chat Mode" (Wide Panel + Rail)
|
||||
// If no rail, we are in "Activity Mode" (Standard Panel)
|
||||
const panelWidth = isOpen ? '20.75rem' : '0';
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div
|
||||
className="ui-shell-panel flex overflow-hidden transition-all duration-300"
|
||||
className="ui-shell-panel flex overflow-hidden h-full"
|
||||
style={{
|
||||
width: panelWidth,
|
||||
boxShadow: isOpen ? '-24px 0 40px -26px rgba(0,0,0,0.95), inset 1px 0 0 rgba(91,168,160,0.22)' : 'none',
|
||||
}}
|
||||
data-testid="right-panel-desktop"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { LeftPanel, type LeftPanelFilters } from './left-panel';
|
|||
import { RightPanel } from './right-panel';
|
||||
import { MobileNav } from './mobile-nav';
|
||||
import { ThreadDrawer } from './thread-drawer';
|
||||
import { ResizeHandle } from './resize-handle';
|
||||
import { useUrlState } from '../../hooks/use-url-state';
|
||||
import { usePanelResize } from '../../hooks/use-panel-resize';
|
||||
import { SmartDag } from '../graph/smart-dag';
|
||||
import { SocialPage } from '../social/social-page';
|
||||
import { buildSocialCards } from '../../lib/social-cards';
|
||||
|
|
@ -91,8 +93,8 @@ export function UnifiedShell({
|
|||
const drawerTitle = selectedSocialCard?.title || selectedSwarmCard?.title || '';
|
||||
const drawerId = taskId || swarmId || '';
|
||||
|
||||
// Grid Layout: Fixed width for right panel to match right-panel.tsx
|
||||
const rightPanelWidth = panel === 'open' ? '20.75rem' : '0rem';
|
||||
// Panel resize hook
|
||||
const { leftWidth, rightWidth, handleLeftResize, handleRightResize } = usePanelResize();
|
||||
|
||||
const renderMiddleContent = () => {
|
||||
// Filter issues by Epic if selected (Global Filter)
|
||||
|
|
@ -160,31 +162,41 @@ export function UnifiedShell({
|
|||
{/* TOP BAR: 3rem fixed */}
|
||||
<TopBar />
|
||||
|
||||
{/* MAIN AREA: CSS Grid [18rem | 1fr | RightPanel] */}
|
||||
{/* Increased Left Panel width to 18rem per redesign request */}
|
||||
{/* MAIN AREA: Flex layout for resizable panels */}
|
||||
<div
|
||||
className="flex-1 grid overflow-hidden transition-all duration-300"
|
||||
style={{ gridTemplateColumns: `20rem 1fr ${rightPanelWidth}` }}
|
||||
className="flex-1 flex overflow-hidden"
|
||||
data-testid="main-area"
|
||||
>
|
||||
{/* LEFT PANEL: 20rem unified Epic/Task tree */}
|
||||
<LeftPanel
|
||||
issues={issues}
|
||||
selectedEpicId={epicId}
|
||||
onEpicSelect={setEpicId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
{/* LEFT PANEL */}
|
||||
<div style={{ width: leftWidth }} className="flex-shrink-0 overflow-hidden">
|
||||
<LeftPanel
|
||||
issues={issues}
|
||||
selectedEpicId={epicId}
|
||||
onEpicSelect={setEpicId}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* RESIZE HANDLE: Left */}
|
||||
<ResizeHandle direction="left" onResize={handleLeftResize} />
|
||||
|
||||
{/* MIDDLE CONTENT: flex-1 */}
|
||||
<div className="relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
|
||||
<div className="flex-1 relative overflow-hidden bg-black/10 shadow-inner" data-testid="middle-content">
|
||||
{renderMiddleContent()}
|
||||
</div>
|
||||
|
||||
{/* RIGHT PANEL: Activity or Assignment */}
|
||||
<RightPanel isOpen={panel === 'open'}>
|
||||
{renderRightPanelContent()}
|
||||
</RightPanel>
|
||||
{/* RESIZE HANDLE: Right (only when panel open) */}
|
||||
{panel === 'open' && <ResizeHandle direction="right" onResize={handleRightResize} />}
|
||||
|
||||
{/* RIGHT PANEL */}
|
||||
{panel === 'open' && (
|
||||
<div style={{ width: rightWidth }} className="flex-shrink-0 overflow-hidden">
|
||||
<RightPanel isOpen={true}>
|
||||
{renderRightPanelContent()}
|
||||
</RightPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* THREAD DRAWER: Popup overlay when a task is selected */}
|
||||
|
|
|
|||
61
src/hooks/use-panel-resize.ts
Normal file
61
src/hooks/use-panel-resize.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const LEFT_PANEL_KEY = 'bb.ui.leftPanelWidth';
|
||||
const RIGHT_PANEL_KEY = 'bb.ui.rightPanelWidth';
|
||||
|
||||
const DEFAULT_LEFT_WIDTH = 320;
|
||||
const DEFAULT_RIGHT_WIDTH = 332;
|
||||
|
||||
export const MIN_LEFT_WIDTH = 192;
|
||||
export const MIN_RIGHT_WIDTH = 256;
|
||||
|
||||
export function usePanelResize() {
|
||||
const [leftWidth, setLeftWidth] = useState(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_LEFT_WIDTH;
|
||||
const saved = localStorage.getItem(LEFT_PANEL_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_LEFT_WIDTH;
|
||||
});
|
||||
|
||||
const [rightWidth, setRightWidth] = useState(() => {
|
||||
if (typeof window === 'undefined') return DEFAULT_RIGHT_WIDTH;
|
||||
const saved = localStorage.getItem(RIGHT_PANEL_KEY);
|
||||
return saved ? parseInt(saved, 10) : DEFAULT_RIGHT_WIDTH;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LEFT_PANEL_KEY, String(leftWidth));
|
||||
}, [leftWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(RIGHT_PANEL_KEY, String(rightWidth));
|
||||
}, [rightWidth]);
|
||||
|
||||
const clampLeftWidth = useCallback((width: number) => {
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.30);
|
||||
return Math.max(MIN_LEFT_WIDTH, Math.min(width, maxWidth));
|
||||
}, []);
|
||||
|
||||
const clampRightWidth = useCallback((width: number) => {
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.35);
|
||||
return Math.max(MIN_RIGHT_WIDTH, Math.min(width, maxWidth));
|
||||
}, []);
|
||||
|
||||
const handleLeftResize = useCallback((delta: number) => {
|
||||
setLeftWidth(prev => clampLeftWidth(prev + delta));
|
||||
}, [clampLeftWidth]);
|
||||
|
||||
const handleRightResize = useCallback((delta: number) => {
|
||||
setRightWidth(prev => clampRightWidth(prev + delta));
|
||||
}, [clampRightWidth]);
|
||||
|
||||
return {
|
||||
leftWidth,
|
||||
rightWidth,
|
||||
handleLeftResize,
|
||||
handleRightResize,
|
||||
clampLeftWidth,
|
||||
clampRightWidth,
|
||||
MIN_LEFT_WIDTH,
|
||||
MIN_RIGHT_WIDTH
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue