(frontend) add floating bar with collapse button

Add sticky floating bar at top of document with leftpanelcollapse btn
This commit is contained in:
Cyril
2026-02-09 14:52:15 +01:00
parent c0994d7d1f
commit 010ed4618a
21 changed files with 394 additions and 74 deletions

View File

@@ -9,6 +9,7 @@ and this project adheres to
### Added
- ✨(tracking) add UTM parameters to shared document links
- ✨(frontend) add floating bar with leftpanel collapse button #1876
- ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878
- 👷(CI) add GHCR workflow for forked repo testing #1851

View File

@@ -41,7 +41,7 @@ test.describe('Doc Comments', () => {
// We add a comment with the first user
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -124,7 +124,7 @@ test.describe('Doc Comments', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -191,7 +191,7 @@ test.describe('Doc Comments', () => {
/* Delete the last comment remove the thread */
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
@@ -249,7 +249,9 @@ test.describe('Doc Comments', () => {
editor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage.getByRole('button', { name: 'Comment' }).click();
await otherPage
.getByRole('button', { name: 'Comment', exact: true })
.click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread
.getByRole('paragraph')
@@ -280,7 +282,7 @@ test.describe('Doc Comments', () => {
await expect(otherThread).toBeHidden();
await otherEditor.getByText('Hello').selectText();
await expect(
otherPage.getByRole('button', { name: 'Comment' }),
otherPage.getByRole('button', { name: 'Comment', exact: true }),
).toBeHidden();
await otherPage.reload();
@@ -334,7 +336,7 @@ test.describe('Doc Comments', () => {
// We add a comment in the first document
const editor1 = await writeInEditor({ page, text: 'Document One' });
await editor1.getByText('Document One').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread1 = page.locator('.bn-thread');
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
@@ -388,7 +390,7 @@ test.describe('Doc Comments mobile', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');

View File

@@ -410,7 +410,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI' }).click();
await page.getByRole('button', { name: 'AI', exact: true }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -494,11 +494,13 @@ test.describe('Doc Editor', () => {
await editor.getByText('Hello').selectText();
if (!ai_transform && !ai_translate) {
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
await expect(
page.getByRole('button', { name: 'AI', exact: true }),
).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI' }).click();
await page.getByRole('button', { name: 'AI', exact: true }).click();
if (ai_transform) {
await expect(

View File

@@ -7,6 +7,7 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import {
connectOtherUserToDoc,
mockedAccesses,
@@ -20,6 +21,43 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Header', () => {
test('toggles panel collapse from floating bar button', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-floating-bar',
browserName,
1,
);
const collapseButton = page.getByTestId('floating-bar-toggle-left-panel');
await expect(collapseButton).toBeVisible();
// Panel open
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
// Collapse panel
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'false');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
// When the title is not visible in the viewport, the button should show the title
const editor = await writeInEditor({ page, text: 'Lorem ipsum' });
for (let i = 0; i < 25; i++) {
await editor.press('Enter');
}
await writeInEditor({ page, text: 'Lorem ipsum 2' });
await expect(collapseButton.getByText(docTitle)).toBeVisible();
// Expand panel and check the title is hidden again
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
});
test('it checks the element are correctly displayed', async ({
page,
browserName,

View File

@@ -2,7 +2,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import { DocHeader, FloatingBar } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
@@ -35,6 +35,7 @@ export const DocEditorContainer = ({
return (
<>
{isDesktop && <FloatingBar />}
<Box
$maxWidth="868px"
$width="100%"

View File

@@ -35,7 +35,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<>
<Box
$width="100%"
$padding={{ top: isDesktop ? '50px' : 'md' }}
$padding={{ top: isDesktop ? '0' : 'md' }}
$gap={spacingsTokens['base']}
aria-label={t('It is the card information about the document.')}
className="--docs--doc-header"

View File

@@ -18,6 +18,8 @@ import {
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';
export const CLASS_DOC_TITLE = '--docs--doc-title';
interface DocTitleProps {
doc: Doc;
}
@@ -39,13 +41,15 @@ export const DocTitleText = () => {
const { untitledDocument } = useTrans();
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{currentDoc?.title || untitledDocument}
</Text>
<Box className={CLASS_DOC_TITLE} $direction="row" $align="center">
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{currentDoc?.title || untitledDocument}
</Text>
</Box>
);
};
@@ -65,6 +69,7 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
placement="top"
>
<Box
className={CLASS_DOC_TITLE}
$css={css`
padding: 4px;
padding-top: 3px;

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham/useCunninghamTheme';
import { LeftPanelCollapseButton } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
/**
* Sticky bar trick (desktop):
* - MainContent has padding `base`; we extend the bar width and apply
* matching negative margins so it aligns with the scroll area edges.
*
* Mobile: returns null to avoid header overlap.
*/
export const FloatingBar = () => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const FLOATING_STYLES = useMemo(() => {
const base = spacingsTokens['base'];
const sm = spacingsTokens['sm'];
return css`
position: sticky;
top: calc(-${base});
left: 0;
right: 0;
width: calc(100% + ${base} + ${base});
min-height: 64px;
padding: ${sm};
margin-left: calc(-${base});
margin-right: calc(-${base});
margin-top: calc(-${base});
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: flex-start;
isolation: isolate;
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(
180deg,
#fff 0%,
rgba(255, 255, 255, 0) 100%
);
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(1px);
mask-image: linear-gradient(180deg, black 50%, transparent 100%);
-webkit-mask-image: linear-gradient(
180deg,
black 50%,
transparent 100%
);
}
> * {
position: relative;
z-index: 1;
}
`;
}, [spacingsTokens]);
if (!isDesktop) {
return null;
}
return (
<Box
className="--docs--floating-bar"
data-testid="floating-bar"
$css={FLOATING_STYLES}
>
<LeftPanelCollapseButton />
</Box>
);
};

View File

@@ -1,2 +1,3 @@
export * from './DocHeader';
export * from './DocTitle';
export * from './FloatingBar';

View File

@@ -61,7 +61,7 @@ export const TableContent = () => {
$width={!isOpen ? '40px' : '200px'}
$height={!isOpen ? '40px' : 'auto'}
$maxHeight="calc(50vh - 60px)"
$zIndex={1000}
$zIndex={2000}
$align="center"
$padding={isOpen ? 'xs' : '0'}
$justify="center"

View File

@@ -6,19 +6,22 @@ import { useLeftPanelStore } from '@/features/left-panel';
export const ButtonTogglePanel = () => {
const { t } = useTranslation();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
const { isPanelOpenMobile, togglePanel } = useLeftPanelStore();
return (
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={t(
isPanelOpen ? 'Close the header menu' : 'Open the header menu',
isPanelOpenMobile ? 'Close the header menu' : 'Open the header menu',
)}
aria-expanded={isPanelOpen}
aria-expanded={isPanelOpenMobile}
variant="tertiary"
icon={
<Icon $withThemeInherited iconName={isPanelOpen ? 'close' : 'menu'} />
<Icon
$withThemeInherited
iconName={isPanelOpenMobile ? 'close' : 'menu'}
/>
}
className="--docs--button-toggle-panel"
data-testid="header-menu-toggle"

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04601 20.3381C3.04306 20.3381 2.28465 20.0812 1.77079 19.5673C1.25693 19.0597 1 18.3105 1 17.32V6.01815C1 5.02139 1.25693 4.26917 1.77079 3.7615C2.28465 3.25383 3.04306 3 4.04601 3H19.9447C20.9538 3 21.7154 3.25383 22.2292 3.7615C22.7431 4.26917 23 5.02139 23 6.01815V17.32C23 18.3105 22.7431 19.0597 22.2292 19.5673C21.7154 20.0812 20.9538 20.3381 19.9447 20.3381H4.04601ZM4.15745 18.5087H19.8425C20.2697 18.5087 20.5979 18.3972 20.8269 18.1743C21.056 17.9515 21.1705 17.614 21.1705 17.1621V6.16674C21.1705 5.72098 21.056 5.38666 20.8269 5.16378C20.5979 4.9409 20.2697 4.82946 19.8425 4.82946H4.15745C3.72407 4.82946 3.39285 4.9409 3.16378 5.16378C2.9409 5.38666 2.82946 5.72098 2.82946 6.16674V17.1621C2.82946 17.614 2.9409 17.9515 3.16378 18.1743C3.39285 18.3972 3.72407 18.5087 4.15745 18.5087ZM8.38286 18.8058V4.51372H10.1195V18.8058H8.38286ZM6.56268 8.22837H4.65893C4.49796 8.22837 4.35556 8.16646 4.23174 8.04263C4.11411 7.91881 4.0553 7.78261 4.0553 7.63402C4.0553 7.47305 4.11411 7.33376 4.23174 7.21613C4.35556 7.09849 4.49796 7.03968 4.65893 7.03968H6.56268C6.72984 7.03968 6.87224 7.09849 6.98987 7.21613C7.11369 7.33376 7.1756 7.47305 7.1756 7.63402C7.1756 7.78261 7.11369 7.91881 6.98987 8.04263C6.87224 8.16646 6.72984 8.22837 6.56268 8.22837ZM6.56268 10.7172H4.65893C4.49796 10.7172 4.35556 10.6584 4.23174 10.5407C4.11411 10.4169 4.0553 10.2745 4.0553 10.1136C4.0553 9.95877 4.11411 9.82257 4.23174 9.70494C4.35556 9.58731 4.49796 9.52849 4.65893 9.52849H6.56268C6.72984 9.52849 6.87224 9.58731 6.98987 9.70494C7.11369 9.82257 7.1756 9.95877 7.1756 10.1136C7.1756 10.2745 7.11369 10.4169 6.98987 10.5407C6.87224 10.6584 6.72984 10.7172 6.56268 10.7172ZM6.56268 13.1967H4.65893C4.49796 13.1967 4.35556 13.1379 4.23174 13.0203C4.11411 12.9026 4.0553 12.7664 4.0553 12.6117C4.0553 12.4507 4.11411 12.3114 4.23174 12.1938C4.35556 12.0761 4.49796 12.0173 4.65893 12.0173H6.56268C6.72984 12.0173 6.87224 12.0761 6.98987 12.1938C7.11369 12.3114 7.1756 12.4507 7.1756 12.6117C7.1756 12.7664 7.11369 12.9026 6.98987 13.0203C6.87224 13.1379 6.72984 13.1967 6.56268 13.1967Z" fill="#626A80"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -7,13 +7,15 @@ import { Box, Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocDefaultFilter } from '@/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
export const LeftPanelTargetFilters = () => {
const { t } = useTranslation();
const pathname = usePathname();
const searchParams = useSearchParams();
const { togglePanel } = useLeftPanelStore();
const { isDesktop } = useResponsiveStore();
const { closePanel } = useLeftPanelStore();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const target =
@@ -49,8 +51,10 @@ export const LeftPanelTargetFilters = () => {
return `${pathname}?${params.toString()}`;
};
const handleClick = () => {
togglePanel();
const handleFilterClick = () => {
if (!isDesktop) {
closePanel();
}
};
return (
@@ -70,9 +74,7 @@ export const LeftPanelTargetFilters = () => {
href={href}
aria-label={query.label}
aria-current={isActive ? 'page' : undefined}
onClick={() => {
handleClick();
}}
onClick={handleFilterClick}
$css={css`
display: flex;
align-items: center;

View File

@@ -26,13 +26,14 @@ export const LeftPanel = () => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { togglePanel, isPanelOpen } = useLeftPanelStore();
const { togglePanel, isPanelOpen, isPanelOpenMobile } = useLeftPanelStore();
const isPanelOpenState = isDesktop ? isPanelOpen : isPanelOpenMobile;
const pathname = usePathname();
useEffect(() => {
togglePanel(false);
}, [pathname, togglePanel]);
togglePanel(isDesktop);
}, [pathname, isDesktop, togglePanel]);
return (
<>
@@ -62,7 +63,7 @@ export const LeftPanel = () => {
{!isDesktop && (
<>
{isPanelOpen && <MobileLeftPanelStyle />}
{isPanelOpenState && <MobileLeftPanelStyle />}
<Box
$hasTransition
$css={css`
@@ -71,7 +72,7 @@ export const LeftPanel = () => {
height: calc(100dvh - 52px);
border-right: 1px solid var(--c--globals--colors--gray-200);
position: fixed;
transform: translateX(${isPanelOpen ? '0' : '-100dvw'});
transform: translateX(${isPanelOpenState ? '0' : '-100dvw'});
background-color: var(--c--globals--colors--gray-000);
overflow-y: auto;
overflow-x: hidden;

View File

@@ -0,0 +1,91 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { CLASS_DOC_TITLE } from '@/docs/doc-header/components/DocTitle';
import { getEmojiAndTitle, useDocStore, useTrans } from '@/docs/doc-management';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import LeftPanelIcon from '../assets/left-panel.svg';
import { useLeftPanelStore } from '../stores';
export const LeftPanelCollapseButton = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
const { currentDoc } = useDocStore();
const [isDocTitleVisible, setIsDocTitleVisible] = useState(true);
useEffect(() => {
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
const docTitleEl = document.querySelector(`.${CLASS_DOC_TITLE}`);
if (!mainContent || !docTitleEl) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIsDocTitleVisible(entry.isIntersecting);
},
{
root: mainContent,
threshold: 0.05,
},
);
observer.observe(docTitleEl);
return () => {
observer.disconnect();
setIsDocTitleVisible(true);
};
}, [currentDoc?.id]);
const { untitledDocument } = useTrans();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
currentDoc?.title ?? '',
);
const docTitle = titleWithoutEmoji || untitledDocument;
const buttonTitle = emoji ? `${emoji} ${docTitle}` : docTitle;
const shouldShowButtonTitle = !isPanelOpen && !isDocTitleVisible;
const ariaLabel = isPanelOpen
? t('Hide the side panel for {{title}}', { title: docTitle })
: t('Show the side panel for {{title}}', { title: docTitle });
return (
<Box
$css={css`
display: inline-flex;
padding: var(--c--globals--spacings--xxxs);
align-items: center;
gap: var(--c--globals--spacings--xxxs);
border-radius: var(--c--globals--spacings--xs);
border: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
<Button
size="small"
onClick={() => togglePanel()}
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
color="neutral"
variant="tertiary"
icon={<LeftPanelIcon width={24} height={24} aria-hidden="true" />}
data-testid="floating-bar-toggle-left-panel"
>
{shouldShowButtonTitle ? (
<Text $size="sm" $weight={700} $color={colorsTokens['gray-1000']}>
{buttonTitle}
</Text>
) : undefined}
</Button>
</Box>
);
};

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
import { useCreateDoc } from '@/features/docs/doc-management';
import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { useLeftPanelStore } from '../stores';
@@ -13,6 +14,7 @@ export const LeftPanelHeaderButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { closePanel } = useLeftPanelStore();
const { isDesktop } = useResponsiveStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const [isNavigating, setIsNavigating] = useState(false);
@@ -25,7 +27,9 @@ export const LeftPanelHeaderButton = () => {
.then(() => {
// The skeleton will be disabled by the [id] page once the data is loaded
setIsNavigating(false);
closePanel();
if (!isDesktop) {
closePanel();
}
})
.catch(() => {
// In case of navigation error, disable the skeleton

View File

@@ -6,6 +6,7 @@ import {
PanelResizeHandle,
} from 'react-resizable-panels';
import { useLeftPanelStore } from '@/features/left-panel/stores';
import { useResponsiveStore } from '@/stores';
// Convert a target pixel width to a percentage of the current viewport width.
@@ -13,6 +14,9 @@ const pxToPercent = (px: number) => {
return (px / window.innerWidth) * 100;
};
const PANEL_TOGGLE_TRANSITION =
'flex-grow 180ms var(--c--globals--transitions--ease-out), flex-basis 180ms var(--c--globals--transitions--ease-out)';
type ResizableLeftPanelProps = {
leftPanel: React.ReactNode;
children: React.ReactNode;
@@ -27,44 +31,73 @@ export const ResizableLeftPanel = ({
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const { isDesktop } = useResponsiveStore();
const { isPanelOpen } = useLeftPanelStore();
const ref = useRef<ImperativePanelHandle>(null);
const savedWidthPxRef = useRef<number>(minPanelSizePx);
const previousPanelOpenRef = useRef<boolean>(isPanelOpen);
const [isToggleAnimating, setIsToggleAnimating] = useState(false);
const minPanelSizePercent = pxToPercent(minPanelSizePx);
const maxPanelSizePercent = Math.min(pxToPercent(maxPanelSizePx), 40);
const [panelSizePercent, setPanelSizePercent] = useState(() => {
const initialSize = pxToPercent(minPanelSizePx);
return Math.max(
minPanelSizePercent,
Math.min(initialSize, maxPanelSizePercent),
);
});
// Keep pixel width constant on window resize
useEffect(() => {
if (!isDesktop) {
const syncPanelState = () => {
if (!ref.current || !isDesktop) {
return;
}
if (!isPanelOpen) {
ref.current.collapse();
return;
}
const restoredSizePercent = Math.max(
minPanelSizePercent,
Math.min(pxToPercent(savedWidthPxRef.current), maxPanelSizePercent),
);
ref.current.expand();
ref.current.resize(restoredSizePercent);
};
const hasPanelToggleChanged = previousPanelOpenRef.current !== isPanelOpen;
previousPanelOpenRef.current = isPanelOpen;
if (hasPanelToggleChanged) {
setIsToggleAnimating(true);
const animationFrameId = requestAnimationFrame(() => {
syncPanelState();
});
const timeoutId = setTimeout(() => {
setIsToggleAnimating(false);
}, 180);
return () => {
window.cancelAnimationFrame(animationFrameId);
window.clearTimeout(timeoutId);
};
}
syncPanelState();
if (!isDesktop || !isPanelOpen) {
return;
}
const handleResize = () => {
const newPercent = pxToPercent(savedWidthPxRef.current);
setPanelSizePercent(newPercent);
if (ref.current) {
ref.current.resize?.(newPercent - (ref.current.getSize() || 0));
}
syncPanelState();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [isDesktop]);
}, [isDesktop, isPanelOpen, minPanelSizePercent, maxPanelSizePercent]);
const handleResize = (sizePercent: number) => {
const widthPx = (sizePercent / 100) * window.innerWidth;
savedWidthPxRef.current = widthPx;
setPanelSizePercent(sizePercent);
if (isDesktop && sizePercent > 0) {
const widthPx = (sizePercent / 100) * window.innerWidth;
savedWidthPxRef.current = widthPx;
}
};
return (
@@ -73,11 +106,20 @@ export const ResizableLeftPanel = ({
ref={ref}
className="--docs--resizable-left-panel"
order={0}
collapsible
collapsedSize={0}
style={{
overflow: 'hidden',
transition: isToggleAnimating ? PANEL_TOGGLE_TRANSITION : 'none',
}}
defaultSize={
isDesktop
? Math.max(
minPanelSizePercent,
Math.min(panelSizePercent, maxPanelSizePercent),
Math.min(
pxToPercent(savedWidthPxRef.current),
maxPanelSizePercent,
),
)
: 0
}
@@ -95,10 +137,17 @@ export const ResizableLeftPanel = ({
width: '1px',
cursor: 'col-resize',
}}
disabled={!isDesktop}
disabled={!isDesktop || !isPanelOpen}
/>
<Panel order={1}>{children}</Panel>
<Panel
order={1}
style={{
transition: isToggleAnimating ? PANEL_TOGGLE_TRANSITION : 'none',
}}
>
{children}
</Panel>
</PanelGroup>
);
};

View File

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

View File

@@ -2,21 +2,30 @@ import { create } from 'zustand';
interface LeftPanelState {
isPanelOpen: boolean;
isPanelOpenMobile: boolean;
togglePanel: (value?: boolean) => void;
closePanel: () => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
isPanelOpen: false,
isPanelOpen: true,
isPanelOpenMobile: false,
togglePanel: (value?: boolean) => {
const sanitizedValue =
value !== undefined && typeof value === 'boolean'
? value
: !get().isPanelOpen;
set({ isPanelOpen: sanitizedValue });
if (value === true) {
set({ isPanelOpen: true });
return;
}
if (value === false) {
set({ isPanelOpen: false, isPanelOpenMobile: false });
return;
}
const { isPanelOpen, isPanelOpenMobile } = get();
set({
isPanelOpen: !isPanelOpen,
isPanelOpenMobile: !isPanelOpenMobile,
});
},
closePanel: () => {
set({ isPanelOpen: false });
set({ isPanelOpen: false, isPanelOpenMobile: false });
},
}));

View File

@@ -1,13 +1,29 @@
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
export const useRouteChangeCompleteFocus = () => {
const router = useRouter();
const lastCompletedPathRef = useRef<string | null>(null);
const isKeyboardNavigationRef = useRef(false);
useEffect(() => {
const handleRouteChangeComplete = () => {
const handleKeyboardNavigation = (event: KeyboardEvent) => {
if (['Tab', 'Enter', ' ', 'Spacebar'].includes(event.key)) {
isKeyboardNavigationRef.current = true;
}
};
window.addEventListener('keydown', handleKeyboardNavigation);
const handleRouteChangeComplete = (url: string) => {
const normalizedUrl = url.split('#')[0];
if (lastCompletedPathRef.current === normalizedUrl) {
return;
}
lastCompletedPathRef.current = normalizedUrl;
requestAnimationFrame(() => {
const mainContent =
document.getElementById(MAIN_LAYOUT_ID) ??
@@ -22,7 +38,13 @@ export const useRouteChangeCompleteFocus = () => {
'(prefers-reduced-motion: reduce)',
).matches;
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
if (isKeyboardNavigationRef.current) {
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
isKeyboardNavigationRef.current = false;
}
if (router.pathname === '/docs/[id]') {
return;
}
(firstHeading ?? mainContent)?.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
@@ -32,7 +54,8 @@ export const useRouteChangeCompleteFocus = () => {
router.events.on('routeChangeComplete', handleRouteChangeComplete);
return () => {
window.removeEventListener('keydown', handleKeyboardNavigation);
router.events.off('routeChangeComplete', handleRouteChangeComplete);
};
}, [router.events]);
}, [router.events, router.pathname]);
};

View File

@@ -120,9 +120,13 @@ const MainContent = ({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus-visible {
outline: 3px solid ${colorsTokens['brand-400']};
outline-offset: -3px;
&:focus-visible::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid ${colorsTokens['brand-400']};
pointer-events: none;
z-index: 2001;
}
`}
>