Skip to content

Commit 3e294e9

Browse files
committed
✨(frontend) add resizable left panel on desktop with persistence
mainlayout and leftpanel updated with resizable panel saved in localstorage Signed-off-by: Cyril <[email protected]> ✨(frontend) show full nested doc names with horizontal scroll support horizontal overflow enabled and opacity used for sticky actions visibility Signed-off-by: Cyril <[email protected]> ✨(frontend) show full nested doc names with horizontal scroll support horizontal overflow enabled and opacity used for sticky actions visibility Signed-off-by: Cyril <[email protected]> ✨(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 <[email protected]>
1 parent e3b2fdb commit 3e294e9

File tree

9 files changed

+289
-323
lines changed

9 files changed

+289
-323
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ and this project adheres to
4343
- 🐛(frontend) fix attachment download filename #1447
4444
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
4545
- 🔒(frontend) prevent readers from changing callout emoji #1449
46+
- 🐛(frontend) show full nested doc names with ajustable bar #1456
4647

4748
## [3.7.0] - 2025-09-12
4849

src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,48 @@ test.describe('Left panel desktop', () => {
1111
await expect(page.getByTestId('home-button')).toBeVisible();
1212
await expect(page.getByTestId('new-doc-button')).toBeVisible();
1313
});
14+
15+
test('checks resize handle is present and functional', async ({ page }) => {
16+
await page.goto('/');
17+
18+
// Verify the resize handle is present on desktop
19+
const resizeHandle = page.locator('[data-panel-resize-handle-id]').first();
20+
await expect(resizeHandle).toBeVisible();
21+
22+
const leftPanel = page.getByTestId('left-panel-desktop');
23+
await expect(leftPanel).toBeVisible();
24+
25+
const initialBox = await leftPanel.boundingBox();
26+
expect(initialBox).not.toBeNull();
27+
28+
// Test resize by dragging the handle
29+
if (initialBox) {
30+
const handleBox = await resizeHandle.boundingBox();
31+
expect(handleBox).not.toBeNull();
32+
33+
if (handleBox) {
34+
await page.mouse.move(
35+
handleBox.x + handleBox.width / 2,
36+
handleBox.y + handleBox.height / 2,
37+
);
38+
await page.mouse.down();
39+
await page.mouse.move(
40+
handleBox.x + 100,
41+
handleBox.y + handleBox.height / 2,
42+
);
43+
await page.mouse.up();
44+
45+
await page.waitForTimeout(200);
46+
47+
// Verify the panel has been resized
48+
const newBox = await leftPanel.boundingBox();
49+
expect(newBox).not.toBeNull();
50+
if (newBox) {
51+
expect(newBox.width).toBeGreaterThan(initialBox.width);
52+
}
53+
}
54+
}
55+
});
1456
});
1557

1658
test.describe('Left panel mobile', () => {
@@ -47,4 +89,12 @@ test.describe('Left panel mobile', () => {
4789
await expect(languageButton).toBeInViewport();
4890
await expect(logoutButton).toBeInViewport();
4991
});
92+
93+
test('checks resize handle is not present on mobile', async ({ page }) => {
94+
await page.goto('/');
95+
96+
// Verify the resize handle is NOT present on mobile
97+
const resizeHandle = page.locator('[data-panel-resize-handle-id]');
98+
await expect(resizeHandle).not.toBeVisible();
99+
});
50100
});

src/frontend/apps/impress/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"react-dom": "*",
6060
"react-i18next": "15.7.3",
6161
"react-intersection-observer": "9.16.0",
62+
"react-resizable-panels": "^3.0.6",
6263
"react-select": "5.10.2",
6364
"styled-components": "6.1.19",
6465
"use-debounce": "10.0.6",

src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
116116
? 'var(--c--theme--colors--greyscale-100)'
117117
: 'var(--c--theme--colors--greyscale-000)'};
118118
.light-doc-item-actions {
119-
display: ${menuOpen || !isDesktop ? 'flex' : 'none'};
120-
position: absolute;
119+
display: flex;
120+
position: sticky;
121121
right: 0;
122+
123+
opacity: ${menuOpen || !isDesktop ? '1' : '0'};
122124
background: ${isDesktop
123125
? 'var(--c--theme--colors--greyscale-100)'
124126
: 'var(--c--theme--colors--greyscale-000)'};
@@ -137,7 +139,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
137139
background-color: var(--c--theme--colors--greyscale-100);
138140
border-radius: 4px;
139141
.light-doc-item-actions {
140-
display: flex;
142+
opacity: 1;
141143
background: var(--c--theme--colors--greyscale-100);
142144
}
143145
}

