From 1f4c1bd2f04245c06d0f0a230deff0bd18134085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:48 +0200 Subject: [PATCH] fix(rail): keep every destination reachable under a finger A touch tablet keeps the rail's 44 px rows, so in landscape three to seven destinations sit below the fold, and the list has no scrollbar at rest. The edge fade alone did not read as "swipe here", and a swipe that reached an end chained to the document, which held the next one. The list region moves into its own module. Under a coarse pointer it contains its overscroll, keeps a focused row clear of a new scroll cue, and shows that cue at an edge with more destinations; a tap scrolls the list most of a screen on. The cue stays out of the focus order and the accessibility tree: the keyboard and screen readers already reach every row. The rail takes the home indicator's inset so the account row stays clear of it. A mouse sees no change. --- .../layout/__tests__/sidebar-nav.test.tsx | 62 ++++++ src/components/layout/sidebar-nav-scroll.tsx | 192 ++++++++++++++++++ src/components/layout/sidebar-nav.tsx | 103 ++-------- 3 files changed, 268 insertions(+), 89 deletions(-) create mode 100644 src/components/layout/sidebar-nav-scroll.tsx diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 88809bc95..b123458a2 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -63,6 +63,7 @@ import { readSidebarCollapsedPref, writeSidebarCollapsedPref, } from "../sidebar-nav"; +import { RAIL_NAV_SCROLL, RailScrollCue } from "../sidebar-nav-scroll"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; import { visibleUtilityDestinations } from "../nav-model"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; @@ -577,3 +578,64 @@ describe(" carries no rules for a viewport it never shows on", () => expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/); }); }); + +describe(" keeps every destination reachable on a touch tablet", () => { + function navClass(html: string): string { + return ( + /]*aria-label="[^"]*"[^>]*class="([^"]*)"/.exec(html)?.[1] ?? "" + ); + } + + it("scrolls the list inside the rail and keeps the swipe there", () => { + const nav = navClass(render()).replaceAll("&", "&").split(" "); + expect(RAIL_NAV_SCROLL.split(" ")).toEqual( + expect.arrayContaining(["min-h-0", "flex-1", "overflow-y-auto"]), + ); + for (const cls of RAIL_NAV_SCROLL.split(" ")) expect(nav).toContain(cls); + // Without it a swipe at either end chained to the document on iOS. + expect(nav).toContain("pointer-coarse:overscroll-contain"); + // A focused row lands clear of the 44 px cue under a finger. + expect(nav).toContain("pointer-coarse:scroll-py-12"); + }); + + it("gives the rail the home indicator's inset, so the avatar row stays clear", () => { + const aside = /]*class="([^"]*)"/.exec(render())?.[1] ?? ""; + expect(aside.split(" ")).toContain("pb-[env(safe-area-inset-bottom,0px)]"); + }); + + it("draws the scroll cue for a finger only, outside the focus order and the accessibility tree", () => { + for (const direction of ["up", "down"] as const) { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="sidebar-nav-scroll-cue"'); + expect(html).toContain(`data-direction="${direction}"`); + expect(html).toContain('tabindex="-1"'); + expect(html).toContain('aria-hidden="true"'); + const cls = /class="([^"]*)"/.exec(html)![1].split(" "); + expect(cls).toEqual( + expect.arrayContaining(["hidden", "pointer-coarse:flex"]), + ); + // 44 px touch floor. + expect(cls).toContain("h-11"); + } + }); + + it("scrolls the list most of a screen on per tap", () => { + const calls: ScrollToOptions[] = []; + const nav = { + clientHeight: 400, + scrollBy: (o: ScrollToOptions) => calls.push(o), + } as unknown as HTMLElement; + vi.stubGlobal("window", { + matchMedia: () => ({ matches: true }), + }); + const element = RailScrollCue({ + direction: "down", + navRef: { current: nav }, + }) as React.ReactElement<{ onClick: () => void }>; + element.props.onClick(); + vi.unstubAllGlobals(); + expect(calls).toEqual([{ top: 300, behavior: "auto" }]); + }); +}); diff --git a/src/components/layout/sidebar-nav-scroll.tsx b/src/components/layout/sidebar-nav-scroll.tsx new file mode 100644 index 000000000..262d75a44 --- /dev/null +++ b/src/components/layout/sidebar-nav-scroll.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * The rail's destination list as a scroll region: the one part of the rail + * that gives up height, between the pinned logo band above and the pinned + * footer group and account row below (`sidebar-nav.tsx`). + */ + +/** + * The nav list's scroll region. `scroll-py-6` keeps a focused row clear of + * the edge fades; under a finger `scroll-py-12` keeps it clear of the scroll + * cue as well, which is 44 px tall. `overscroll-contain` is the own-scroll + * list rule (ui-standards §2), taken under a finger, where the swipe that + * reaches an end is the problem. A mouse or trackpad has nothing to chain + * to (the app shell's document neither scrolls nor bounces), and on Chromium + * the property moves the list into a layer of its own, which re-rasterised + * every label in the rail by a shade. + */ +export const RAIL_NAV_SCROLL = + "min-h-0 flex-1 scroll-py-6 pointer-coarse:scroll-py-12 overflow-y-auto pointer-coarse:overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"; + +/** + * The scroll cue at an edge of the nav list that has more destinations + * beyond it, under a finger only. + * + * A touch tablet keeps the 44 px rows, so in landscape (an iPad is 670 to + * 830 px tall in Safari) three to seven destinations sit below the fold, and + * the list has no scrollbar: iOS paints none on an element at rest. The fade + * alone did not read as "swipe here", and the rail looked as if it simply + * ended. The cue says so and is a way there in itself: a tap scrolls the + * list most of a screen on. + * + * It is a pointer shortcut and nothing else. The keyboard reaches every row + * by Tab (focus scrolls the row into view) and a screen reader reads the + * list whole, so the cue stays out of the focus order and the accessibility + * tree. A mouse scrolls with the wheel and never sees it. + */ +export function RailScrollCue({ + direction, + navRef, +}: { + direction: "up" | "down"; + navRef: React.RefObject; +}) { + const Icon = direction === "down" ? ChevronDown : ChevronUp; + return ( + + ); +} + +/** 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. + */ +export function useNavOverflows(navRef: React.RefObject): { + 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 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(); + nav.removeEventListener("scroll", update); + }; + }, [navRef]); + return edges; +} + +/** + * The list's region: the `