mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
23961d2280
commit
fa48110875
2 files changed
+91
-2
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user