📱(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.
This commit is contained in:
Anthony LC 2026-05-21 15:47:03 +02:00
parent f91ecf9d58
commit 4f4ebee585
No known key found for this signature in database
9 changed files with 207 additions and 18 deletions

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { tryFocusEditorContent } from './utils-editor';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Left panel desktop', () => {
@ -174,4 +175,47 @@ test.describe('Left panel responsive', () => {
await verifyDocName(page, docChild);
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
});
test('checks panel coordination on tablet sizes', async ({
page,
browserName,
}) => {
await page.setViewportSize({ width: 900, height: 1200 });
await page.goto('/');
await createDoc(page, 'tablet-doc-test', browserName, 1);
const leftPanel = page.locator('.--docs--resizable-left-panel');
const rightPanel = page.getByLabel('Table of contents side panel');
// Initially, left panel should be visible and right panel should be hidden
await expect(leftPanel).toBeInViewport();
await expect(rightPanel).not.toBeInViewport();
await tryFocusEditorContent({ page });
await page.keyboard.type('# Level 1');
// Open right panel, the left panel should hide
await page
.getByRole('button', { name: 'Show the table of contents sidebar' })
.click();
await expect(rightPanel).toBeInViewport();
await expect(leftPanel).toBeHidden();
// Open left panel, the right panel should hide
await page.getByRole('button', { name: /Show the side panel/ }).click();
await expect(leftPanel).toBeInViewport();
await expect(rightPanel).not.toBeInViewport();
// Close the left panel, the right panel should show
await page.getByRole('button', { name: /Hide the side panel/ }).click();
await expect(leftPanel).toBeHidden();
await expect(rightPanel).toBeInViewport();
// Close right panel, the left panel should stay closed
await page
.getByRole('button', { name: 'Hide the table of contents sidebar' })
.click();
await expect(rightPanel).not.toBeInViewport();
await expect(leftPanel).toBeHidden();
});
});

View file

@ -29,7 +29,7 @@ import { useConfig } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/docs/doc-management';
import { avatarUrlFromName, useAuth } from '@/features/auth';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { useAnalytics } from '@/libs/Analytics';
import { AI_FEATURE_FLAG, DEFAULT_LOCALE } from '../conf';

View file

@ -8,7 +8,7 @@ import CommentsIcon from '@/assets/icons/ui-kit/bubble-text.svg';
import SortingResolvedSVG from '@/assets/icons/ui-kit/filter-notification.svg';
import SortingOpenSVG from '@/assets/icons/ui-kit/filter_list.svg';
import { Box, ButtonCloseModal, Text } from '@/components/';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { useCommentSidebarStore } from './useCommentSidebarStore';

View file

@ -8,7 +8,7 @@ import { Box, ButtonCloseModal, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useHeadingStore } from '@/docs/doc-editor/stores/useHeadingStore';
import { useRightPanelStore } from '@/features/right-panel/components/useRightPanelStore';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { Heading } from './Heading';

View file

@ -9,14 +9,22 @@ type TogglePanelArgs = {
interface LeftPanelState {
isPanelOpen: boolean;
isPanelOpenMobile: boolean;
/**
* Depending on the responsive breakpoint, the panel can be auto-closed, and so
* auto-opened later on.
*/
wasAutoClosed: boolean;
togglePanel: (args?: TogglePanelArgs) => void;
closePanel: (args?: TogglePanelType) => void;
autoClose: () => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
isPanelOpen: true,
isPanelOpenMobile: false,
wasAutoClosed: false,
togglePanel: ({ value, type }: TogglePanelArgs = {}) => {
set({ wasAutoClosed: false });
if (typeof value === 'boolean') {
if (type === 'mobile') {
set({ isPanelOpenMobile: value });
@ -42,6 +50,7 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
set({ isPanelOpen: !isPanelOpen, isPanelOpenMobile: !isPanelOpenMobile });
},
closePanel: ({ type }: Partial<TogglePanelType> = {}) => {
set({ wasAutoClosed: false });
if (type === 'mobile') {
set({ isPanelOpenMobile: false });
return;
@ -52,4 +61,5 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
}
set({ isPanelOpen: false, isPanelOpenMobile: false });
},
autoClose: () => set({ isPanelOpen: false, wasAutoClosed: true }),
}));

View file

@ -9,7 +9,10 @@ import { TableContentSideBar } from '@/features/docs/doc-table-content/component
import { HEADER_HEIGHT } from '@/features/header';
import { useResponsiveStore } from '@/stores';
import { RightPanelView, useRightPanelStore } from './useRightPanelStore';
import {
RightPanelView,
useRightPanelStore,
} from '../stores/useRightPanelStore';
export const RightPanel = () => {
const { t } = useTranslation();

View file

@ -5,20 +5,37 @@ export type RightPanelView = 'tableContent' | 'comments';
export interface UseRightPanelStore {
isPanelOpen: boolean;
activePanel: RightPanelView | null;
/**
* Depending on the responsive breakpoint, the panel can be auto-closed, and so
* auto-opened later on.
*/
wasAutoClosed: boolean;
setActivePanel: (panel: RightPanelView | null) => void;
setIsPanelOpen: (isOpen: boolean) => void;
togglePanel: () => void;
autoClose: () => void;
}
export const useRightPanelStore = create<UseRightPanelStore>((set) => ({
isPanelOpen: false,
activePanel: null,
wasAutoClosed: false,
setActivePanel: (activePanel) =>
set(() => ({ activePanel, isPanelOpen: activePanel !== null })),
set(() => ({
activePanel,
isPanelOpen: activePanel !== null,
wasAutoClosed: false,
})),
setIsPanelOpen: (isPanelOpen) =>
set((state) => ({
isPanelOpen,
activePanel: isPanelOpen ? state.activePanel : null,
wasAutoClosed: false,
})),
togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })),
togglePanel: () =>
set((state) => ({
isPanelOpen: !state.isPanelOpen,
wasAutoClosed: false,
})),
autoClose: () => set({ isPanelOpen: false, wasAutoClosed: true }),
}));

