feat(resizable-side-panels): Make the left and right side panels (optionally) resizable. (#4672)

This commit is contained in:
Joe Boccanfuso authored and GitHub committed 2025-01-21 11:20:34 -05:00
1 parent a518854a4c
commit d90a4cfb16
14 files changed
+694 -244

No files matched your search

@@ -15,7 +15,7 @@ describe('OHIF Measurement Panel', function () {
cy.get('@RightCollapseBtn').click();
cy.get('@measurementsPanel').should('not.exist');
cy.get('@RightCollapseBtn').click();
cy.get('@RightCollapseBtn').click({ force: true });
// segmentation panel should be visible
cy.get('@segmentationPanel').should('be.visible');
@@ -151,13 +151,7 @@ export default class PanelService extends PubSubService {
panelsIds.forEach(panelId => this.addPanel(position, panelId, options));
}
public setPanels(
panels: { [key in PanelPosition]: string[] },
options: {
rightPanelClosed?: boolean;
leftPanelClosed?: boolean;
}
): void {
public setPanels(panels: { [key in PanelPosition]: string[] }, options): void {
this.reset();
Object.keys(panels).forEach((position: PanelPosition) => {
+1
View File
@@ -53,6 +53,7 @@
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-resizable-panels": "^2.1.7",
"react-shepherd": "6.1.1",
"shepherd.js": "13.0.3",
"sonner": "^1.5.0",
@@ -0,0 +1,43 @@
'use client';
import React from 'react';
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import cn from 'classnames';
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,3 @@
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from './Resizable';
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -1,39 +1,64 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useState } from 'react';
import { Icons } from '../Icons';
import { TooltipTrigger, TooltipContent, TooltipProvider, Tooltip } from '../Tooltip';
import { TooltipTrigger, TooltipContent, Tooltip } from '../Tooltip';
import { Separator } from '../Separator';
/**
* SidePanel component properties.
* Note that the component monitors changes to the various widths and border sizes and will resize dynamically
* @property {boolean} isExpanded - boolean indicating if the side panel is expanded/open or collapsed
* @property {number} expandedWidth - the width of this side panel when expanded not including any borders or margins
* @property {number} collapsedWidth - the width of this side panel when collapsed not including any borders or margins
* @property {number} expandedInsideBorderSize - the width of the space between the expanded side panel content and viewport grid
* @property {number} collapsedInsideBorderSize - the width of the space between the collapsed side panel content and the viewport grid
* @property {number} collapsedOutsideBorderSize - the width of the space between the collapsed side panel content and the edge of the browser window
*/
type SidePanelProps = {
side: 'left' | 'right';
className: string;
activeTabIndex: number;
onOpen: () => void;
onClose: () => void;
onActiveTabIndexChange: () => void;
isExpanded: boolean;
expandedWidth: number;
collapsedWidth: number;
expandedInsideBorderSize: number;
collapsedInsideBorderSize: number;
collapsedOutsideBorderSize: number;
tabs: any;
};
type StyleMap = {
open: {
left: { marginLeft: string };
right: { marginRight: string };
left: {
marginLeft: string; // the space between the expanded/open left side panel and the browser window left edge
marginRight: string; // the space between the expanded/open left side panel and the viewport grid
};
right: {
marginLeft: string; // the space between the expanded/open right side panel and the viewport grid
marginRight: string; // the space between the expanded/open right side panel and the browser window right edge
};
};
closed: {
left: { marginLeft: string };
right: { marginRight: string };
left: {
marginLeft: string; // the space between the collapsed/closed left panel and the browser window left edge
marginRight: string; // the space between the collapsed/closed left panel and the viewport grid
alignItems: 'flex-end'; // the flexbox layout align-items property
};
right: {
marginLeft: string; // the space between the collapsed/closed right panel and the viewport grid
marginRight: string; // the space between the collapsed/closed right panel and the browser window right edge
alignItems: 'flex-start'; // the flexbox layout align-items property
};
};
};
const borderSize = 4;
const collapsedWidth = 25;
const closeIconWidth = 30;
const gridHorizontalPadding = 10;
const tabSpacerWidth = 2;
const baseClasses =
'transition-all duration-300 ease-in-out bg-black border-black justify-start box-content flex flex-col';
const classesMap = {
open: {
left: `mr-1`,
right: `ml-1`,
},
closed: {
left: `mr-2 items-end`,
right: `ml-2 items-start`,
},
};
const baseClasses = 'bg-black border-black justify-start box-content flex flex-col';
const openStateIconName = {
left: 'SidePanelCloseLeft',
@@ -106,19 +131,29 @@ const getTabIconClassNames = (numTabs: number, isActiveTab: boolean) => {
};
const createStyleMap = (
expandedWidth: number,
borderSize: number,
collapsedWidth: number
expandedInsideBorderSize: number,
collapsedWidth: number,
collapsedInsideBorderSize: number,
collapsedOutsideBorderSize: number
): StyleMap => {
const collapsedHideWidth = expandedWidth - collapsedWidth - borderSize;
const collapsedHideWidth = expandedWidth - collapsedWidth - collapsedInsideBorderSize;
return {
open: {
left: { marginLeft: '0px' },
right: { marginRight: '0px' },
left: { marginLeft: '0px', marginRight: `${expandedInsideBorderSize}px` },
right: { marginLeft: `${expandedInsideBorderSize}px`, marginRight: '0px' },
},
closed: {
left: { marginLeft: `-${collapsedHideWidth}px` },
right: { marginRight: `-${collapsedHideWidth}px` },
left: {
marginLeft: `-${collapsedHideWidth}px`,
marginRight: `${collapsedOutsideBorderSize}px`,
alignItems: `flex-end`,
},
right: {
marginLeft: `${collapsedOutsideBorderSize}px`,
marginRight: `-${collapsedHideWidth}px`,
alignItems: `flex-start`,
},
},
};
};
@@ -143,47 +178,67 @@ const createBaseStyle = (expandedWidth: number) => {
height: '99.8%',
};
};
const SidePanel = ({
side,
className,
activeTabIndex: activeTabIndexProp = null,
activeTabIndex: activeTabIndexProp,
isExpanded,
tabs,
onOpen,
onClose,
expandedWidth = 280,
onActiveTabIndexChange,
}) => {
const [panelOpen, setPanelOpen] = useState(activeTabIndexProp !== null);
const [activeTabIndex, setActiveTabIndex] = useState(0);
expandedWidth = 280,
collapsedWidth = 25,
expandedInsideBorderSize = 4,
collapsedInsideBorderSize = 8,
collapsedOutsideBorderSize = 4,
}: SidePanelProps) => {
const [panelOpen, setPanelOpen] = useState(isExpanded);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0);
const styleMap = createStyleMap(expandedWidth, borderSize, collapsedWidth);
const baseStyle = createBaseStyle(expandedWidth);
const gridAvailableWidth = expandedWidth - closeIconWidth - gridHorizontalPadding;
const gridWidth = getGridWidth(tabs.length, gridAvailableWidth);
const [styleMap, setStyleMap] = useState(
createStyleMap(
expandedWidth,
expandedInsideBorderSize,
collapsedWidth,
collapsedInsideBorderSize,
collapsedOutsideBorderSize
)
);
const [baseStyle, setBaseStyle] = useState(createBaseStyle(expandedWidth));
const [gridAvailableWidth, setGridAvailableWidth] = useState(
expandedWidth - closeIconWidth - gridHorizontalPadding
);
const [gridWidth, setGridWidth] = useState(getGridWidth(tabs.length, gridAvailableWidth));
const openStatus = panelOpen ? 'open' : 'closed';
const style = Object.assign({}, styleMap[openStatus][side], baseStyle);
const updatePanelOpen = useCallback(
(panelOpen: boolean) => {
setPanelOpen(panelOpen);
if (panelOpen && onOpen) {
onOpen();
} else if (onClose && !panelOpen) {
onClose();
(isOpen: boolean) => {
setPanelOpen(isOpen);
if (isOpen !== panelOpen) {
// only fire events for changes
if (isOpen && onOpen) {
onOpen();
} else if (onClose && !isOpen) {
onClose();
}
}
},
[onOpen, onClose]
[panelOpen, onOpen, onClose]
);
const updateActiveTabIndex = useCallback(
(activeTabIndex: number) => {
if (activeTabIndex === null) {
updatePanelOpen(false);
return;
(activeTabIndex: number, forceOpen: boolean = false) => {
if (forceOpen) {
updatePanelOpen(true);
}
setActiveTabIndex(activeTabIndex);
updatePanelOpen(true);
if (onActiveTabIndexChange) {
onActiveTabIndexChange({ activeTabIndex });
@@ -193,7 +248,35 @@ const SidePanel = ({
);
useEffect(() => {
updateActiveTabIndex(activeTabIndexProp);
updatePanelOpen(isExpanded);
}, [isExpanded, updatePanelOpen]);
useEffect(() => {
setStyleMap(
createStyleMap(
expandedWidth,
expandedInsideBorderSize,
collapsedWidth,
collapsedInsideBorderSize,
collapsedOutsideBorderSize
)
);
setBaseStyle(createBaseStyle(expandedWidth));
const gridAvailableWidth = expandedWidth - closeIconWidth - gridHorizontalPadding;
setGridAvailableWidth(gridAvailableWidth);
setGridWidth(getGridWidth(tabs.length, gridAvailableWidth));
}, [
collapsedInsideBorderSize,
collapsedWidth,
expandedWidth,
expandedInsideBorderSize,
tabs.length,
collapsedOutsideBorderSize,
]);
useEffect(() => {
updateActiveTabIndex(activeTabIndexProp ?? 0);
}, [activeTabIndexProp, updateActiveTabIndex]);
const getCloseStateComponent = () => {
@@ -223,7 +306,7 @@ const SidePanel = ({
data-cy={`${childComponent.name}-btn`}
className="text-primary-active hover:cursor-pointer"
onClick={() => {
return childComponent.disabled ? null : updateActiveTabIndex(index);
return childComponent.disabled ? null : updateActiveTabIndex(index, true);
}}
>
{React.createElement(Icons[childComponent.iconName] || Icons.MissingIcon, {
@@ -374,7 +457,7 @@ const SidePanel = ({
return (
<div
className={classnames(className, baseClasses, classesMap[openStatus][side])}
className={classnames(className, baseClasses)}
style={style}
>
{panelOpen ? (
@@ -394,14 +477,4 @@ const SidePanel = ({
);
};
SidePanel.propTypes = {
side: PropTypes.oneOf(['left', 'right']).isRequired,
className: PropTypes.string,
activeTabIndex: PropTypes.number,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onActiveTabIndexChange: PropTypes.func,
expandedWidth: PropTypes.number,
};
export { SidePanel };
+4
View File
@@ -27,6 +27,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { Clipboard } from './Clipboard';
import { Combobox } from './Combobox';
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from './Popover';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './Resizable';
import { Calendar } from './Calendar';
import { DatePickerWithRange } from './DateRange';
import { Separator } from './Separator';
@@ -121,6 +122,9 @@ export {
PopoverContent,
PopoverTrigger,
PopoverAnchor,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
Calendar,
DatePickerWithRange,
Input,
+6
View File
@@ -44,6 +44,9 @@ import {
PopoverAnchor,
PopoverContent,
PopoverTrigger,
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
Select,
SelectTrigger,
SelectContent,
@@ -150,6 +153,9 @@ export {
PopoverAnchor,
PopoverContent,
PopoverTrigger,
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
Select,
SelectTrigger,
SelectContent,