mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-server
# Conflicts: # src/__tests__/baselines/source-function-lines.json
This commit is contained in:
commit
f55a86a08c
20 files changed
+1004
-140
No files matched your search
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The authenticated shell's geometry decisions, which no browser under test
|
||||
* can show going wrong: Playwright's WebKit and Chromium have no floating
|
||||
* toolbar, no rotation and no home indicator, so `100dvh` always equals the
|
||||
* window there. On an iPad it does not, and that disagreement was the strip
|
||||
* under the shell and the cut-off card. These hold the decisions that take
|
||||
* the disagreement out; `e2e/tablet-shell.spec.ts` measures the result.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import {
|
||||
MAIN_SCROLL_CLASS,
|
||||
SHELL_ROOT_CLASS,
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
} from "../shell-metrics";
|
||||
|
||||
const shellSource = () =>
|
||||
readSource("src/components/layout/auth-shell.tsx", [
|
||||
"SHELL_ROOT_CLASS",
|
||||
'data-slot="main-content-wrapper"',
|
||||
]);
|
||||
/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */
|
||||
const MAIN_CONTENT_WRAPPER_CLASS =
|
||||
/"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1];
|
||||
const classes = (value: string) => value.split(/\s+/).filter(Boolean);
|
||||
|
||||
/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */
|
||||
function rem(token: string): number {
|
||||
const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token);
|
||||
if (calc) return Number(calc[1]);
|
||||
const step = /-(\d+(?:\.\d+)?)$/.exec(token);
|
||||
if (step) return Number(step[1]) / 4;
|
||||
throw new Error(`no length in ${token}`);
|
||||
}
|
||||
|
||||
describe("the shell takes the window's edges", () => {
|
||||
it("takes its height from the initial containing block, never from a viewport unit", () => {
|
||||
const shell = classes(SHELL_ROOT_CLASS);
|
||||
expect(shell).toEqual(
|
||||
expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]),
|
||||
);
|
||||
// `fixed` re-rasterises the whole app in its own layer and traps the
|
||||
// bottom bar's z-index in a stacking context.
|
||||
expect(shell).not.toContain("fixed");
|
||||
expect(SHELL_ROOT_CLASS).not.toMatch(
|
||||
/\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/,
|
||||
);
|
||||
expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/);
|
||||
});
|
||||
|
||||
it("AuthShell renders that box and flags the document for it", () => {
|
||||
const source = shellSource();
|
||||
expect(source).toContain("<div className={SHELL_ROOT_CLASS}>");
|
||||
expect(source).not.toMatch(/shell-safe-area flex h-dvh/);
|
||||
expect(source).toMatch(
|
||||
/useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/,
|
||||
);
|
||||
const css = readSource("src/app/globals.css", '[data-scroll="shell"]');
|
||||
expect(css).toMatch(
|
||||
/html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bottom edge is cleared in both shells", () => {
|
||||
const fab = readSource(
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
'data-slot="coach-fab"',
|
||||
);
|
||||
const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]);
|
||||
const phoneFabBottom = rem(
|
||||
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const desktopFabBottom = rem(
|
||||
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const wrapper = classes(MAIN_CONTENT_WRAPPER_CLASS);
|
||||
const main = classes(MAIN_SCROLL_CLASS);
|
||||
|
||||
it("the desktop shell's main keeps the home indicator off the last line", () => {
|
||||
expect(main).not.toContain("shell-desktop:pb-0");
|
||||
expect(main).toContain(
|
||||
"shell-desktop:pb-[env(safe-area-inset-bottom,0px)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("the last line scrolls clear of the Coach button in the phone shell", () => {
|
||||
const mainPad = Number(
|
||||
/^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec(
|
||||
main.find((c) => c.startsWith("pb-[calc("))!,
|
||||
)![1],
|
||||
);
|
||||
const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!);
|
||||
expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize);
|
||||
});
|
||||
|
||||
it("the last line scrolls clear of the Coach button in the desktop shell", () => {
|
||||
const wrapperPad = rem(
|
||||
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
|
||||
);
|
||||
expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize);
|
||||
});
|
||||
|
||||
it("the sub-shell floor follows the desktop shell's padding", () => {
|
||||
// Top bar 4rem + pt-6 + the wrapper's bottom padding.
|
||||
const desktopPad = rem(
|
||||
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
|
||||
);
|
||||
expect(SUB_SHELL_GRID_FLOOR).toContain(
|
||||
`shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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("<SidebarNav> carries no rules for a viewport it never shows on", () =>
|
||||
expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> keeps every destination reachable on a touch tablet", () => {
|
||||
function navClass(html: string): string {
|
||||
return (
|
||||
/<nav[^>]*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 = /<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(
|
||||
<RailScrollCue direction={direction} navRef={{ current: null }} />,
|
||||
);
|
||||
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" }]);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from "@/components/layout/module-page-gate";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isFullBleedPage } from "./full-bleed";
|
||||
import { useDocumentScrollMode } from "./document-scroll-mode";
|
||||
import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics";
|
||||
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
|
||||
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
@@ -166,25 +168,18 @@ export function AuthShell({
|
||||
const isOnboardingPage = isOnboardingPathname(pathname);
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
|
||||
// only for the body-scrolled shells (login / register / onboarding /
|
||||
// standalone legal pages). The authenticated branch is height-locked and
|
||||
// scrolls inside its own `<main>`, which reserves its own gutter; applying
|
||||
// the document gutter there too produced a second, never-painted gutter on
|
||||
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
|
||||
// so the CSS rule scopes to them.
|
||||
// Who scrolls the page, on `<html>` (`document-scroll-mode.ts`).
|
||||
const isBodyScrolled = isPublicPage || isOnboardingPage;
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isBodyScrolled) {
|
||||
root.setAttribute("data-scroll", "body");
|
||||
} else {
|
||||
root.removeAttribute("data-scroll");
|
||||
}
|
||||
return () => {
|
||||
root.removeAttribute("data-scroll");
|
||||
};
|
||||
}, [isBodyScrolled]);
|
||||
const rendersAppShell =
|
||||
!isBodyScrolled &&
|
||||
!accessRefused &&
|
||||
!isLoading &&
|
||||
isAuthenticated &&
|
||||
recordSessionTransition.phase === "ready" &&
|
||||
!recordSessionPending;
|
||||
useDocumentScrollMode(
|
||||
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
|
||||
);
|
||||
|
||||
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
|
||||
// mounted across every route change, so its scroll offset survives
|
||||
@@ -438,9 +433,13 @@ export function AuthShell({
|
||||
five pixels of content box and pushed the logo and the account menu
|
||||
across its bottom border. `shell-safe-area` paints the strip behind
|
||||
the status bar in the chrome colour (globals.css). The bottom inset
|
||||
stays with the bottom nav and `<main>`, which own that edge.
|
||||
stays with the bottom nav, `<main>` and the rail, which own that edge.
|
||||
|
||||
The shell takes the window's edges rather than `h-dvh`, and the
|
||||
document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in
|
||||
`shell-metrics.ts` says why.
|
||||
*/}
|
||||
<div className="shell-safe-area flex h-dvh flex-col">
|
||||
<div className={SHELL_ROOT_CLASS}>
|
||||
{/*
|
||||
v1.4.43 — `<OfflineBanner>` paints only when
|
||||
`navigator.onLine === false`. Sits above the maintainership
|
||||
@@ -500,7 +499,7 @@ export function AuthShell({
|
||||
// for all of them at once instead of asking each new
|
||||
// absolutely-positioned child to remember its own wrapper.
|
||||
className={cn(
|
||||
"shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
|
||||
MAIN_SCROLL_CLASS,
|
||||
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
|
||||
)}
|
||||
>
|
||||
@@ -516,7 +515,8 @@ export function AuthShell({
|
||||
v1.16.8 — the bottom padding clears the always-on Coach
|
||||
FAB (48 px button + its offset above the bottom-nav band /
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
can always scroll out from under the floating button; the
|
||||
desktop shell's sits 5.5rem up, so `pb-24` there.
|
||||
*/}
|
||||
<div
|
||||
data-slot="main-content-wrapper"
|
||||
@@ -524,7 +524,7 @@ export function AuthShell({
|
||||
className={
|
||||
fullBleed
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
: "shell-desktop:pb-24 mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
}
|
||||
>
|
||||
{outsideSharedRecord ? (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Who scrolls the page, published on `<html>` as `data-scroll` for the rules
|
||||
* in `globals.css`.
|
||||
*
|
||||
* - `"body"`: the body-scrolled shells (login / register / onboarding /
|
||||
* standalone legal pages), where the document itself overflows. They get
|
||||
* the document-level `scrollbar-gutter`. The authenticated branch
|
||||
* scrolls inside its own `<main>`, which reserves its own gutter; the
|
||||
* document gutter there too produced a second, never-painted gutter on
|
||||
* classic-scrollbar platforms.
|
||||
* - `"shell"`: the app shell. It takes the window's edges and `<main>` is its
|
||||
* one vertical scroller, so the document gets no scroll and no rubber band
|
||||
* of its own. On iOS a bounce of the document held the next swipe, and the
|
||||
* list under the finger did not move.
|
||||
* - `null`: anything else (the refusal panel, the loading states) keeps the
|
||||
* browser's defaults.
|
||||
*/
|
||||
export type DocumentScrollMode = "body" | "shell" | null;
|
||||
|
||||
export function useDocumentScrollMode(mode: DocumentScrollMode): void {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (mode) {
|
||||
root.setAttribute("data-scroll", mode);
|
||||
} else {
|
||||
root.removeAttribute("data-scroll");
|
||||
}
|
||||
return () => {
|
||||
root.removeAttribute("data-scroll");
|
||||
};
|
||||
}, [mode]);
|
||||
}
|
||||
@@ -18,15 +18,51 @@
|
||||
* never drift — Admin previously carried a stale, all-breakpoint
|
||||
* `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires.
|
||||
*
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20`
|
||||
* (5rem) = 10.5rem. Retune here — and only here — if those shell paddings
|
||||
* change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom
|
||||
* padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) =
|
||||
* 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here —
|
||||
* and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* a locale/offline/demo banner or an iOS PWA safe-area inset makes it
|
||||
* over-reserve. A fully self-accounting `min-h-full` chain would need the
|
||||
* AuthShell content wrapper to pass a definite height through, which is out of
|
||||
* scope for the shell-only over-scroll fix.)
|
||||
*/
|
||||
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
|
||||
export const SUB_SHELL_GRID_FLOOR =
|
||||
"md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]";
|
||||
|
||||
/**
|
||||
* The authenticated shell's outer box. It takes the window's edges
|
||||
* (`absolute inset-0` against the initial containing block) rather than
|
||||
* `h-dvh`, and the top and side safe-area insets (`shell-safe-area`,
|
||||
* globals.css); the bottom inset belongs to whatever owns the bottom edge in
|
||||
* each shell.
|
||||
*
|
||||
* Not a viewport unit: `h-dvh` is the browser's idea of the visible height,
|
||||
* and on iPadOS Safari that idea and the area it lays out disagree after a
|
||||
* rotation and under the floating toolbars. A short value left a strip of
|
||||
* page colour under the shell with the last card cut off above it; a tall one
|
||||
* made the document scroll by the difference, and that document scroll took
|
||||
* the swipes meant for `<main>`. The initial containing block is the layout
|
||||
* itself, recomputed with every rotation and resize, and the document never
|
||||
* scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box
|
||||
* cannot drift from the window either.
|
||||
*
|
||||
* Not `fixed`: a fixed box gets a compositing layer of its own, which
|
||||
* re-rasterised the text of every page (the antialiasing moved on every
|
||||
* screen), and forms a stacking context that would trap the bottom bar's
|
||||
* z-index under the Coach button and the toasts.
|
||||
*/
|
||||
export const SHELL_ROOT_CLASS =
|
||||
"shell-safe-area absolute inset-0 flex flex-col";
|
||||
|
||||
/**
|
||||
* `<main>`, the shell's one vertical scroller. Phone shell: it clears the
|
||||
* bottom nav and the home indicator. Desktop shell: there is no bar, and it
|
||||
* clears the home indicator of an installed app on a tablet (zero in a
|
||||
* browser tab), so the last line never sits under it.
|
||||
*/
|
||||
export const MAIN_SCROLL_CLASS =
|
||||
"relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]";
|
||||
|
||||
/**
|
||||
* The app-chrome header band: one 4rem strip whose bottom border draws the
|
||||
|
||||
@@ -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<HTMLElement | null>;
|
||||
}) {
|
||||
const Icon = direction === "down" ? ChevronDown : ChevronUp;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
data-slot="sidebar-nav-scroll-cue"
|
||||
data-direction={direction}
|
||||
onClick={() => {
|
||||
const nav = navRef.current;
|
||||
if (!nav) return;
|
||||
const reduce = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
nav.scrollBy({
|
||||
top: (direction === "down" ? 1 : -1) * nav.clientHeight * 0.75,
|
||||
behavior: reduce ? "auto" : "smooth",
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"absolute inset-x-0 z-10 hidden h-11 justify-center outline-none pointer-coarse:flex",
|
||||
direction === "down"
|
||||
? "bottom-0 items-end pb-1"
|
||||
: "top-0 items-start pt-1",
|
||||
)}
|
||||
>
|
||||
<span className="bg-sidebar border-sidebar-border text-foreground flex size-7 items-center justify-center rounded-full border shadow-sm">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<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 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 `<nav>` that scrolls, with its edge fades, and the
|
||||
* touch cues over its edges.
|
||||
*
|
||||
* 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.
|
||||
* `overscroll-contain` (under a finger) keeps a swipe that reaches either end
|
||||
* inside the rail: on iOS it chained to the document, which rubber-banded and
|
||||
* held the next swipe, so the list seemed not to scroll.
|
||||
*/
|
||||
export function RailNavList({
|
||||
navRef,
|
||||
edges,
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
navRef: React.RefObject<HTMLElement | null>;
|
||||
edges: { moreAbove: boolean; moreBelow: boolean };
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-nav-region"
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={label}
|
||||
className={cn(RAIL_NAV_SCROLL, className)}
|
||||
style={railFadeMask(edges.moreAbove, edges.moreBelow)}
|
||||
data-fade-top={edges.moreAbove ? "true" : undefined}
|
||||
data-fade-bottom={edges.moreBelow ? "true" : undefined}
|
||||
onFocus={(event) => {
|
||||
event.target.scrollIntoView?.({ block: "nearest" });
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
{edges.moreAbove && <RailScrollCue direction="up" navRef={navRef} />}
|
||||
{edges.moreBelow && <RailScrollCue direction="down" navRef={navRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Bell,
|
||||
Keyboard,
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RailNavList,
|
||||
useNavOverflows,
|
||||
} from "@/components/layout/sidebar-nav-scroll";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import { useAuth, useLogout } from "@/hooks/use-auth";
|
||||
@@ -367,69 +371,6 @@ function railRowClass(collapsed: boolean): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 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 sidebar's collapse / expand control, drawn as a footer nav row.
|
||||
*
|
||||
@@ -547,11 +488,8 @@ export function SidebarNav() {
|
||||
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
|
||||
|
||||
const navRef = useRef<HTMLElement | null>(null);
|
||||
const {
|
||||
overflows: navOverflows,
|
||||
moreAbove: navMoreAbove,
|
||||
moreBelow: navMoreBelow,
|
||||
} = useNavOverflows(navRef);
|
||||
const navEdges = useNavOverflows(navRef);
|
||||
const navOverflows = navEdges.overflows;
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !collapsed;
|
||||
@@ -689,7 +627,7 @@ export function SidebarNav() {
|
||||
<aside
|
||||
aria-label={t("nav.sidebar")}
|
||||
className={cn(
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none",
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-[env(safe-area-inset-bottom,0px)] transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
@@ -737,24 +675,11 @@ export function SidebarNav() {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<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 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" });
|
||||
}}
|
||||
<RailNavList
|
||||
navRef={navRef}
|
||||
edges={navEdges}
|
||||
label={t("nav.mainNavigation")}
|
||||
className={cn(collapsed ? "p-1.5" : "p-3", RAIL_SHORT_PY)}
|
||||
>
|
||||
<div className={RAIL_LIST_GAP}>
|
||||
{visibleNavItems.map((item) => {
|
||||
@@ -811,7 +736,7 @@ export function SidebarNav() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</RailNavList>
|
||||
|
||||
{/* Bottom utility links — the shared utility tail (minus
|
||||
Notifications, which lives in the avatar menu) with the
|
||||
|
||||
@@ -30,4 +30,21 @@ describe("<TabsList>", () => {
|
||||
// Horizontal scroll behaviour is still intact for long strips.
|
||||
expect(list![0]).toMatch(/\boverflow-x-auto\b/);
|
||||
});
|
||||
|
||||
it("leaves the vertical pan to the page", () => {
|
||||
// `touch-pan-x` (or `touch-none`) on the strip refuses a vertical pan
|
||||
// that starts on it, so a swipe up or down over a tab strip scrolled
|
||||
// nothing. The strip pans sideways through its own overflow.
|
||||
const html = renderToStaticMarkup(
|
||||
<Tabs defaultValue="a">
|
||||
<TabsList>
|
||||
<TabsTrigger value="a">A</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>,
|
||||
);
|
||||
const list = html.match(/<div\b[^>]*data-slot="tabs-list"[^>]*>/)![0];
|
||||
expect(list).not.toMatch(
|
||||
/\btouch-(?:pan-x|pan-left|pan-right|none|pinch-zoom)\b/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,8 @@ function Tabs({
|
||||
// mobile when more than ~3 tabs render at typical label widths
|
||||
// (`/insights`, `/admin`, `/settings/*`). Tab labels are i18n-driven
|
||||
// so we cannot truncate them deterministically. Instead, the list
|
||||
// becomes horizontally scrollable on mobile via `overflow-x-auto`
|
||||
// plus `touch-pan-x` for the iOS PWA, and the wrapping tabs preserve
|
||||
// becomes horizontally scrollable on mobile via `overflow-x-auto`, and the
|
||||
// wrapping tabs preserve
|
||||
// `whitespace-nowrap` so labels never word-wrap mid-tab. Desktop
|
||||
// looks identical because the inline-flex shrink-wraps inside the
|
||||
// wider container.
|
||||
@@ -47,10 +47,15 @@ function Tabs({
|
||||
// Padding sits at `p-1` (4 px) so the 3 px focus-visible ring
|
||||
// (`focus-visible:ring-[3px]`) on triggers stays inside the
|
||||
// `overflow-y-hidden` clamp without clipping the bottom of the ring.
|
||||
// No `touch-action` on the strip: `overflow-x-auto` already pans it sideways
|
||||
// under a finger, and the `touch-pan-x` it once carried also refused the
|
||||
// vertical pan, so a swipe up or down that began on a tab strip scrolled
|
||||
// nothing at all (on a tablet the strip is 52 px of the page under the 44 px
|
||||
// touch floor).
|
||||
// Under a coarse pointer the strip sizes to its triggers, which take the
|
||||
// 44 px touch floor; a mouse keeps the compact 36 px strip.
|
||||
const tabsListVariants = cva(
|
||||
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
|
||||
"rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
Reference in new issue
Block a user