feat(resizable-side-panels): Make the left and right side panels (optionally) resizable. (#4672)
This commit is contained in:
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) => {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user