mirror of
https://github.com/suitenumerique/docs
synced 2026-05-24 09:28:25 +00:00
📱(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:
parent
f91ecf9d58
commit
4f4ebee585
9 changed files with 207 additions and 18 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
Loading…
Reference in a new issue