diff --git a/e2e/sidebar-expand-no-scrollbar.spec.ts b/e2e/sidebar-expand-no-scrollbar.spec.ts
index 71b8d73e8..cb136e1df 100644
--- a/e2e/sidebar-expand-no-scrollbar.spec.ts
+++ b/e2e/sidebar-expand-no-scrollbar.spec.ts
@@ -133,7 +133,43 @@ test.describe("sidebar width transition", () => {
});
// With room to spare there is no edge: the default rail is unchanged.
+ // No native scrollbar in the rail (a classic one with arrow buttons
+ // read as a foreign body); a fade marks the edge that has more.
+ expect(
+ await nav.evaluate((el) => getComputedStyle(el).scrollbarWidth),
+ ).toBe("none");
+ expect(
+ await nav.evaluate(
+ (el) => (el as HTMLElement).offsetWidth - el.clientWidth,
+ ),
+ ).toBe(0);
+ await expect(nav).toHaveAttribute("data-fade-top", "true");
+ await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
+ await nav.evaluate((el) => {
+ el.scrollTop = 0;
+ });
+ await expect(nav).toHaveAttribute("data-fade-bottom", "true");
+ await expect(nav).not.toHaveAttribute("data-fade-top", "true");
+
await page.setViewportSize({ width: 1280, height: 1400 });
await expect(footer).not.toHaveAttribute("data-nav-overflows", "true");
+ await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
+ });
+
+ test("a 1366 × 768 window fits the rail without scrolling, collapsed and expanded", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1366, height: 768 });
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ const nav = page.locator("aside nav").first();
+ const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]');
+ await expect(nav).toBeVisible();
+ for (let i = 0; i < 2; i++) {
+ await expect
+ .poll(() => nav.evaluate((el) => el.scrollHeight - el.clientHeight))
+ .toBeLessThanOrEqual(0);
+ await toggle.click();
+ await page.waitForTimeout(300);
+ }
});
});
diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx
index 033882f4d..2e2bd4af9 100644
--- a/src/components/layout/sidebar-nav.tsx
+++ b/src/components/layout/sidebar-nav.tsx
@@ -248,7 +248,12 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
if (collapsed) {
return (
-
+
): boolean {
- const [overflows, setOverflows] = useState(false);
+// Disjoint height ranges, not nested max-heights: two arbitrary media
+// variants on one property cascade in stylesheet order, not by how narrow
+// the query is, so a nested pair let the 36 px step win on a 720 px window.
+const RAIL_ROW_HEIGHT =
+ "min-h-10 [@media(740px): {
+ overflows: boolean;
+ moreAbove: boolean;
+ moreBelow: boolean;
+} {
+ const [edges, setEdges] = useState({
+ overflows: false,
+ moreAbove: false,
+ moreBelow: 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);
+ // Strict: any overflow, the same test that makes the list scroll.
+ const update = () => {
+ const overflows = nav.scrollHeight > nav.clientHeight;
+ const moreAbove = overflows && nav.scrollTop > 0.5;
+ const moreBelow =
+ overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5;
+ setEdges((prev) =>
+ prev.overflows === overflows &&
+ prev.moreAbove === moreAbove &&
+ prev.moreBelow === moreBelow
+ ? prev
+ : { overflows, moreAbove, moreBelow },
+ );
+ };
const observer = new ResizeObserver(update);
observer.observe(nav);
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
+ nav.addEventListener("scroll", update, { passive: true });
update();
- return () => observer.disconnect();
+ return () => {
+ observer.disconnect();
+ nav.removeEventListener("scroll", update);
+ };
}, [navRef]);
- return overflows;
+ return edges;
}
/**
@@ -361,8 +443,8 @@ export function SidebarCollapseToggle({
aria-expanded={!collapsed}
data-slot="sidebar-collapse-toggle"
className={cn(
- "text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 flex w-full items-center rounded-lg text-sm font-medium transition-colors outline-none focus-visible:ring-[3px]",
- collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5",
+ railRowClass(collapsed),
+ "text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 w-full text-sm font-medium outline-none focus-visible:ring-[3px]",
)}
>
@@ -448,7 +530,11 @@ export function SidebarNav() {
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
const navRef = useRef(null);
- const navOverflows = useNavOverflows(navRef);
+ const {
+ overflows: navOverflows,
+ moreAbove: navMoreAbove,
+ moreBelow: navMoreBelow,
+ } = useNavOverflows(navRef);
function toggleCollapsed() {
const next = !collapsed;
@@ -504,7 +590,7 @@ export function SidebarNav() {
data-tour-id={tourId}
data-slot={isSettings ? "nav-settings-link" : undefined}
className={cn(
- "flex items-center justify-center rounded-lg p-2.5 transition-colors",
+ railRowClass(true),
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
@@ -527,7 +613,7 @@ export function SidebarNav() {
data-tour-id={tourId}
data-slot={isSettings ? "nav-settings-link" : undefined}
className={cn(
- "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
+ railRowClass(false),
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
@@ -549,7 +635,7 @@ export function SidebarNav() {
href="/admin"
aria-current={onAdminPage ? "page" : undefined}
className={cn(
- "flex items-center justify-center rounded-lg p-2.5 transition-colors",
+ railRowClass(true),
onAdminPage
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
@@ -569,7 +655,7 @@ export function SidebarNav() {
href="/admin"
aria-current={onAdminPage ? "page" : undefined}
className={cn(
- "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
+ railRowClass(false),
onAdminPage
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
@@ -637,12 +723,23 @@ export function SidebarNav() {