diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf16037..7f43fa19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts index b73814b4..93a04094 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -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'); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 11796c65..1d9f5281 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -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( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index 4652f590..71f1594b 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -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, diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index f1ad090e..17de12d2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -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 && } { <> { const { untitledDocument } = useTrans(); return ( - - {currentDoc?.title || untitledDocument} - + + + {currentDoc?.title || untitledDocument} + + ); }; @@ -65,6 +69,7 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => { placement="top" > { + 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 ( + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-header/components/index.ts index b0e56b2d..1163e974 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/index.ts @@ -1,2 +1,3 @@ export * from './DocHeader'; export * from './DocTitle'; +export * from './FloatingBar'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContent.tsx index 309ebc30..a0dba7d6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/TableContent.tsx @@ -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" diff --git a/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx b/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx index 21767a71..e7fa67db 100644 --- a/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx +++ b/src/frontend/apps/impress/src/features/header/components/ButtonTogglePanel.tsx @@ -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 ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx index 02d48a95..6c008ac6 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx @@ -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 diff --git a/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx b/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx index 2b1ca998..193b0bc8 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/ResizableLeftPanel.tsx @@ -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(null); const savedWidthPxRef = useRef(minPanelSizePx); + const previousPanelOpenRef = useRef(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} /> - {children} + + {children} + ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/index.tsx b/src/frontend/apps/impress/src/features/left-panel/components/index.tsx index d146f385..ab07d3d4 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/index.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/index.tsx @@ -1,2 +1,3 @@ export * from './LeftPanel'; +export * from './LeftPanelCollapseButton'; export * from './ResizableLeftPanel'; diff --git a/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx b/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx index 83f1ab09..ad9246fb 100644 --- a/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/stores/useLeftPanelStore.tsx @@ -2,21 +2,30 @@ import { create } from 'zustand'; interface LeftPanelState { isPanelOpen: boolean; + isPanelOpenMobile: boolean; togglePanel: (value?: boolean) => void; closePanel: () => void; } export const useLeftPanelStore = create((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 }); }, })); diff --git a/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx b/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx index 929b76c1..7ef76e8c 100644 --- a/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx +++ b/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx @@ -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(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]); }; diff --git a/src/frontend/apps/impress/src/layouts/MainLayout.tsx b/src/frontend/apps/impress/src/layouts/MainLayout.tsx index 9a295bae..9f591f76 100644 --- a/src/frontend/apps/impress/src/layouts/MainLayout.tsx +++ b/src/frontend/apps/impress/src/layouts/MainLayout.tsx @@ -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; } `} >