✨(frontend) add resizable left panel on desktop with persistence
mainlayout and leftpanel updated with resizable panel saved in localstorage Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) show full nested doc names with horizontal scroll support horizontal overflow enabled and opacity used for sticky actions visibility Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) show full nested doc names with horizontal scroll support horizontal overflow enabled and opacity used for sticky actions visibility Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) add resizable-panels lib also used in our shared ui kit needed for adaptable ui consistent with our shared ui kit components Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,3 +75,6 @@ db.sqlite3
|
||||
.vscode/
|
||||
*.iml
|
||||
.devcontainer
|
||||
|
||||
# Cursor rules
|
||||
.cursorrules
|
||||
|
||||
@@ -9,6 +9,7 @@ and this project adheres to
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix duplicate document entries in grid #1479
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
|
||||
## [3.8.2] - 2025-10-17
|
||||
|
||||
@@ -30,7 +31,6 @@ and this project adheres to
|
||||
|
||||
- 🔥(backend) remove treebeard form for the document admin #1470
|
||||
|
||||
|
||||
## [3.8.0] - 2025-10-14
|
||||
|
||||
### Added
|
||||
@@ -38,6 +38,10 @@ and this project adheres to
|
||||
- ✨(frontend) add pdf block to the editor #1293
|
||||
- ✨List and restore deleted docs #1450
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) show full nested doc names with ajustable bar #1456
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './utils-common';
|
||||
|
||||
test.describe('Left panel desktop', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -11,6 +13,53 @@ test.describe('Left panel desktop', () => {
|
||||
await expect(page.getByTestId('home-button')).toBeVisible();
|
||||
await expect(page.getByTestId('new-doc-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks resize handle is present and functional on document page', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// On home page, resize handle should NOT be present
|
||||
let resizeHandle = page.locator('[data-panel-resize-handle-id]');
|
||||
await expect(resizeHandle).toBeHidden();
|
||||
|
||||
// Create and navigate to a document
|
||||
await createDoc(page, 'doc-resize-test', browserName, 1);
|
||||
|
||||
// Now resize handle should be visible on document page
|
||||
resizeHandle = page.locator('[data-panel-resize-handle-id]').first();
|
||||
await expect(resizeHandle).toBeVisible();
|
||||
|
||||
const leftPanel = page.getByTestId('left-panel-desktop');
|
||||
await expect(leftPanel).toBeVisible();
|
||||
|
||||
// Get initial panel width
|
||||
const initialBox = await leftPanel.boundingBox();
|
||||
expect(initialBox).not.toBeNull();
|
||||
|
||||
// Get handle position
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
expect(handleBox).not.toBeNull();
|
||||
|
||||
// Test resize by dragging the handle
|
||||
await page.mouse.move(
|
||||
handleBox!.x + handleBox!.width / 2,
|
||||
handleBox!.y + handleBox!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
handleBox!.x + 100,
|
||||
handleBox!.y + handleBox!.height / 2,
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for resize to complete
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Verify the panel has been resized
|
||||
const newBox = await leftPanel.boundingBox();
|
||||
expect(newBox).not.toBeNull();
|
||||
expect(newBox!.width).toBeGreaterThan(initialBox!.width);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Left panel mobile', () => {
|
||||
@@ -47,4 +96,12 @@ test.describe('Left panel mobile', () => {
|
||||
await expect(languageButton).toBeInViewport();
|
||||
await expect(logoutButton).toBeInViewport();
|
||||
});
|
||||
|
||||
test('checks resize handle is not present on mobile', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Verify the resize handle is NOT present on mobile
|
||||
const resizeHandle = page.locator('[data-panel-resize-handle-id]');
|
||||
await expect(resizeHandle).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-intersection-observer": "9.16.0",
|
||||
"react-resizable-panels": "3.0.6",
|
||||
"react-select": "5.10.2",
|
||||
"styled-components": "6.1.19",
|
||||
"use-debounce": "10.0.6",
|
||||
|
||||
@@ -163,6 +163,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
|
||||
$css={css`
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
`}
|
||||
>
|
||||
<Box $width="16px" $height="16px">
|
||||
@@ -180,8 +181,10 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
`}
|
||||
>
|
||||
<Text $css={ItemTextCss} $size="sm" $variation="1000">
|
||||
|
||||
@@ -184,7 +184,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
/* Remove outline from TreeViewItem wrapper elements */
|
||||
.c__tree-view--row {
|
||||
outline: none !important;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
@@ -241,7 +240,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
&:focus-visible {
|
||||
.doc-tree-root-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -39,12 +39,10 @@ export const LeftPanel = () => {
|
||||
{isDesktop && (
|
||||
<Box
|
||||
data-testid="left-panel-desktop"
|
||||
$css={`
|
||||
$css={css`
|
||||
height: calc(100vh - ${HEADER_HEIGHT}px);
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid ${colorsTokens['greyscale-200']};
|
||||
background-color: ${colorsTokens['greyscale-000']};
|
||||
`}
|
||||
className="--docs--left-panel-desktop"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ImperativePanelHandle,
|
||||
Panel,
|
||||
PanelGroup,
|
||||
PanelResizeHandle,
|
||||
} from 'react-resizable-panels';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
interface PanelStyleProps {
|
||||
$isResizing: boolean;
|
||||
}
|
||||
|
||||
const PanelStyle = createGlobalStyle<PanelStyleProps>`
|
||||
${({ $isResizing }) => $isResizing && `body * { transition: none !important; }`}
|
||||
`;
|
||||
|
||||
// Convert a target pixel width to a percentage of the current viewport width.
|
||||
// react-resizable-panels expects sizes in %, not px.
|
||||
const calculateDefaultSize = (targetWidth: number) => {
|
||||
const windowWidth = window.innerWidth;
|
||||
return (targetWidth / windowWidth) * 100;
|
||||
};
|
||||
|
||||
type ResizableLeftPanelProps = {
|
||||
leftPanel: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
minPanelSizePx?: number;
|
||||
maxPanelSizePx?: number;
|
||||
};
|
||||
|
||||
export const ResizableLeftPanel = ({
|
||||
leftPanel,
|
||||
children,
|
||||
minPanelSizePx = 300,
|
||||
maxPanelSizePx = 450,
|
||||
}: ResizableLeftPanelProps) => {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const ref = useRef<ImperativePanelHandle>(null);
|
||||
const resizeTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const [minPanelSize, setMinPanelSize] = useState(0);
|
||||
const [maxPanelSize, setMaxPanelSize] = useState(0);
|
||||
|
||||
// Single resize listener that handles both panel size updates and transition disabling
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Update panel sizes (px -> %)
|
||||
const min = Math.round(calculateDefaultSize(minPanelSizePx));
|
||||
const max = Math.round(
|
||||
Math.min(calculateDefaultSize(maxPanelSizePx), 40),
|
||||
);
|
||||
setMinPanelSize(min);
|
||||
setMaxPanelSize(max);
|
||||
|
||||
// Temporarily disable transitions to avoid flicker
|
||||
setIsResizing(true);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsResizing(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
handleResize();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [minPanelSizePx, maxPanelSizePx]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelStyle $isResizing={isResizing} />
|
||||
<PanelGroup
|
||||
autoSaveId="docs-left-panel-persistence"
|
||||
direction="horizontal"
|
||||
>
|
||||
<Panel
|
||||
ref={ref}
|
||||
order={0}
|
||||
defaultSize={minPanelSize}
|
||||
minSize={minPanelSize}
|
||||
maxSize={maxPanelSize}
|
||||
>
|
||||
{leftPanel}
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
style={{
|
||||
borderRightWidth: '1px',
|
||||
borderRightStyle: 'solid',
|
||||
borderRightColor: colorsTokens['greyscale-200'],
|
||||
width: '1px',
|
||||
cursor: 'col-resize',
|
||||
}}
|
||||
/>
|
||||
<Panel order={1}>{children}</Panel>
|
||||
</PanelGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from './LeftPanel';
|
||||
export * from './ResizableLeftPanel';
|
||||
|
||||
@@ -6,23 +6,21 @@ import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Header } from '@/features/header';
|
||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||
import { LeftPanel } from '@/features/left-panel';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { MAIN_LAYOUT_ID } from './conf';
|
||||
|
||||
type MainLayoutProps = {
|
||||
backgroundColor?: 'white' | 'grey';
|
||||
enableResizablePanel?: boolean;
|
||||
};
|
||||
|
||||
export function MainLayout({
|
||||
children,
|
||||
backgroundColor = 'white',
|
||||
enableResizablePanel = false,
|
||||
}: PropsWithChildren<MainLayoutProps>) {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box className="--docs--main-layout">
|
||||
<Header />
|
||||
@@ -30,33 +28,90 @@ export function MainLayout({
|
||||
$direction="row"
|
||||
$margin={{ top: `${HEADER_HEIGHT}px` }}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
>
|
||||
<LeftPanel />
|
||||
<Box
|
||||
as="main"
|
||||
role="main"
|
||||
aria-label={t('Main content')}
|
||||
id={MAIN_LAYOUT_ID}
|
||||
$align="center"
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
$padding={{
|
||||
all: isDesktop ? 'base' : '0',
|
||||
}}
|
||||
$background={
|
||||
currentBackgroundColor === 'white'
|
||||
? colorsTokens['greyscale-000']
|
||||
: colorsTokens['greyscale-050']
|
||||
}
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
`}
|
||||
<MainLayoutContent
|
||||
backgroundColor={backgroundColor}
|
||||
enableResizablePanel={enableResizablePanel}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</MainLayoutContent>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MainLayoutContentProps {
|
||||
backgroundColor: 'white' | 'grey';
|
||||
enableResizablePanel?: boolean;
|
||||
}
|
||||
|
||||
export function MainLayoutContent({
|
||||
children,
|
||||
backgroundColor,
|
||||
enableResizablePanel = false,
|
||||
}: PropsWithChildren<MainLayoutContentProps>) {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const { t } = useTranslation();
|
||||
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
||||
|
||||
const mainContent = (
|
||||
<Box
|
||||
as="main"
|
||||
role="main"
|
||||
aria-label={t('Main content')}
|
||||
id={MAIN_LAYOUT_ID}
|
||||
$align="center"
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
$padding={{
|
||||
all: isDesktop ? 'base' : '0',
|
||||
}}
|
||||
$background={
|
||||
currentBackgroundColor === 'white'
|
||||
? colorsTokens['greyscale-000']
|
||||
: colorsTokens['greyscale-050']
|
||||
}
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<>
|
||||
<LeftPanel />
|
||||
{mainContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (enableResizablePanel) {
|
||||
return (
|
||||
<ResizableLeftPanel leftPanel={<LeftPanel />}>
|
||||
{mainContent}
|
||||
</ResizableLeftPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
$css={css`
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
border-right: 1px solid ${colorsTokens['greyscale-200']};
|
||||
`}
|
||||
>
|
||||
<LeftPanel />
|
||||
</Box>
|
||||
{mainContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function DocLayout() {
|
||||
return subPageToTree(doc.results);
|
||||
}}
|
||||
>
|
||||
<MainLayout>
|
||||
<MainLayout enableResizablePanel={true}>
|
||||
<DocPage id={id} />
|
||||
</MainLayout>
|
||||
</TreeProvider>
|
||||
|
||||
@@ -13793,6 +13793,11 @@ react-resizable-panels@2.1.7:
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz#afd29d8a3d708786a9f95183a38803c89f13c2e7"
|
||||
integrity sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==
|
||||
|
||||
react-resizable-panels@3.0.6:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz#8183132ea13a09821e9c93962ed49f240cdcfd3f"
|
||||
integrity sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==
|
||||
|
||||
react-select@5.10.2:
|
||||
version "5.10.2"
|
||||
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.10.2.tgz#8dffc69dfd7d74684d9613e6eb27204e3b99e127"
|
||||
|
||||
Reference in New Issue
Block a user