src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
184184
/* Remove outline from TreeViewItem wrapper elements */
185185
.c__tree-view--row {
186186
outline: none !important;
187-
188187
&:focus-visible {
189188
outline: none !important;
190189
}
@@ -195,7 +194,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
195194
margin-top: -10px;
196195
197196
.c__tree-view {
198-
overflow: hidden !important;
197+
overflow: visible !important;
199198
}
200199
}
201200
`}

src/frontend/apps/impress/src/features/left-panel/components/LeftPanel.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,10 @@ export const LeftPanel = () => {
3939
{isDesktop && (
4040
<Box
4141
data-testid="left-panel-desktop"
42-
$css={`
42+
$css={css`
4343
height: calc(100vh - ${HEADER_HEIGHT}px);
44-
width: 300px;
45-
min-width: 300px;
44+
width: 100%;
4645
overflow: hidden;
47-
border-right: 1px solid ${colorsTokens['greyscale-200']};
4846
background-color: ${colorsTokens['greyscale-000']};
4947
`}
5048
className="--docs--left-panel-desktop"

src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export const LeftPanelDocContent = () => {
1717
<Box
1818
$flex={1}
1919
$width="100%"
20-
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
20+
$css="width: 100%; overflow-y: auto"
2121
className="--docs--left-panel-doc-content"
2222
>
2323
<DocTree currentDoc={currentDoc} />

src/frontend/apps/impress/src/layouts/MainLayout.tsx

Lines changed: 169 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { PropsWithChildren } from 'react';
1+
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
3+
import {
4+
ImperativePanelHandle,
5+
Panel,
6+
PanelGroup,
7+
PanelResizeHandle,
8+
} from 'react-resizable-panels';
39
import { css } from 'styled-components';
410

511
import { Box } from '@/components';
@@ -12,50 +18,186 @@ import { useResponsiveStore } from '@/stores';
1218

1319
type MainLayoutProps = {
1420
backgroundColor?: 'white' | 'grey';
21+
enableResize?: boolean;
1522
};
1623

1724
export function MainLayout({
1825
children,
1926
backgroundColor = 'white',
27+
enableResize = true,
2028
}: PropsWithChildren<MainLayoutProps>) {
2129
const { isDesktop } = useResponsiveStore();
2230
const { colorsTokens } = useCunninghamTheme();
2331
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
2432
const { t } = useTranslation();
2533

34+
// Convert a target pixel width to a percentage of the current viewport width.
35+
// react-resizable-panels expects sizes in %, not px.
36+
const calculateDefaultSize = (
37+
targetWidth: number,
38+
isDesktopDevice: boolean,
39+
) => {
40+
if (!isDesktopDevice) {
41+
return 0;
42+
}
43+
const windowWidth = window.innerWidth;
44+
return (targetWidth / windowWidth) * 100;
45+
};
46+
47+
const ref = useRef<ImperativePanelHandle>(null);
48+
const [isResizing, setIsResizing] = useState(false);
49+
const resizeTimeoutRef = useRef<number | undefined>(undefined);
50+
const MIN_PANEL_SIZE = 300;
51+
const MAX_PANEL_SIZE = 450;
52+
53+
const [minPanelSize, setMinPanelSize] = useState(
54+
calculateDefaultSize(MIN_PANEL_SIZE, isDesktop),
55+
);
56+
const [maxPanelSize, setMaxPanelSize] = useState(
57+
calculateDefaultSize(MAX_PANEL_SIZE, isDesktop),
58+
);
59+
60+
// UX: During window resize, temporarily disable CSS transitions to avoid flicker.
61+
// This does not affect the resize feature; it only improves visual smoothness.
62+
useEffect(() => {
63+
const handleResizeStart = () => {
64+
setIsResizing(true);
65+
if (resizeTimeoutRef.current) {
66+
clearTimeout(resizeTimeoutRef.current);
67+
}
68+
resizeTimeoutRef.current = window.setTimeout(() => {
69+
setIsResizing(false);
70+
}, 150);
71+
};
72+
73+
window.addEventListener('resize', handleResizeStart);
74+
75+
return () => {
76+
window.removeEventListener('resize', handleResizeStart);
77+
if (resizeTimeoutRef.current) {
78+
clearTimeout(resizeTimeoutRef.current);
79+
}
80+
};
81+
}, []);
82+
83+
// Keep pixel-based constraints while the library works in percentages.
84+
// We translate px -> % on mount and on viewport resizes so that:
85+
// - min stays ~300px, max stays ~450px (capped to 40% on small screens)
86+
// - on mobile, the left panel collapses (min = 0)
87+
// - when enableResize is false, we lock the size by setting max == min
88+
useEffect(() => {
89+
const updatePanelSize = () => {
90+
const min = Math.round(calculateDefaultSize(MIN_PANEL_SIZE, isDesktop));
91+
const max = Math.round(
92+
Math.min(calculateDefaultSize(MAX_PANEL_SIZE, isDesktop), 40),
93+
);
94+
setMinPanelSize(isDesktop ? min : 0);
95+
enableResize ? setMaxPanelSize(max) : setMaxPanelSize(min);
96+
};
97+
98+
updatePanelSize();
99+
window.addEventListener('resize', updatePanelSize);
100+
101+
return () => {
102+
window.removeEventListener('resize', updatePanelSize);
103+
};
104+
}, [isDesktop, enableResize]);
105+
26106
return (
27-
<Box className="--docs--main-layout">
107+
<Box
108+
className={`--docs--main-layout ${isResizing ? 'resizing' : ''}`}
109+
$css={css`
110+
&.resizing * {
111+
transition: none !important;
112+
}
113+
`}
114+
>
28115
<Header />
29116
<Box
30117
$direction="row"
31118
$margin={{ top: `${HEADER_HEIGHT}px` }}
32119
$width="100%"
120+
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
33121
>
34-
<LeftPanel />
35-
<Box
36-
as="main"
37-
role="main"
38-
aria-label={t('Main content')}
39-
id={MAIN_LAYOUT_ID}
40-
$align="center"
41-
$flex={1}
42-
$width="100%"
43-
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
44-
$padding={{
45-
all: isDesktop ? 'base' : '0',
46-
}}
47-
$background={
48-
currentBackgroundColor === 'white'
49-
? colorsTokens['greyscale-000']
50-
: colorsTokens['greyscale-050']
51-
}
52-
$css={css`
53-
overflow-y: auto;
54-
overflow-x: clip;
55-
`}
56-
>
57-
{children}
58-
</Box>
122+
{isDesktop ? (
123+
<PanelGroup
124+
autoSaveId="docs-left-panel-persistence"
125+
direction="horizontal"
126+
>
127+
<Panel
128+
ref={ref}
129+
order={0}
130+
defaultSize={minPanelSize}
131+
minSize={minPanelSize}
132+
maxSize={maxPanelSize}
133+
>
134+
<LeftPanel />
135+
</Panel>
136+
<PanelResizeHandle
137+
className="border-clr-surface-primary"
138+
style={{
139+
borderRightWidth: '1px',
140+
borderRightStyle: 'solid',
141+
borderRightColor: colorsTokens['greyscale-200'],
142+
width: '1px',
143+
cursor: 'col-resize',
144+
}}
145+
/>
146+
<Panel order={1}>
147+
<Box
148+
as="main"
149+
role="main"
150+
aria-label={t('Main content')}
151+
id={MAIN_LAYOUT_ID}
152+
$align="center"
153+
$width="100%"
154+
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
155+
$padding={{
156+
all: 'base',
157+
}}
158+
$background={
159+
currentBackgroundColor === 'white'
160+
? colorsTokens['greyscale-000']
161+
: colorsTokens['greyscale-050']
162+
}
163+
$css={css`
164+
overflow-y: auto;
165+
overflow-x: clip;
166+
`}
167+
>
168+
{children}
169+
</Box>
170+
</Panel>
171+
</PanelGroup>
172+
) : (
173+
<>
174+
<LeftPanel />
175+
<Box
176+
as="main"
177+
role="main"
178+
aria-label={t('Main content')}
179+
id={MAIN_LAYOUT_ID}
180+
$align="center"
181+
$flex={1}
182+
$width="100%"
183+
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
184+
$padding={{
185+
all: '0',
186+
}}
187+
$background={
188+
currentBackgroundColor === 'white'
189+
? colorsTokens['greyscale-000']
190+
: colorsTokens['greyscale-050']
191+
}
192+
$css={css`
193+
overflow-y: auto;
194+
overflow-x: clip;
195+
`}
196+
>
197+
{children}
198+
</Box>
199+
</>
200+
)}
59201
</Box>
60202
</Box>
61203
);

0 commit comments

Comments
 (0)