(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:
Cyril
2025-10-13 13:59:01 +02:00
parent b3cc2bf833
commit a833fdc7a1
12 changed files with 274 additions and 38 deletions

3
.gitignore vendored
View File

@@ -75,3 +75,6 @@ db.sqlite3
.vscode/
*.iml
.devcontainer
# Cursor rules
.cursorrules

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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",

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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>
</>
);
};

View File

@@ -1 +1,2 @@
export * from './LeftPanel';
export * from './ResizableLeftPanel';

View File

@@ -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}
</>
);
}

View File

@@ -47,7 +47,7 @@ export function DocLayout() {
return subPageToTree(doc.results);
}}
>
<MainLayout>
<MainLayout enableResizablePanel={true}>
<DocPage id={id} />
</MainLayout>
</TreeProvider>

View File

@@ -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"