Skip to content

Commit 981aef0

Browse files
committed
📱(frontend) coordinate left/right panel visibility on tablet
On tablet, the place is too tight to have both panels open at the same time. We have a fine-grained control of the panel visibility to display panel depend users interactions and responsive breakpoints.
1 parent 43a3620 commit 981aef0

9 files changed

Lines changed: 207 additions & 18 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect, test } from '@playwright/test';
22

33
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
4+
import { tryFocusEditorContent } from './utils-editor';
45
import { createRootSubPage } from './utils-sub-pages';
56

67
test.describe('Left panel desktop', () => {
@@ -174,4 +175,47 @@ test.describe('Left panel responsive', () => {
174175
await verifyDocName(page, docChild);
175176
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
176177
});
178+
179+
test('checks panel coordination on tablet sizes', async ({
180+
page,
181+
browserName,
182+
}) => {
183+
await page.setViewportSize({ width: 900, height: 1200 });
184+
await page.goto('/');
185+
186+
await createDoc(page, 'tablet-doc-test', browserName, 1);
187+
188+
const leftPanel = page.locator('.--docs--resizable-left-panel');
189+
const rightPanel = page.getByLabel('Table of contents side panel');
190+
191+
// Initially, left panel should be visible and right panel should be hidden
192+
await expect(leftPanel).toBeInViewport();
193+
await expect(rightPanel).not.toBeInViewport();
194+
await tryFocusEditorContent({ page });
195+
await page.keyboard.type('# Level 1');
196+
197+
// Open right panel, the left panel should hide
198+
await page
199+
.getByRole('button', { name: 'Show the table of contents sidebar' })
200+
.click();
201+
await expect(rightPanel).toBeInViewport();
202+
await expect(leftPanel).toBeHidden();
203+
204+
// Open left panel, the right panel should hide
205+
await page.getByRole('button', { name: /Show the side panel/ }).click();
206+
await expect(leftPanel).toBeInViewport();
207+
await expect(rightPanel).not.toBeInViewport();
208+
209+
// Close the left panel, the right panel should show
210+
await page.getByRole('button', { name: /Hide the side panel/ }).click();
211+
await expect(leftPanel).toBeHidden();
212+
await expect(rightPanel).toBeInViewport();
213+
214+
// Close right panel, the left panel should stay closed
215+
await page
216+
.getByRole('button', { name: 'Hide the table of contents sidebar' })
217+
.click();
218+
await expect(rightPanel).not.toBeInViewport();
219+
await expect(leftPanel).toBeHidden();
220+
});
177221
});

src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { useConfig } from '@/core';
2929
import { useCunninghamTheme } from '@/cunningham';
3030
import { Doc } from '@/docs/doc-management';
3131
import { avatarUrlFromName, useAuth } from '@/features/auth';
32-
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
32+
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
3333
import { useAnalytics } from '@/libs/Analytics';
3434

3535
import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf';

src/frontend/apps/impress/src/features/docs/doc-editor/components/comments/CommentSideBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import CommentsIcon from '@/assets/icons/ui-kit/bubble-text.svg';
88
import SortingResolvedSVG from '@/assets/icons/ui-kit/filter-notification.svg';
99
import SortingOpenSVG from '@/assets/icons/ui-kit/filter_list.svg';
1010
import { Box, ButtonCloseModal, Text } from '@/components/';
11-
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
11+
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
1212

1313
import { useCommentSidebarStore } from './useCommentSidebarStore';
1414

src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContentSideBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Box, ButtonCloseModal, Text } from '@/components';
88
import { useCunninghamTheme } from '@/cunningham';
99
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
1010
import { useHeadingStore } from '@/docs/doc-editor/stores/useHeadingStore';
11-
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
11+
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
1212
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
1313

1414
import { Heading } from './Heading';

src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,22 @@ type TogglePanelArgs = {
99
interface LeftPanelState {
1010
isPanelOpen: boolean;
1111
isPanelOpenMobile: boolean;
12+
/**
13+
* Depending on the responsive breakpoint, the panel can be auto-closed, and so
14+
* auto-opened later on.
15+
*/
16+
wasAutoClosed: boolean;
1217
togglePanel: (args?: TogglePanelArgs) => void;
1318
closePanel: (args?: TogglePanelType) => void;
19+
autoClose: () => void;
1420
}
1521

1622
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
1723
isPanelOpen: true,
1824
isPanelOpenMobile: false,
25+
wasAutoClosed: false,
1926
togglePanel: ({ value, type }: TogglePanelArgs = {}) => {
27+
set({ wasAutoClosed: false });
2028
if (typeof value === 'boolean') {
2129
if (type === 'mobile') {
2230
set({ isPanelOpenMobile: value });
@@ -42,6 +50,7 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
4250
set({ isPanelOpen: !isPanelOpen, isPanelOpenMobile: !isPanelOpenMobile });
4351
},
4452
closePanel: ({ type }: Partial<TogglePanelType> = {}) => {
53+
set({ wasAutoClosed: false });
4554
if (type === 'mobile') {
4655
set({ isPanelOpenMobile: false });
4756
return;
@@ -52,4 +61,5 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
5261
}
5362
set({ isPanelOpen: false, isPanelOpenMobile: false });
5463
},
64+
autoClose: () => set({ isPanelOpen: false, wasAutoClosed: true }),
5565
}));

