diff --git a/e2e/sidebar-expand-no-scrollbar.spec.ts b/e2e/sidebar-expand-no-scrollbar.spec.ts index c8bfe58df..71b8d73e8 100644 --- a/e2e/sidebar-expand-no-scrollbar.spec.ts +++ b/e2e/sidebar-expand-no-scrollbar.spec.ts @@ -86,4 +86,54 @@ test.describe("sidebar width transition", () => { expect(tallest).toBeLessThanOrEqual(restHeight + 1); } }); + + test("on a short window the list scrolls behind a visible edge, never under the footer", async ({ + page, + }) => { + // Short enough that the list cannot fit between the logo band and the + // footer group. The rail used to cut the last visible entry off right at + // the Collapse row with nothing marking the edge, which read as the + // footer covering the entry. + await page.setViewportSize({ width: 1280, height: 600 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const nav = page.locator("aside nav").first(); + const footer = page.locator('aside [data-slot="sidebar-footer"]'); + await expect(nav).toBeVisible(); + await expect(footer).toHaveAttribute("data-nav-overflows", "true"); + const borderTop = await footer.evaluate( + (el) => getComputedStyle(el).borderTopWidth, + ); + expect(borderTop).toBe("1px"); + + // Scrolled to its end, the last entry sits wholly inside the list, + // above the footer, and is the element under its own centre. + const end = await nav.evaluate((el) => { + el.scrollTop = el.scrollHeight; + const links = [...el.querySelectorAll("a")]; + const last = links[links.length - 1] as HTMLElement; + const r = last.getBoundingClientRect(); + const navBox = el.getBoundingClientRect(); + const footerTop = ( + document.querySelector('aside [data-slot="sidebar-footer"]') as Element + ).getBoundingClientRect().top; + const hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return { + insideList: r.bottom <= navBox.bottom + 0.5, + aboveFooter: r.bottom <= footerTop + 0.5, + hitsItself: last.contains(hit), + }; + }); + expect(end).toEqual({ + insideList: true, + aboveFooter: true, + hitsItself: true, + }); + + // With room to spare there is no edge: the default rail is unchanged. + await page.setViewportSize({ width: 1280, height: 1400 }); + await expect(footer).not.toHaveAttribute("data-nav-overflows", "true"); + }); }); diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 1e8fe6969..033882f4d 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Bell, LogOut, @@ -310,6 +310,27 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { ); } +/** + * Whether the nav list is taller than the room the rail leaves it, i.e. + * whether it scrolls. Re-measured when the nav (viewport height) or the list + * (entries arriving as modules resolve) changes size. + */ +function useNavOverflows(navRef: React.RefObject): boolean { + const [overflows, setOverflows] = useState(false); + useEffect(() => { + const nav = navRef.current; + if (!nav || typeof ResizeObserver === "undefined") return; + // Strict: any overflow, the same test that gives the list a scrollbar. + const update = () => setOverflows(nav.scrollHeight > nav.clientHeight); + const observer = new ResizeObserver(update); + observer.observe(nav); + if (nav.firstElementChild) observer.observe(nav.firstElementChild); + update(); + return () => observer.disconnect(); + }, [navRef]); + return overflows; +} + /** * The sidebar's collapse / expand control, drawn as a footer nav row. * @@ -426,6 +447,9 @@ export function SidebarNav() { ); const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; + const navRef = useRef(null); + const navOverflows = useNavOverflows(navRef); + function toggleCollapsed() { const next = !collapsed; setCollapsedPref(next); @@ -611,6 +635,7 @@ export function SidebarNav() {