Skip to content

Commit 53a5872

Browse files
committed
✨(frontend) add floating bar with collapse button
Add sticky floating bar at top of document with leftpanelcollapse btn
1 parent 17cb213 commit 53a5872

File tree

21 files changed

+399
-52
lines changed

21 files changed

+399
-52
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to
88

99
### Added
1010

11+
- ✨(frontend) add floating bar with leftpanel collapse button #1876
1112
- ✨(frontend) Can print a doc #1832
1213
- ✨(backend) manage reconciliation requests for user accounts #1878
1314

src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ test.describe('Doc Comments', () => {
4141
// We add a comment with the first user
4242
const editor = await writeInEditor({ page, text: 'Hello World' });
4343
await editor.getByText('Hello').selectText();
44-
await page.getByRole('button', { name: 'Comment' }).click();
44+
await page.getByRole('button', { name: 'Comment', exact: true }).click();
4545

4646
const thread = page.locator('.bn-thread');
4747
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -124,7 +124,7 @@ test.describe('Doc Comments', () => {
124124
// Checks add react reaction
125125
const editor = await writeInEditor({ page, text: 'Hello' });
126126
await editor.getByText('Hello').selectText();
127-
await page.getByRole('button', { name: 'Comment' }).click();
127+
await page.getByRole('button', { name: 'Comment', exact: true }).click();
128128

129129
const thread = page.locator('.bn-thread');
130130
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -191,7 +191,7 @@ test.describe('Doc Comments', () => {
191191

192192
/* Delete the last comment remove the thread */
193193
await editor.getByText('Hello').selectText();
194-
await page.getByRole('button', { name: 'Comment' }).click();
194+
await page.getByRole('button', { name: 'Comment', exact: true }).click();
195195

196196
await thread.getByRole('paragraph').first().fill('This is a new comment');
197197
await thread.locator('[data-test="save"]').click();
@@ -249,7 +249,9 @@ test.describe('Doc Comments', () => {
249249
editor.getByText('Hello, I can edit the document'),
250250
).toBeVisible();
251251
await otherEditor.getByText('Hello').selectText();
252-
await otherPage.getByRole('button', { name: 'Comment' }).click();
252+
await otherPage
253+
.getByRole('button', { name: 'Comment', exact: true })
254+
.click();
253255
const otherThread = otherPage.locator('.bn-thread');
254256
await otherThread
255257
.getByRole('paragraph')
@@ -280,7 +282,7 @@ test.describe('Doc Comments', () => {
280282
await expect(otherThread).toBeHidden();
281283
await otherEditor.getByText('Hello').selectText();
282284
await expect(
283-
otherPage.getByRole('button', { name: 'Comment' }),
285+
otherPage.getByRole('button', { name: 'Comment', exact: true }),
284286
).toBeHidden();
285287

286288
await otherPage.reload();
@@ -334,7 +336,7 @@ test.describe('Doc Comments', () => {
334336
// We add a comment in the first document
335337
const editor1 = await writeInEditor({ page, text: 'Document One' });
336338
await editor1.getByText('Document One').selectText();
337-
await page.getByRole('button', { name: 'Comment' }).click();
339+
await page.getByRole('button', { name: 'Comment', exact: true }).click();
338340

339341
const thread1 = page.locator('.bn-thread');
340342
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
@@ -388,7 +390,7 @@ test.describe('Doc Comments mobile', () => {
388390
// Checks add react reaction
389391
const editor = await writeInEditor({ page, text: 'Hello' });
390392
await editor.getByText('Hello').selectText();
391-
await page.getByRole('button', { name: 'Comment' }).click();
393+
await page.getByRole('button', { name: 'Comment', exact: true }).click();
392394

393395
const thread = page.locator('.bn-thread');
394396
await thread.getByRole('paragraph').first().fill('This is a comment');

src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,45 @@ test.beforeEach(async ({ page }) => {
2424
});
2525

2626
test.describe('Doc Editor', () => {
27+
test('shows floating bar and collapse button on desktop', async ({
28+
page,
29+
browserName,
30+
}) => {
31+
await createDoc(page, 'doc-floating-bar', browserName, 1);
32+
33+
await expect(page.getByTestId('floating-bar')).toBeVisible();
34+
35+
const collapseButton = page.getByTestId('floating-bar-toggle-left-panel');
36+
await expect(collapseButton).toBeVisible();
37+
});
38+
39+
test('toggles panel collapse from floating bar button', async ({
40+
page,
41+
browserName,
42+
}) => {
43+
await createDoc(page, 'doc-floating-bar', browserName, 1);
44+
45+
const collapseButton = page.getByTestId('floating-bar-toggle-left-panel');
46+
await expect(collapseButton).toBeVisible();
47+
const initialExpanded = await collapseButton.getAttribute('aria-expanded');
48+
expect(
49+
initialExpanded === 'true' || initialExpanded === 'false',
50+
).toBeTruthy();
51+
const isInitiallyExpanded = initialExpanded === 'true';
52+
53+
await collapseButton.click();
54+
await expect(collapseButton).toHaveAttribute(
55+
'aria-expanded',
56+
isInitiallyExpanded ? 'false' : 'true',
57+
);
58+
59+
await collapseButton.click();
60+
await expect(collapseButton).toHaveAttribute(
61+
'aria-expanded',
62+
isInitiallyExpanded ? 'true' : 'false',
63+
);
64+
});
65+
2766
test('it checks toolbar buttons are displayed', async ({
2867
page,
2968
browserName,
@@ -410,7 +449,7 @@ test.describe('Doc Editor', () => {
410449
const editor = page.locator('.ProseMirror');
411450
await editor.getByText('Hello').selectText();
412451

413-
await page.getByRole('button', { name: 'AI' }).click();
452+
await page.getByRole('button', { name: 'AI', exact: true }).click();
414453

415454
await expect(
416455
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -494,11 +533,13 @@ test.describe('Doc Editor', () => {
494533
await editor.getByText('Hello').selectText();
495534

496535
if (!ai_transform && !ai_translate) {
497-
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
536+
await expect(
537+
page.getByRole('button', { name: 'AI', exact: true }),
538+
).toBeHidden();
498539
return;
499540
}
500541

501-
await page.getByRole('button', { name: 'AI' }).click();
542+
await page.getByRole('button', { name: 'AI', exact: true }).click();
502543

503544
if (ai_transform) {
504545
await expect(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@/docs/doc-management';
1313
import { TableContent } from '@/docs/doc-table-content/';
1414
import { useAuth } from '@/features/auth/';
15+
import { FloatingBar } from '@/features/floating-bar';
1516
import { useSkeletonStore } from '@/features/skeletons';
1617
import { useAnalytics } from '@/libs';
1718
import { useResponsiveStore } from '@/stores';
@@ -35,6 +36,7 @@ export const DocEditorContainer = ({
3536

3637
return (
3738
<>
39+
{isDesktop && <FloatingBar />}
3840
<Box
3941
$maxWidth="868px"
4042
$width="100%"

src/frontend/apps/impress/src/features/docs/doc-header/components/DocHeader.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect, useRef } from 'react';
12
import { useTranslation } from 'react-i18next';
23

34
import { Box, HorizontalSeparator } from '@/components';
@@ -8,6 +9,8 @@ import {
89
getDocLinkReach,
910
useIsCollaborativeEditable,
1011
} from '@/docs/doc-management';
12+
import { useFloatingBarStore } from '@/features/floating-bar';
13+
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
1114
import { useResponsiveStore } from '@/stores';
1215

1316
import { AlertNetwork } from './AlertNetwork';
@@ -23,19 +26,49 @@ interface DocHeaderProps {
2326
}
2427

2528
export const DocHeader = ({ doc }: DocHeaderProps) => {
29+
const headerRef = useRef<HTMLDivElement>(null);
2630
const { spacingsTokens } = useCunninghamTheme();
2731
const { isDesktop } = useResponsiveStore();
2832
const { t } = useTranslation();
33+
const { setIsDocHeaderVisible } = useFloatingBarStore();
2934
const { isEditable } = useIsCollaborativeEditable(doc);
3035
const docIsPublic = getDocLinkReach(doc) === LinkReach.PUBLIC;
3136
const docIsAuth = getDocLinkReach(doc) === LinkReach.AUTHENTICATED;
3237
const isDeletedDoc = !!doc.deleted_at;
3338

39+
useEffect(() => {
40+
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
41+
const header = headerRef.current;
42+
43+
if (!mainContent || !header) {
44+
setIsDocHeaderVisible(false);
45+
return;
46+
}
47+
48+
const observer = new IntersectionObserver(
49+
([entry]) => {
50+
setIsDocHeaderVisible(entry.isIntersecting);
51+
},
52+
{
53+
root: mainContent,
54+
threshold: 0.05,
55+
},
56+
);
57+
58+
observer.observe(header);
59+
60+
return () => {
61+
observer.disconnect();
62+
setIsDocHeaderVisible(true);
63+
};
64+
}, [doc.id, setIsDocHeaderVisible]);
65+
3466
return (
3567
<>
3668
<Box
69+
ref={headerRef}
3770
$width="100%"
38-
$padding={{ top: isDesktop ? '50px' : 'md' }}
71+
$padding={{ top: isDesktop ? '0' : 'md' }}
3972
$gap={spacingsTokens['base']}
4073
aria-label={t('It is the card information about the document.')}
4174
className="--docs--doc-header"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const TableContent = () => {
6161
$width={!isOpen ? '40px' : '200px'}
6262
$height={!isOpen ? '40px' : 'auto'}
6363
$maxHeight="calc(50vh - 60px)"
64-
$zIndex={1000}
64+
$zIndex={2000}
6565
$align="center"
6666
$padding={isOpen ? 'xs' : '0'}
6767
$justify="center"
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Button } from '@gouvfr-lasuite/cunningham-react';
2+
import { useTranslation } from 'react-i18next';
3+
import { css } from 'styled-components';
4+
5+
import { Box, Text } from '@/components';
6+
import { useCunninghamTheme } from '@/cunningham';
7+
import { getEmojiAndTitle, useDocStore, useTrans } from '@/docs/doc-management';
8+
import { useFloatingBarStore } from '@/features/floating-bar';
9+
import { useLeftPanelStore } from '@/features/left-panel';
10+
11+
import LeftPanelIcon from '../assets/left-panel.svg';
12+
13+
export const CollapsePanel = () => {
14+
const { t } = useTranslation();
15+
const { colorsTokens } = useCunninghamTheme();
16+
const { isPanelOpen, togglePanel } = useLeftPanelStore();
17+
const { isDocHeaderVisible } = useFloatingBarStore();
18+
const { currentDoc } = useDocStore();
19+
const { untitledDocument } = useTrans();
20+
21+
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
22+
currentDoc?.title ?? '',
23+
);
24+
const docTitle = titleWithoutEmoji || untitledDocument;
25+
const buttonTitle = emoji ? `${emoji} ${docTitle}` : docTitle;
26+
const shouldShowButtonTitle = !isPanelOpen && !isDocHeaderVisible;
27+
const ariaLabel = t(
28+
isPanelOpen
29+
? 'Hide the side panel for {{title}}'
30+
: 'Show the side panel for {{title}}',
31+
{ title: docTitle },
32+
);
33+
34+
return (
35+
<Box
36+
$css={css`
37+
display: inline-flex;
38+
padding: var(--c--globals--spacings--xxxs);
39+
align-items: center;
40+
gap: var(--c--globals--spacings--xxxs);
41+
border-radius: var(--c--globals--spacings--xs);
42+
border: 1px solid var(--c--contextuals--border--surface--primary);
43+
background: var(--c--contextuals--background--surface--primary);
44+
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
45+
`}
46+
>
47+
<Button
48+
size="small"
49+
onClick={() => togglePanel()}
50+
aria-label={ariaLabel}
51+
aria-expanded={isPanelOpen}
52+
color="neutral"
53+
variant="tertiary"
54+
icon={<LeftPanelIcon width={24} height={24} aria-hidden="true" />}
55+
data-testid="floating-bar-toggle-left-panel"
56+
>
57+
{shouldShowButtonTitle ? (
58+
<Text $size="sm" $weight={700} $color={colorsTokens['gray-1000']}>
59+
{buttonTitle}
60+
</Text>
61+
) : undefined}
62+
</Button>
63+
</Box>
64+
);
65+
};
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { RuleSet, css } from 'styled-components';
2+
3+
import { Box } from '@/components';
4+
import { useCunninghamTheme } from '@/cunningham';
5+
import { useResponsiveStore } from '@/stores';
6+
7+
import { FloatingBarLeft } from './FloatingBarLeft';
8+
9+
export const FLOATING_BAR_HEIGHT = '64px';
10+
export const FLOATING_BAR_Z_INDEX = 1000;
11+
const FLOATING_BAR_BLUR_RADIUS = '1px';
12+
const FLOATING_BAR_GRADIENT =
13+
'linear-gradient(180deg, #FFF 0%, rgba(255, 255, 255, 0) 100%)';
14+
15+
/**
16+
* Sticky bar trick (desktop):
17+
* - MainContent has padding `base`; we extend the bar width and apply
18+
* matching negative margins (mainContentPadding) so it aligns with the
19+
* scroll area edges.
20+
* - `top: calc(-mainContentPadding)` keeps sticky positioning visually
21+
* aligned with the content start.
22+
*
23+
* Mobile: returns null to avoid header overlap.
24+
*/
25+
const getFloatingBarStyles = (
26+
mainContentPadding: string,
27+
barSpacing: string,
28+
blurRadius: string,
29+
): RuleSet => css`
30+
position: sticky;
31+
top: calc(-${mainContentPadding});
32+
left: 0;
33+
right: 0;
34+
width: calc(100% + ${mainContentPadding} + ${mainContentPadding});
35+
min-height: ${FLOATING_BAR_HEIGHT};
36+
padding: ${barSpacing};
37+
margin-left: calc(-${mainContentPadding});
38+
margin-right: calc(-${mainContentPadding});
39+
margin-top: calc(-${mainContentPadding});
40+
z-index: ${FLOATING_BAR_Z_INDEX};
41+
display: flex;
42+
align-items: flex-start;
43+
justify-content: flex-start;
44+
background: ${FLOATING_BAR_GRADIENT};
45+
backdrop-filter: blur(${blurRadius});
46+
-webkit-backdrop-filter: blur(${blurRadius});
47+
48+
> * {
49+
position: relative;
50+
z-index: 1;
51+
}
52+
`;
53+
54+
export const FloatingBar = () => {
55+
const { spacingsTokens } = useCunninghamTheme();
56+
const { isDesktop } = useResponsiveStore();
57+
const mainContentPadding =
58+
spacingsTokens['base'] || 'var(--c--globals--spacings--base)';
59+
const barSpacing = spacingsTokens['sm'] || 'var(--c--globals--spacings--sm)';
60+
61+
if (!isDesktop) {
62+
return null;
63+
}
64+
65+
return (
66+
<Box
67+
data-testid="floating-bar"
68+
$css={getFloatingBarStyles(
69+
mainContentPadding,
70+
barSpacing,
71+
FLOATING_BAR_BLUR_RADIUS,
72+
)}
73+
>
74+
<FloatingBarLeft />
75+
</Box>
76+
);
77+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { CollapsePanel } from './CollapsePanel';
2+
3+
export const FloatingBarLeft = () => {
4+
return <CollapsePanel />;
5+
};

0 commit comments

Comments
 (0)