From 6721ce841e5dee0c0b824fb16fd88d49a4c6a1d0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:13:45 +0200 Subject: [PATCH] fix(client): Open the mobile drawer below a site banner The drawer was fixed to the viewport, so a configured site banner, which sits above the app and is sticky over it, painted over the drawer's header strip and left its close toggle, panel switcher and New chat untappable. The drawer is now absolute inside the app container, as the scrim and the pane already are, so it opens below the banner. Without a banner the container starts at the top of the screen and nothing moves. --- .../UnifiedSidebar/UnifiedSidebar.tsx | 8 +- .../scenarios/mobile-drawer-banner.spec.ts | 117 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 e2e/specs/mock/scenarios/mobile-drawer-banner.spec.ts diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx index 0b2bd54f9ea..a41e8bddf8d 100644 --- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx +++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx @@ -204,8 +204,12 @@ function UnifiedSidebar({ className={cn( /** The close swipe reads horizontal touches here (the drawer holds no * horizontal scrollers), while pinch-zoom stays with the browser: - * this full-viewport surface must not disable zooming entirely. */ - 'bg-surface-primary-alt text-text-primary fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col', + * this full-height surface must not disable zooming entirely. + * Absolute in the app container rather than fixed to the viewport, so + * a site banner above that container stays above the drawer too + * instead of covering its header strip, as it already does for the + * scrim and the pane. */ + 'bg-surface-primary-alt text-text-primary absolute inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col', /** In dark mode the scrim and the drawer are both near-black, and no * scrim opacity separates them by 3:1, so the drawer draws its own * edge. Light palettes get that separation from the scrim. */ diff --git a/e2e/specs/mock/scenarios/mobile-drawer-banner.spec.ts b/e2e/specs/mock/scenarios/mobile-drawer-banner.spec.ts new file mode 100644 index 00000000000..12c7efe489e --- /dev/null +++ b/e2e/specs/mock/scenarios/mobile-drawer-banner.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { NEW_CHAT_PATH } from '../helpers'; + +/** + * A site banner sits above the app and stays put while the drawer opens. The drawer opens inside + * the app below it, as the scrim and the conversation pane already do, so the banner never covers + * the drawer's header strip: the close toggle, the panel switcher, New chat and the account menu + * stay visible and tappable. + * + * Every scenario loads at phone width rather than resizing into it (berry-13/LibreChat#205). + */ + +const DRAWER = '#mobile-drawer'; +const BANNER_TEXT = 'Scheduled maintenance tonight'; + +type Mode = 'light' | 'dark'; + +test.use({ viewport: { width: 390, height: 844 } }); + +async function openDrawer(page: Page, mode: Mode, withBanner: boolean) { + await page.addInitScript((appearance) => { + localStorage.setItem('color-theme', appearance); + localStorage.removeItem('theme-definition'); + localStorage.removeItem('theme-source'); + localStorage.removeItem('hideBannerHint'); + }, mode); + /** The banner is the deployment's; this page alone is served one. */ + await page.route('**/api/banner', (route) => + route.fulfill({ + json: withBanner + ? { + bannerId: 'e2e-drawer-banner', + message: `
${BANNER_TEXT}
`, + displayFrom: new Date(Date.now() - 60_000).toISOString(), + displayTo: new Date(Date.now() + 3_600_000).toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + isPublic: false, + persistable: true, + } + : null, + }), + ); + await page.goto(NEW_CHAT_PATH, { timeout: 15000 }); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible({ + timeout: 30000, + }); + await expect(page.locator('html')).toHaveClass(mode === 'dark' ? /\bdark\b/ : /\blight\b/); + if (withBanner) { + await expect(page.getByText(BANNER_TEXT)).toBeVisible(); + } + + const drawer = page.locator(DRAWER); + if (!(await drawer.getByTestId('close-sidebar-button').isVisible())) { + await page.getByTestId('header-open-sidebar-button').click(); + } + await expect(drawer.getByTestId('close-sidebar-button')).toBeVisible(); + await expect(drawer).not.toHaveAttribute('inert'); + await expect.poll(async () => (await drawer.boundingBox())?.x ?? -1, { timeout: 5000 }).toBe(0); + return drawer; +} + +/** The element a tap at the center of `selector` would land on, and whether it is that control. */ +async function tapTarget(page: Page, selector: string) { + return page.evaluate((target) => { + const node = document.querySelector(target); + if (!node) { + throw new Error(`${target} is not rendered`); + } + const box = node.getBoundingClientRect(); + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); + return node === hit || node.contains(hit); + }, selector); +} + +for (const mode of ['light', 'dark'] as const) { + const tag = + mode === 'light' + ? '@scenario:mobile-drawer-header-below-banner-light' + : '@scenario:mobile-drawer-header-below-banner-dark'; + test(`the drawer opens below a site banner and its header strip stays tappable (${mode}) ${tag}`, async ({ + page, + }) => { + const drawer = await openDrawer(page, mode, true); + const banner = await page.getByText(BANNER_TEXT).boundingBox(); + const drawerBox = await drawer.boundingBox(); + expect(banner && drawerBox).toBeTruthy(); + /** Below the banner and down to the bottom of the screen. */ + expect(drawerBox!.y).toBeGreaterThanOrEqual(banner!.y + banner!.height - 1); + expect(drawerBox!.y + drawerBox!.height).toBeCloseTo(844, 0); + await expect(page.getByText(BANNER_TEXT)).toBeVisible(); + + for (const selector of [ + `${DRAWER} [data-testid="close-sidebar-button"]`, + `${DRAWER} [data-testid="panel-switcher-button"]`, + `${DRAWER} [data-testid="nav-new-chat-fab"]`, + ]) { + expect(await tapTarget(page, selector), `${selector} is covered`).toBe(true); + } + + await drawer.getByTestId('close-sidebar-button').click(); + await expect(drawer).toHaveAttribute('inert', ''); + }); +} + +/** The tags are written out whole above; the runner finds a scenario by its literal tag. */ +test('without a banner the drawer still covers the whole screen height @scenario:mobile-drawer-full-height-without-banner', async ({ + page, +}) => { + const drawer = await openDrawer(page, 'light', false); + const box = await drawer.boundingBox(); + expect(box).toBeTruthy(); + expect(box!.y).toBe(0); + expect(box!.height).toBeCloseTo(844, 0); + expect(await tapTarget(page, `${DRAWER} [data-testid="close-sidebar-button"]`)).toBe(true); +});