fix(nav): mark the scroll edge when the sidebar list overflows

On a short window (around 785 px and below) the nav list is taller than
the room between the logo band and the footer group, so it scrolls. With
nothing marking the edge, the last visible entry was cut off right at
the Collapse row and read as the footer covering it; macOS hides the
scrollbar that would have said otherwise.

While the list overflows, the footer group draws the same hairline the
user section does. A ResizeObserver on the nav and its list keeps the
state current as the window or the entries change; with room to spare
the rail is unchanged. The e2e spec checks the edge, that the last entry
scrolls fully clear of the footer, and that a tall window draws no edge.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 16:24:37 +02:00
1 parent 23961d2280
commit fa48110875
2 files changed
+91 -2

No files matched your search

+50
View File
@@ -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");
});
});
+41 -2
View File
@@ -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<HTMLElement | null>): 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<HTMLElement | null>(null);
const navOverflows = useNavOverflows(navRef);
function toggleCollapsed() {
const next = !collapsed;
setCollapsedPref(next);
@@ -611,6 +635,7 @@ export function SidebarNav() {
</div>
<nav
ref={navRef}
aria-label={t("nav.mainNavigation")}
className={cn(
"flex-1 overflow-y-auto",
@@ -679,7 +704,21 @@ export function SidebarNav() {
role-gated Admin entry inserted before Settings. The collapse
control heads the group, so it sits directly above Admin for an
administrator and directly above Settings for everyone else. */}
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
{/* On a short window the list scrolls under this group. Without an
edge the last visible row ran straight into the Collapse row,
cut off mid-entry, and read as the footer covering it (macOS
hides the scrollbar that would have said otherwise). While the
list overflows, the group draws the same hairline the user
section does, so the boundary reads as a scroll edge. */}
<div
data-slot="sidebar-footer"
data-nav-overflows={navOverflows ? "true" : undefined}
className={cn(
"space-y-1 pb-1",
collapsed ? "px-1.5" : "px-3",
navOverflows && "border-sidebar-border border-t pt-1",
)}
>
<SidebarCollapseToggle
collapsed={collapsed}
onToggle={toggleCollapsed}