View file

@ -11,6 +11,7 @@ import { DocEditorSkeleton, Skeleton } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { MAIN_LAYOUT_ID } from './conf';
import { usePanelCoordination } from './usePanelCoordination';
type MainLayoutProps = {
backgroundColor?: 'white' | 'grey';
@ -56,18 +57,9 @@ export function MainLayoutContent({
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
<Box $direction="row" $width="100%" $position="relative">
<MainContent
backgroundColor={backgroundColor}
$flex="auto"
$padding="0"
>
{children}
</MainContent>
<RightPanel />
</Box>
</ResizableLeftPanel>
<MainResizableLayout backgroundColor={backgroundColor}>
{children}
</MainResizableLayout>
);
}
@ -96,6 +88,32 @@ export function MainLayoutContent({
);
}
interface MainResizableLayoutProps {
backgroundColor: 'white' | 'grey';
}
const MainResizableLayout = ({
children,
backgroundColor,
}: PropsWithChildren<MainResizableLayoutProps>) => {
usePanelCoordination();
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
<Box $direction="row" $width="100%" $position="relative">
<MainContent
backgroundColor={backgroundColor}
$flex="auto"
$padding="0"
>
{children}
</MainContent>
<RightPanel />
</Box>
</ResizableLeftPanel>
);
};
type MainContentProps = BoxProps & {
backgroundColor: 'white' | 'grey';
};

View file

@ -0,0 +1,97 @@
import { useEffect, useRef } from 'react';
import { useLeftPanelStore } from '@/features/left-panel';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { useResponsiveStore } from '@/stores';
/**
* Coordinates left/right panel visibility on tablet breakpoint.
* On tablet, the place is too tight to have both panels open at the same time, so we auto-close
* panels depending on the user actions.
*/
export function usePanelCoordination(): void {
const { screenSize } = useResponsiveStore();
const {
isPanelOpen: isLeftPanelOpen,
togglePanel: toggleLeftPanel,
autoClose: autoCloseLeft,
} = useLeftPanelStore();
const {
isPanelOpen: isRightPanelOpen,
setActivePanel: setRightActivePanel,
autoClose: autoCloseRight,
} = useRightPanelStore();
const prevScreenSizeRef = useRef(screenSize);
const prevRightPanelOpenRef = useRef(isRightPanelOpen);
/**
* Case 1 entering tablet with both panels open auto-close left.
* Case 2 leaving tablet reopen left if it was auto-closed.
* Case 3 right panel opens on tablet auto-close left if open.
* Case 4 right panel closes on tablet reopen left if auto-closed.
*/
useEffect(() => {
const prevScreenSize = prevScreenSizeRef.current;
const prevRightOpen = prevRightPanelOpenRef.current;
prevScreenSizeRef.current = screenSize;
prevRightPanelOpenRef.current = isRightPanelOpen;
// Case 1 entering tablet
if (screenSize === 'tablet' && prevScreenSize !== 'tablet') {
if (isRightPanelOpen && useLeftPanelStore.getState().isPanelOpen) {
autoCloseLeft();
}
return;
}
// Case 2 leaving tablet
if (screenSize !== 'tablet' && prevScreenSize === 'tablet') {
if (useLeftPanelStore.getState().wasAutoClosed) {
toggleLeftPanel({ type: 'desktop', value: true });
}
return;
}
// Case 3 right opens on tablet
if (screenSize === 'tablet' && isRightPanelOpen && !prevRightOpen) {
if (useLeftPanelStore.getState().isPanelOpen) {
autoCloseLeft();
}
return;
}
// Case 4 right closes on tablet
if (screenSize === 'tablet' && !isRightPanelOpen && prevRightOpen) {
if (useLeftPanelStore.getState().wasAutoClosed) {
toggleLeftPanel({ type: 'desktop', value: true });
}
return;
}
}, [screenSize, isRightPanelOpen, autoCloseLeft, toggleLeftPanel]);
/**
* Exception force-open / symmetric restore.
*
* Left opens on tablet (right is open) close right (save activePanel).
* Left closes on tablet (right was auto-closed) reopen right.
*/
useEffect(() => {
if (useResponsiveStore.getState().screenSize !== 'tablet') {
return;
}
if (isLeftPanelOpen) {
// User force-opened left: close right and remember which view was active.
if (useRightPanelStore.getState().isPanelOpen) {
autoCloseRight();
}
} else {
// User closed left: reopen right if it was auto-closed.
const { wasAutoClosed, activePanel: savedPanel } =
useRightPanelStore.getState();
if (wasAutoClosed && savedPanel) {
setRightActivePanel(savedPanel);
}
}
}, [isLeftPanelOpen, autoCloseRight, setRightActivePanel]);
}