✨(frontend) add floating bar with collapse button
Add sticky floating bar at top of document with leftpanelcollapse btn
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './DocHeader';
|
||||
export * from './DocTitle';
|
||||
export * from './FloatingBar';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './LeftPanel';
|
||||
export * from './LeftPanelCollapseButton';
|
||||
export * from './ResizableLeftPanel';
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user