mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Fit the sidebar rail to short windows and hide its native scrollbar
On a 13-inch laptop the rail's list overflowed and a classic scrollbar with arrow buttons appeared inside it. The rows now step down by window height (gaps close at 940 px, 36 px rows at 820 px, 32 px at 740 px), so 1366 x 768 fits every entry collapsed and expanded. Where the list still scrolls, the native scrollbar is hidden, fades mark the edge that has more, and a focused row scrolls clear of the fades.
This commit is contained in:
1 parent
ce6d2fe72a
commit
7493ec48e4
2 files changed
+156
-22
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -248,7 +248,12 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"border-sidebar-border border-t p-3",
|
||||
"[@media(max-height:940px)]:p-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -311,24 +316,101 @@ 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.
|
||||
* Height-based density for the rail. A 13-inch laptop at 100 % zoom leaves
|
||||
* the browser roughly 700–860 px, and the full destination list plus the
|
||||
* footer rows did not fit: the list scrolled, and on a classic-scrollbar
|
||||
* platform (Windows) the rail grew a native scrollbar with arrow buttons.
|
||||
* On short windows the rows close their gaps first, then step their height
|
||||
* down; tall windows keep the roomy default. Desktop-only by construction —
|
||||
* the rail is hidden below `md`, so the phone layout never sees these steps.
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
*/
|
||||
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): 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<height<=820px)]:min-h-9 [@media(height<=740px)]:min-h-8";
|
||||
const RAIL_LIST_GAP = "space-y-1 [@media(max-height:940px)]:space-y-0";
|
||||
const RAIL_SHORT_PY = "[@media(max-height:940px)]:py-1.5";
|
||||
|
||||
/** Rail row, collapsed (icon only) or expanded (icon + label). */
|
||||
function railRowClass(collapsed: boolean): string {
|
||||
return cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
RAIL_ROW_HEIGHT,
|
||||
collapsed
|
||||
? "justify-center px-2.5"
|
||||
: "gap-3 px-3 py-1.5 text-sm font-medium",
|
||||
);
|
||||
}
|
||||
|
||||
/** Height of the fade at a scroll edge that has more content beyond it. */
|
||||
const RAIL_FADE = "1.5rem";
|
||||
|
||||
/**
|
||||
* The fade mask for the list's scroll edges: a top fade while there is
|
||||
* content above, a bottom fade while there is content below. A mask paints
|
||||
* alpha only, so it reads the same in light and dark and never moves layout.
|
||||
*/
|
||||
function railFadeMask(
|
||||
top: boolean,
|
||||
bottom: boolean,
|
||||
): React.CSSProperties | undefined {
|
||||
if (!top && !bottom) return undefined;
|
||||
const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`;
|
||||
return { maskImage: image, WebkitMaskImage: image };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the nav list is taller than the room the rail leaves it, i.e.
|
||||
* whether it scrolls, and which of its edges have more content beyond them.
|
||||
* Re-measured when the nav (viewport height) or the list (entries arriving
|
||||
* as modules resolve) changes size, and on scroll.
|
||||
*/
|
||||
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): {
|
||||
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]",
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||
@@ -448,7 +530,11 @@ export function SidebarNav() {
|
||||
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
|
||||
|
||||
const navRef = useRef<HTMLElement | null>(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() {
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={t("nav.mainNavigation")}
|
||||
// The list scrolls without a native scrollbar (a classic
|
||||
// scrollbar with arrow buttons read as a foreign body in the
|
||||
// rail); fades at the edges say there is more, and focus keeps
|
||||
// the focused row clear of them.
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
"flex-1 scroll-py-6 [scrollbar-width:none] overflow-y-auto [&::-webkit-scrollbar]:hidden",
|
||||
collapsed ? "p-1.5" : "p-3",
|
||||
RAIL_SHORT_PY,
|
||||
)}
|
||||
style={railFadeMask(navMoreAbove, navMoreBelow)}
|
||||
data-fade-top={navMoreAbove ? "true" : undefined}
|
||||
data-fade-bottom={navMoreBelow ? "true" : undefined}
|
||||
onFocus={(event) => {
|
||||
event.target.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className={RAIL_LIST_GAP}>
|
||||
{visibleNavItems.map((item) => {
|
||||
const isActive = isNavDestinationActive(
|
||||
item.href,
|
||||
@@ -661,7 +758,7 @@ export function SidebarNav() {
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
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",
|
||||
@@ -685,7 +782,7 @@ export function SidebarNav() {
|
||||
data-tour-id={item.tourId}
|
||||
{...(item.href === "/medications" ? medsIntent : {})}
|
||||
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",
|
||||
@@ -714,7 +811,8 @@ export function SidebarNav() {
|
||||
data-slot="sidebar-footer"
|
||||
data-nav-overflows={navOverflows ? "true" : undefined}
|
||||
className={cn(
|
||||
"space-y-1 pb-1",
|
||||
RAIL_LIST_GAP,
|
||||
"pb-1",
|
||||
collapsed ? "px-1.5" : "px-3",
|
||||
navOverflows && "border-sidebar-border border-t pt-1",
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user