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 `