Files
upstream-plane/apps/web/core/components/sidebar/resizable-sidebar.tsx
upstream-mirror 23059a1de3
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
2026-09-15 15:14:58 +05:00

270 lines
7.9 KiB
TypeScript

/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, ReactElement, SetStateAction } from "react";
import React, { useCallback, useEffect, useState, useRef } from "react";
// helpers
import { usePlatformOS } from "@plane/hooks";
import { cn } from "@plane/utils";
interface ResizableSidebarProps {
showPeek?: boolean;
togglePeek: (value?: boolean) => void;
isCollapsed?: boolean;
width: number;
setWidth: Dispatch<SetStateAction<number>>;
defaultWidth?: number;
minWidth?: number;
maxWidth?: number;
defaultCollapsed?: boolean;
peekDuration?: number;
toggleCollapsed: (value?: boolean) => void;
onWidthChange?: (width: number) => void;
onCollapsedChange?: (collapsed: boolean) => void;
className?: string;
children?: ReactElement;
extendedSidebar?: ReactElement;
isAnyExtendedSidebarExpanded?: boolean;
isAnySidebarDropdownOpen?: boolean;
}
export function ResizableSidebar({
showPeek = false,
togglePeek,
peekDuration = 500,
isCollapsed = false,
toggleCollapsed: toggleCollapsedProp,
onCollapsedChange,
width,
setWidth,
onWidthChange,
minWidth = 236,
maxWidth = 350,
className = "",
children,
extendedSidebar,
isAnyExtendedSidebarExpanded = false,
isAnySidebarDropdownOpen = false,
}: ResizableSidebarProps) {
// states
const [isResizing, setIsResizing] = useState(false);
const [isHoveringTrigger, setIsHoveringTrigger] = useState(false);
// refs
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const initialWidthRef = useRef<number>(0);
const initialMouseXRef = useRef<number>(0);
// hooks
const { isMobile } = usePlatformOS();
// handlers
const setShowPeek = useCallback(
(value: boolean) => {
togglePeek(value);
},
[togglePeek]
);
const handleResize = useCallback(
(e: MouseEvent) => {
if (!isResizing) return;
const deltaX = e.clientX - initialMouseXRef.current;
const newWidth = Math.min(Math.max(initialWidthRef.current + deltaX, minWidth), maxWidth);
setWidth(newWidth);
},
[isResizing, minWidth, maxWidth, setWidth]
);
const startResizing = useCallback(
(e: React.MouseEvent) => {
setIsResizing(true);
initialWidthRef.current = width;
initialMouseXRef.current = e.clientX;
},
[width]
);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const toggleCollapsed = useCallback(() => {
toggleCollapsedProp();
setShowPeek(false);
setIsHoveringTrigger(false);
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}, [toggleCollapsedProp, setShowPeek]);
const handlePeekEnter = useCallback(() => {
if (isCollapsed && showPeek) {
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}
}, [isCollapsed, showPeek]);
const handlePeekLeave = useCallback(() => {
if (isCollapsed && !isAnyExtendedSidebarExpanded && !isAnySidebarDropdownOpen) {
peekTimeoutRef.current = setTimeout(() => {
setShowPeek(false);
}, peekDuration);
}
}, [isCollapsed, peekDuration, setShowPeek, isAnyExtendedSidebarExpanded, isAnySidebarDropdownOpen]);
// Set up event listeners for resizing
useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleResize);
document.addEventListener("mouseup", stopResizing);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleResize);
document.removeEventListener("mouseup", stopResizing);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, handleResize, stopResizing]);
// Clean up timeout on unmount
useEffect(
() => () => {
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
},
[]
);
useEffect(() => {
if (!isAnySidebarDropdownOpen && isCollapsed && isHoveringTrigger) {
handlePeekLeave();
}
}, [isAnySidebarDropdownOpen]);
useEffect(() => {
if (!isAnyExtendedSidebarExpanded && isCollapsed && isHoveringTrigger) {
handlePeekLeave();
}
}, [isAnyExtendedSidebarExpanded]);
// Reset peek when sidebar is expanded
useEffect(() => {
if (!isCollapsed) {
setShowPeek(false);
setIsHoveringTrigger(false);
if (peekTimeoutRef.current) {
clearTimeout(peekTimeoutRef.current);
}
}
}, [isCollapsed, setShowPeek]);
// Call external handlers when state changes
useEffect(() => {
onWidthChange?.(width);
}, [width, onWidthChange]);
useEffect(() => {
onCollapsedChange?.(isCollapsed);
}, [isCollapsed, onCollapsedChange]);
return (
<>
{/* Main Sidebar */}
<div
id="main-sidebar"
className={cn(
"z-20 h-full border-r border-subtle bg-surface-1",
!isResizing && "transition-all duration-300 ease-in-out",
isCollapsed ? "w-0 translate-x-[-100%] opacity-0" : "translate-x-0 opacity-100",
isMobile && "absolute",
className
)}
style={{
width: `${isCollapsed ? 0 : width}px`,
minWidth: `${isCollapsed ? 0 : width}px`,
maxWidth: `${isCollapsed ? 0 : width}px`,
}}
role="complementary"
aria-label="Main sidebar"
data-prevent-outside-click={isMobile}
>
<aside
className={cn(
"group/sidebar relative flex h-full w-full flex-col overflow-hidden bg-surface-1 pt-3",
isAnyExtendedSidebarExpanded && "rounded-none"
)}
>
{children}
{/* Resize Handle */}
<div
className={cn(
"absolute z-[20] h-full w-1 cursor-ew-resize transition-all duration-200",
!isResizing && "hover:bg-surface-2",
isResizing && "w-1.5 bg-layer-1",
"top-0 right-0"
)}
// onDoubleClick toggle sidebar
onDoubleClick={() => toggleCollapsed()}
onMouseDown={(e) => startResizing(e)}
role="separator"
aria-label="Resize sidebar"
/>
</aside>
</div>
{/* Peek View */}
<div
className={cn(
"shadow-sm absolute left-0 z-20 h-full bg-surface-1",
!isResizing && "transition-all duration-300 ease-in-out",
isCollapsed && showPeek ? "translate-x-0 opacity-100" : "translate-x-[-100%] opacity-0",
"pointer-events-none",
isCollapsed && showPeek && "pointer-events-auto",
!showPeek ? "w-0" : "w-full"
)}
style={{
width: `${width}px`,
}}
onMouseEnter={handlePeekEnter}
onMouseLeave={handlePeekLeave}
role="complementary"
aria-label="Sidebar peek view"
>
<aside
className={cn(
"group/sidebar relative z-20 flex h-full w-full flex-col overflow-hidden bg-surface-1 pt-4",
"self-center rounded-md rounded-tl-none rounded-bl-none border-r border-subtle",
isAnyExtendedSidebarExpanded && "rounded-none"
)}
>
{children}
{/* Resize Handle */}
<div
className={cn(
"absolute z-[20] h-full w-1 cursor-ew-resize transition-all duration-200",
!isResizing && "hover:bg-surface-2",
isResizing && "bg-layer-1",
"top-0 right-0"
)}
// onDoubleClick toggle sidebar
onDoubleClick={() => toggleCollapsed()}
onMouseDown={(e) => startResizing(e)}
role="separator"
aria-label="Resize sidebar"
/>
</aside>
</div>
{/* Extended Sidebar */}
{extendedSidebar && extendedSidebar}
</>
);
}