src/frontend/apps/impress/src/features/right-panel/components/RightPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { TableContentSideBar } from '@/features/docs/doc-table-content/component
99
import { HEADER_HEIGHT } from '@/features/header';
1010
import { useResponsiveStore } from '@/stores';
1111

12-
import { RightPanelView, useRightPanelStore } from './useRightPanelStore';
12+
import {
13+
RightPanelView,
14+
useRightPanelStore,
15+
} from '../stores/useRightPanelStore';
1316

1417
export const RightPanel = () => {
1518
const { t } = useTranslation();

src/frontend/apps/impress/src/features/right-panel/components/useRightPanelStore.tsx renamed to src/frontend/apps/impress/src/features/right-panel/stores/useRightPanelStore.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,37 @@ export type RightPanelView = 'tableContent' | 'comments';
55
export interface UseRightPanelStore {
66
isPanelOpen: boolean;
77
activePanel: RightPanelView | null;
8+
/**
9+
* Depending on the responsive breakpoint, the panel can be auto-closed, and so
10+
* auto-opened later on.
11+
*/
12+
wasAutoClosed: boolean;
813
setActivePanel: (panel: RightPanelView | null) => void;
914
setIsPanelOpen: (isOpen: boolean) => void;
1015
togglePanel: () => void;
16+
autoClose: () => void;
1117
}
1218

1319
export const useRightPanelStore = create<UseRightPanelStore>((set) => ({
1420
isPanelOpen: false,
1521
activePanel: null,
22+
wasAutoClosed: false,
1623
setActivePanel: (activePanel) =>
17-
set(() => ({ activePanel, isPanelOpen: activePanel !== null })),
24+
set(() => ({
25+
activePanel,
26+
isPanelOpen: activePanel !== null,
27+
wasAutoClosed: false,
28+
})),
1829
setIsPanelOpen: (isPanelOpen) =>
1930
set((state) => ({
2031
isPanelOpen,
2132
activePanel: isPanelOpen ? state.activePanel : null,
33+
wasAutoClosed: false,
34+
})),
35+
togglePanel: () =>
36+
set((state) => ({
37+
isPanelOpen: !state.isPanelOpen,
38+
wasAutoClosed: false,
2239
})),
23-
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
40+
autoClose: () => set({ isPanelOpen: false, wasAutoClosed: true }),
2441
}));

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

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { DocEditorSkeleton, Skeleton } from '@/features/skeletons';
1111
import { useResponsiveStore } from '@/stores';
1212

1313
import { MAIN_LAYOUT_ID } from './conf';
14+
import { usePanelCoordination } from './usePanelCoordination';
1415

1516
type MainLayoutProps = {
1617
backgroundColor?: 'white' | 'grey';
@@ -56,18 +57,9 @@ export function MainLayoutContent({
5657

5758
if (enableResizablePanel) {
5859
return (
59-
<ResizableLeftPanel leftPanel={<LeftPanel />}>
60-
<Box $direction="row" $width="100%" $position="relative">
61-
<MainContent
62-
backgroundColor={backgroundColor}
63-
$flex="auto"
64-
$padding="0"
65-
>
66-
{children}
67-
</MainContent>
68-
<RightPanel />
69-
</Box>
70-
</ResizableLeftPanel>
60+
<MainResizableLayout backgroundColor={backgroundColor}>
61+
{children}
62+
</MainResizableLayout>
7163
);
7264
}
7365

@@ -96,6 +88,32 @@ export function MainLayoutContent({
9688
);
9789
}
9890

91+
interface MainResizableLayoutProps {
92+
backgroundColor: 'white' | 'grey';
93+
}
94+
95+
const MainResizableLayout = ({
96+
children,
97+
backgroundColor,
98+
}: PropsWithChildren<MainResizableLayoutProps>) => {
99+
usePanelCoordination();
100+
101+
return (
102+
<ResizableLeftPanel leftPanel={<LeftPanel />}>
103+
<Box $direction="row" $width="100%" $position="relative">
104+
<MainContent
105+
backgroundColor={backgroundColor}
106+
$flex="auto"
107+
$padding="0"
108+
>
109+
{children}
110+
</MainContent>
111+
<RightPanel />
112+
</Box>
113+
</ResizableLeftPanel>
114+
);
115+
};
116+
99117
type MainContentProps = BoxProps & {
100118
backgroundColor: 'white' | 'grey';
101119
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { useLeftPanelStore } from '@/features/left-panel';
4+
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
5+
import { useResponsiveStore } from '@/stores';
6+
7+
/**
8+
* Coordinates left/right panel visibility on tablet breakpoint.
9+
* On tablet, the place is too tight to have both panels open at the same time, so we auto-close
10+
* panels depending on the user actions.
11+
*/
12+
export function usePanelCoordination(): void {
13+
const { screenSize } = useResponsiveStore();
14+
const {
15+
isPanelOpen: isLeftPanelOpen,
16+
togglePanel: toggleLeftPanel,
17+
autoClose: autoCloseLeft,
18+
} = useLeftPanelStore();
19+
const {
20+
isPanelOpen: isRightPanelOpen,
21+
setActivePanel: setRightActivePanel,
22+
autoClose: autoCloseRight,
23+
} = useRightPanelStore();
24+
25+
const prevScreenSizeRef = useRef(screenSize);
26+
const prevRightPanelOpenRef = useRef(isRightPanelOpen);
27+
28+
/**
29+
* Case 1 – entering tablet with both panels open → auto-close left.
30+
* Case 2 – leaving tablet → reopen left if it was auto-closed.
31+
* Case 3 – right panel opens on tablet → auto-close left if open.
32+
* Case 4 – right panel closes on tablet → reopen left if auto-closed.
33+
*/
34+
useEffect(() => {
35+
const prevScreenSize = prevScreenSizeRef.current;
36+
const prevRightOpen = prevRightPanelOpenRef.current;
37+
prevScreenSizeRef.current = screenSize;
38+
prevRightPanelOpenRef.current = isRightPanelOpen;
39+
40+
// Case 1 – entering tablet
41+
if (screenSize === 'tablet' && prevScreenSize !== 'tablet') {
42+
if (isRightPanelOpen && useLeftPanelStore.getState().isPanelOpen) {
43+
autoCloseLeft();
44+
}
45+
return;
46+
}
47+
48+
// Case 2 – leaving tablet
49+
if (screenSize !== 'tablet' && prevScreenSize === 'tablet') {
50+
if (useLeftPanelStore.getState().wasAutoClosed) {
51+
toggleLeftPanel({ type: 'desktop', value: true });
52+
}
53+
return;
54+
}
55+
56+
// Case 3 – right opens on tablet
57+
if (screenSize === 'tablet' && isRightPanelOpen && !prevRightOpen) {
58+
if (useLeftPanelStore.getState().isPanelOpen) {
59+
autoCloseLeft();
60+
}
61+
return;
62+
}
63+
64+
// Case 4 – right closes on tablet
65+
if (screenSize === 'tablet' && !isRightPanelOpen && prevRightOpen) {
66+
if (useLeftPanelStore.getState().wasAutoClosed) {
67+
toggleLeftPanel({ type: 'desktop', value: true });
68+
}
69+
return;
70+
}
71+
}, [screenSize, isRightPanelOpen, autoCloseLeft, toggleLeftPanel]);
72+
73+
/**
74+
* Exception – force-open / symmetric restore.
75+
*
76+
* Left opens on tablet (right is open) → close right (save activePanel).
77+
* Left closes on tablet (right was auto-closed) → reopen right.
78+
*/
79+
useEffect(() => {
80+
if (useResponsiveStore.getState().screenSize !== 'tablet') {
81+
return;
82+
}
83+
if (isLeftPanelOpen) {
84+
// User force-opened left: close right and remember which view was active.
85+
if (useRightPanelStore.getState().isPanelOpen) {
86+
autoCloseRight();
87+
}
88+
} else {
89+
// User closed left: reopen right if it was auto-closed.
90+
const { wasAutoClosed, activePanel: savedPanel } =
91+
useRightPanelStore.getState();
92+
if (wasAutoClosed && savedPanel) {
93+
setRightActivePanel(savedPanel);
94+
}
95+
}
96+
}, [isLeftPanelOpen, autoCloseRight, setRightActivePanel]);
97+
}

0 commit comments

Comments
 (0)