mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(mobile): fit the shell and sheets to safe areas and the on-screen keyboard
Safe areas. The installed app draws under the status bar and beside the notch, and each 4rem header band padded the inset inside its own border-box height: on a phone with a 59 px inset the top bar kept a 5 px content box and the logo and account menu straddled its bottom border. The authenticated shell now takes the top and side insets once on its outer wrapper (`shell-safe-area`, the strip painted in the chrome colour), so the top bar, the sidebar band and any docked panel start below the status bar together and the seam between them holds. The two Coach full-height pages subtract the inset at every width, the full- height day sheet keeps its handle clear of the Dynamic Island, side sheets keep clear of the status bar and the notch, the bottom bar and the Coach button take the side insets of a phone held sideways, a footer-less responsive sheet keeps its last row above the home indicator, and the sign-in and onboarding shells pad themselves. The sign-in shell grows with its content (`min-h-dvh`), so a phone held sideways can scroll to the top of the card. Keyboard. iOS Safari and Chrome on Android shrink only the visual viewport when the keyboard opens, so a bottom sheet kept Save and the Coach composer behind it. `KeyboardInsetBridge` publishes the covered height (`--keyboard-inset`, `data-keyboard` on <html>); bottom sheets stand on the keyboard and give up its height to their scrolling body, and the bottom bar and the Coach button step aside while it is open. Pinch-zoom and toolbar-sized changes do not count as a keyboard. The sheet primitive names its side in `data-sheet-side`, which consumers do not override the way they replace `data-slot`. pwa-mobile-shell-guard.test.ts pins the structure no headless browser can show (no browser in CI reports a safe-area inset).
This commit is contained in:
1 parent
56a3008cf0
commit
a4ad8ebe92
17 files changed
+470
-36
No files matched your search
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Structural guards for the installed app on a phone — the parts no browser
|
||||
* in CI can show, because neither Chromium nor WebKit under Playwright
|
||||
* reports a safe-area inset or opens an on-screen keyboard.
|
||||
*
|
||||
* 1. The authenticated shell takes the status-bar and side insets once,
|
||||
* on its outer wrapper, and no 4rem header band pads itself inside its
|
||||
* own height (which on a phone with a 59 px inset left the top bar a
|
||||
* 5 px content box and pushed the logo across its bottom border).
|
||||
* 2. The stylesheet carries the rules the shell and the keyboard bridge
|
||||
* depend on.
|
||||
* 3. Every form-field primitive keeps 16 px type on a touch screen: iOS
|
||||
* Safari zooms the page into a focused field below 16 px, and a width
|
||||
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
|
||||
* and to every iPad. The small size is allowed only behind
|
||||
* `pointer-fine:`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8");
|
||||
|
||||
/** Drop `//` and block comments so prose about a class cannot satisfy a check. */
|
||||
function code(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
describe("the shell owns the safe area", () => {
|
||||
it("the authenticated shell wrapper takes the insets", () => {
|
||||
const shell = code(read("src/components/layout/auth-shell.tsx"));
|
||||
expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"src/components/layout/top-bar.tsx",
|
||||
"src/components/insights/coach-panel/conversations-panel.tsx",
|
||||
"src/components/day/day-layer.tsx",
|
||||
])("%s pads no header band with the status-bar inset", (path) => {
|
||||
const source = code(read(path));
|
||||
expect(source).not.toMatch(/paddingTop:\s*"env\(safe-area-inset-top/);
|
||||
expect(source).not.toMatch(/\bpt-\[env\(safe-area-inset-top/);
|
||||
});
|
||||
|
||||
it("globals.css defines the shell band, the keyboard rules and pointer-fine", () => {
|
||||
const css = read("src/app/globals.css");
|
||||
expect(css).toMatch(
|
||||
/\.shell-safe-area\s*\{[^}]*padding-top:\s*env\(safe-area-inset-top/,
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/\[data-sheet-side="bottom"\]\s*\{\s*bottom:\s*var\(--keyboard-inset/,
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/html\[data-keyboard="open"\] \[data-keyboard-hide\]\s*\{\s*display:\s*none/,
|
||||
);
|
||||
expect(css).toMatch(/@custom-variant pointer-fine/);
|
||||
});
|
||||
|
||||
it("the bottom bar and the Coach button step aside for the keyboard", () => {
|
||||
expect(read("src/components/layout/bottom-nav.tsx")).toContain(
|
||||
'data-keyboard-hide=""',
|
||||
);
|
||||
expect(read("src/components/insights/layout-coach-fab.tsx")).toContain(
|
||||
'data-keyboard-hide=""',
|
||||
);
|
||||
});
|
||||
|
||||
it("the sheet primitive names its side for those rules", () => {
|
||||
expect(read("src/components/ui/sheet.tsx")).toMatch(
|
||||
/data-sheet-side=\{side\}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("form fields keep 16 px on a touch screen", () => {
|
||||
const FIELDS = [
|
||||
"src/components/ui/input.tsx",
|
||||
"src/components/ui/textarea.tsx",
|
||||
"src/components/ui/native-select.tsx",
|
||||
"src/components/ui/date-field.tsx",
|
||||
"src/components/ui/time-field.tsx",
|
||||
];
|
||||
|
||||
it.each(FIELDS)(
|
||||
"%s sets text-base and shrinks only under a fine pointer",
|
||||
(path) => {
|
||||
// The field's own box is the class string that draws its border; the
|
||||
// rest of the file (a picker's buttons, a caption) is not typed into.
|
||||
const boxes = (code(read(path)).match(
|
||||
/"[^"]*\bborder-input(?![\w-])[^"]*"/g,
|
||||
) ?? []) as string[];
|
||||
expect(boxes.length, `${path} has no field class`).toBeGreaterThan(0);
|
||||
for (const box of boxes) {
|
||||
expect(box).toMatch(/\btext-base\b/);
|
||||
// Any sub-16 px size must sit behind `pointer-fine:`; `file:` styles
|
||||
// the upload button, not typed text.
|
||||
const bare = box.match(
|
||||
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:xs|sm)(?![\w-])/g,
|
||||
);
|
||||
expect(bare ?? []).toEqual([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -326,8 +326,10 @@ export default function CoachConversationsPage() {
|
||||
// mobile-only BottomNav band). L4 fix
|
||||
// (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the
|
||||
// `safe-area-inset-top` term `/coach` subtracts, overshooting by
|
||||
// ~50px on notched devices; restored to match exactly.
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
// ~50px on notched devices; restored to match exactly. v1.42 — the
|
||||
// shell now takes that inset above the top bar at every width, so
|
||||
// the desktop height subtracts it too, and `/coach/plans` matches.
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
|
||||
>
|
||||
<CoachConversationsBody readOnly={!coach.available} />
|
||||
</div>
|
||||
|
||||
@@ -293,8 +293,10 @@ export default function CoachPlansPage() {
|
||||
data-slot="coach-plans-page"
|
||||
// Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell
|
||||
// padding and claim the viewport height below the top bar (minus the
|
||||
// mobile-only BottomNav band).
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
// mobile-only BottomNav band). The status-bar inset of an installed
|
||||
// app comes off as well, at every width: the shell takes it above
|
||||
// the top bar (`shell-safe-area`).
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
|
||||
>
|
||||
<CoachPlansBody readOnly={!coach.available} />
|
||||
</div>
|
||||
|
||||
@@ -525,6 +525,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* `.shell-safe-area` — the authenticated app shell's share of the installed
|
||||
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
|
||||
* and side insets as padding, and the strip behind the status bar is painted
|
||||
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
|
||||
* the page colour, so the status bar reads as part of the bar beneath it. A
|
||||
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
|
||||
*/
|
||||
.shell-safe-area {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
background: linear-gradient(var(--card), var(--card)) top / 100%
|
||||
env(safe-area-inset-top, 0px) no-repeat;
|
||||
}
|
||||
|
||||
/*
|
||||
* The on-screen keyboard. iOS Safari, and Chrome on Android by default,
|
||||
* shrink only the visual viewport when the keyboard opens: everything
|
||||
* `position: fixed` stays anchored to the layout viewport, so a bottom sheet
|
||||
* keeps its footer (Save, the Coach composer) behind the keyboard and the
|
||||
* bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once
|
||||
* in the providers) publishes the covered height as `--keyboard-inset` and
|
||||
* sets `data-keyboard` on `<html>` while it is open.
|
||||
*
|
||||
* Bottom sheets then stand on the keyboard and give up the height it takes,
|
||||
* so their own scroll body (every bottom sheet is a flex column with a
|
||||
* scrolling middle) carries the form, and the bottom bar and the Coach
|
||||
* button step aside until the keyboard is gone.
|
||||
*/
|
||||
[data-sheet-side="bottom"] {
|
||||
bottom: var(--keyboard-inset, 0px);
|
||||
}
|
||||
html[data-keyboard="open"] [data-sheet-side="bottom"] {
|
||||
max-height: calc(
|
||||
100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - 0.5rem
|
||||
) !important;
|
||||
}
|
||||
html[data-keyboard="open"] [data-keyboard-hide] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* Side and top sheets run the full height of the window. In an installed
|
||||
* app on a tablet the status bar sits over their first 24 px, and a phone
|
||||
* held sideways has its notch over one side, so the panel keeps its header
|
||||
* clear of both. A transparent border rather than padding: every consumer
|
||||
* sets its own padding (often `p-0`), which would override a padding here,
|
||||
* while the border adds the clearance under any of them and lets the
|
||||
* panel's own background show through it.
|
||||
*/
|
||||
[data-sheet-side="right"],
|
||||
[data-sheet-side="left"],
|
||||
[data-sheet-side="top"] {
|
||||
border-top: env(safe-area-inset-top, 0px) solid transparent;
|
||||
}
|
||||
[data-sheet-side="right"] {
|
||||
border-right: env(safe-area-inset-right, 0px) solid transparent;
|
||||
}
|
||||
[data-sheet-side="left"] {
|
||||
border-left: env(safe-area-inset-left, 0px) solid transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* `.no-scrollbar` — keeps a container scrollable but hides the visible
|
||||
* bar. Used by the mobile section strips in `<AdminShell>` and
|
||||
|
||||
@@ -249,10 +249,9 @@ function DayLayer() {
|
||||
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
|
||||
>
|
||||
{view(DockedTitle, {
|
||||
headerClassName: cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-border pt-[env(safe-area-inset-top,0px)]",
|
||||
),
|
||||
// No status-bar inset here: the docked column sits inside
|
||||
// the app shell, which takes it (`shell-safe-area`).
|
||||
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
|
||||
})}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
@@ -436,7 +435,13 @@ function DaySheet({
|
||||
bottom
|
||||
? cn(
|
||||
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
|
||||
full ? "h-[calc(100dvh-2.5rem)]" : "h-[58dvh]",
|
||||
// Full height leaves a strip of the page above the
|
||||
// sheet, and never less than the status bar of an
|
||||
// installed app plus a margin: a 2.5rem strip put the
|
||||
// handle under a 59 px Dynamic Island.
|
||||
full
|
||||
? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]"
|
||||
: "h-[58dvh]",
|
||||
)
|
||||
: "w-105 max-w-[90vw] sm:max-w-105",
|
||||
)}
|
||||
|
||||
@@ -69,13 +69,22 @@ describe("<LayoutCoachFab>", () => {
|
||||
// bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air
|
||||
// in the 768-1023px band. v1.18.1 (C5) — the corner inset is
|
||||
// symmetric: right equals bottom at each breakpoint (`right-6`
|
||||
// mobile-band / `md:right-8` desktop mirrors `md:bottom-8`).
|
||||
expect(html).toContain("fixed right-6");
|
||||
expect(html).toContain("md:right-8");
|
||||
// mobile-band / `md:right-8` desktop mirrors `md:bottom-8`). v1.42 —
|
||||
// each offset sits on top of the matching safe-area inset (the notch
|
||||
// of a phone held sideways, an installed iPad's home indicator).
|
||||
expect(html).toContain(
|
||||
"fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)]",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"md:right-[calc(env(safe-area-inset-right,0px)+2rem)]",
|
||||
);
|
||||
expect(html).toContain(
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
|
||||
);
|
||||
expect(html).toContain("md:bottom-8");
|
||||
expect(html).toContain(
|
||||
"md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
|
||||
);
|
||||
expect(html).toContain('data-keyboard-hide=""');
|
||||
expect(html).not.toContain("md:bottom-6");
|
||||
expect(html).not.toContain("lg:bottom-6");
|
||||
});
|
||||
|
||||
@@ -209,12 +209,9 @@ export function ConversationsPanel({
|
||||
const header = (inSheet: boolean) => (
|
||||
<div
|
||||
data-slot="coach-conversations-panel-header"
|
||||
// Docked, the row reserves the safe-area inset the top bar reserves,
|
||||
// so a standalone PWA on a tablet does not tuck it under the status
|
||||
// bar; the band keeps its height (border-box), like the top bar.
|
||||
style={
|
||||
inSheet ? undefined : { paddingTop: "env(safe-area-inset-top, 0px)" }
|
||||
}
|
||||
// No inset of its own: docked, the panel is a column of the app
|
||||
// shell, which already starts below the status bar of an installed
|
||||
// app (`shell-safe-area` in `auth-shell.tsx`).
|
||||
// Docked, the row is the top bar's band (height and bottom border
|
||||
// from `SHELL_HEADER_BAND`), so the two borders draw one line.
|
||||
className={cn(
|
||||
|
||||
@@ -224,6 +224,8 @@ export function LayoutCoachFab() {
|
||||
type="button"
|
||||
size="icon"
|
||||
data-slot="coach-fab"
|
||||
// Steps aside while the on-screen keyboard is open (globals.css).
|
||||
data-keyboard-hide=""
|
||||
data-unread={showDot ? "true" : undefined}
|
||||
onClick={handleOpen}
|
||||
aria-label={accessibleLabel}
|
||||
@@ -240,8 +242,11 @@ export function LayoutCoachFab() {
|
||||
// 64 px bottom-nav plus the home-indicator safe-area inset
|
||||
// (`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]`), so the
|
||||
// gap is asymmetric there by design, not drift.
|
||||
"fixed right-6 z-40 size-14 rounded-full shadow-lg md:right-8",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-8",
|
||||
// v1.42 — the side and bottom insets apply at every width: a
|
||||
// phone held sideways (844 px, past `md`) has its notch on one
|
||||
// side, and an installed iPad its home indicator along the bottom.
|
||||
"fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)] z-40 size-14 rounded-full shadow-lg md:right-[calc(env(safe-area-inset-right,0px)+2rem)]",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
|
||||
// Dark glyph on the purple/pink gradient — white sat at
|
||||
// ≈2.3:1 against the gradient midpoint; the background token
|
||||
// reads ≈6.5:1.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { trackKeyboardInset } from "@/lib/pwa/keyboard-inset";
|
||||
|
||||
/**
|
||||
* Publishes the on-screen keyboard's height for the whole app, once (see
|
||||
* `src/lib/pwa/keyboard-inset.ts` for the measurement and `globals.css` for
|
||||
* what reads it: bottom sheets stand on the keyboard, the bottom bar and the
|
||||
* Coach button step aside).
|
||||
*/
|
||||
export function KeyboardInsetBridge(): null {
|
||||
useEffect(() => trackKeyboardInset(window, document.documentElement), []);
|
||||
return null;
|
||||
}
|
||||
@@ -278,13 +278,18 @@ export function AuthShell({
|
||||
if (isStandalonePublicPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
// `min-h-dvh`, not `h-dvh`: a phone held sideways is ~390 px tall, and
|
||||
// a sign-in form that is taller than a fixed-height box centres out of
|
||||
// it at the top, where nothing can scroll back to it. A minimum lets the
|
||||
// box grow and the document scroll. The safe-area padding keeps the form
|
||||
// off the status bar and the home indicator in an installed app.
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pb-[env(safe-area-inset-bottom,0px)] pl-[env(safe-area-inset-left,0px)]">
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
<MaintainershipBanner />
|
||||
<main
|
||||
id="main-content"
|
||||
className="flex flex-1 items-center justify-center px-4"
|
||||
className="flex flex-1 items-center justify-center px-4 py-6"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
@@ -352,7 +357,7 @@ export function AuthShell({
|
||||
{showUnlockNotifier && user?.id ? (
|
||||
<AchievementUnlockNotifier userId={user.id} />
|
||||
) : null}
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<div className="flex min-h-dvh flex-col pt-[env(safe-area-inset-top,0px)] pr-[env(safe-area-inset-right,0px)] pl-[env(safe-area-inset-left,0px)]">
|
||||
<MaintainershipBanner />
|
||||
<div className="flex flex-1 items-center justify-center px-4 py-8">
|
||||
{children}
|
||||
@@ -418,7 +423,21 @@ export function AuthShell({
|
||||
vertical scroller. Measured in `e2e/chrome-header-seam-banners.spec.ts`
|
||||
at both breakpoints.
|
||||
*/}
|
||||
<div className="flex h-dvh flex-col">
|
||||
{/*
|
||||
The installed app draws under the status bar and beside the notch
|
||||
(`viewport-fit=cover` + `black-translucent`), so the shell takes the
|
||||
insets once, here, for everything inside it: the banner stack, the
|
||||
top bar, the sidebar's logo band and any docked side panel all start
|
||||
below the status bar together, and the seam between the top bar and
|
||||
the logo band cannot come apart over an inset only one of them
|
||||
reserved. The bands used to pad themselves inside their 4rem
|
||||
(border-box), which on a phone with a 59 px inset left the top bar
|
||||
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.
|
||||
*/}
|
||||
<div className="shell-safe-area flex h-dvh flex-col">
|
||||
{/*
|
||||
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
|
||||
`navigator.onLine === false`. Sits above the maintainership
|
||||
|
||||
@@ -200,9 +200,15 @@ export function BottomNav() {
|
||||
`bg-card` + `border-t` give the bar a clean visual edge so page
|
||||
content that scrolls under the bar is occluded, not bleeding
|
||||
through. */}
|
||||
{/* The side insets keep the outer tabs clear of the notch on a phone
|
||||
held sideways. `data-keyboard-hide`: while the on-screen keyboard
|
||||
is open the bar steps aside (globals.css) instead of floating
|
||||
over the field being typed into. */}
|
||||
<nav
|
||||
aria-label={t("nav.mobileNavigation")}
|
||||
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
|
||||
data-slot="bottom-nav"
|
||||
data-keyboard-hide=""
|
||||
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{primaryLeft.map(renderPrimary)}
|
||||
|
||||
@@ -92,14 +92,9 @@ export function TopBar() {
|
||||
"bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
|
||||
SHELL_HEADER_BAND,
|
||||
)}
|
||||
// iOS PWA on notched iPhones overlays the status bar onto the
|
||||
// sticky header unless we reserve the safe-area inset. The
|
||||
// inline style adds `safe-area-inset-top` as padding-top on
|
||||
// devices that report one and is a no-op on every other
|
||||
// platform. The band stays 4rem tall either way (border-box), so
|
||||
// on a device that reports an inset the content area inside it
|
||||
// gives up those pixels.
|
||||
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
|
||||
// The status-bar inset of the installed app is the shell's
|
||||
// (`shell-safe-area` in `auth-shell.tsx`), not this band's: padded
|
||||
// inside its own 4rem, a 59 px inset left the bar a 5 px content box.
|
||||
>
|
||||
{/* Mobile logo */}
|
||||
<Link href="/" className="flex items-center gap-2 md:hidden">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
@@ -405,6 +406,7 @@ export function Providers({
|
||||
<RecordSessionFenceBridge />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<KeyboardInsetBridge />
|
||||
<VersionPoller />
|
||||
<ServiceWorkerRegistrar />
|
||||
</I18nProvider>
|
||||
|
||||
@@ -232,8 +232,14 @@ export function ResponsiveSheet({
|
||||
className={cn(
|
||||
// Vertical-only scroll port — see the note on the Dialog
|
||||
// branch's body below for why `overflow-y-auto` alone does not
|
||||
// say that.
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto p-4",
|
||||
// say that. `overscroll-contain`: a flick that reaches the end
|
||||
// of the form stops there instead of chaining into the page
|
||||
// (or the browser's pull-to-refresh) behind the sheet.
|
||||
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto overscroll-contain p-4",
|
||||
// Without a footer the body is the sheet's bottom edge, so it
|
||||
// keeps its last row above the home indicator of an
|
||||
// installed app; the footer does that when there is one.
|
||||
!footer && "pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]",
|
||||
bodyClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -61,6 +61,12 @@ function SheetContent({
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
// Read by the safe-area and on-screen-keyboard rules in
|
||||
// `globals.css`: a bottom sheet stands on the keyboard, the
|
||||
// full-height sides keep clear of the status bar and the notch.
|
||||
// Its own attribute rather than `data-slot`, which consumers
|
||||
// replace with theirs (`responsive-sheet-content`, `day-panel`).
|
||||
data-sheet-side={side}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
KEYBOARD_MIN_PX,
|
||||
keyboardInset,
|
||||
trackKeyboardInset,
|
||||
} from "@/lib/pwa/keyboard-inset";
|
||||
|
||||
describe("keyboardInset", () => {
|
||||
it("is the layout viewport left below the visual one", () => {
|
||||
// iPhone 15, portrait: 844 tall, keyboard 336.
|
||||
expect(keyboardInset(844, { height: 508, offsetTop: 0, scale: 1 })).toBe(
|
||||
336,
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a visual viewport iOS panned to keep the field in view", () => {
|
||||
expect(keyboardInset(844, { height: 508, offsetTop: 120, scale: 1 })).toBe(
|
||||
216,
|
||||
);
|
||||
});
|
||||
|
||||
it("is zero without a keyboard", () => {
|
||||
expect(keyboardInset(844, { height: 844, offsetTop: 0, scale: 1 })).toBe(0);
|
||||
});
|
||||
|
||||
it("does not read a collapsing toolbar as a keyboard", () => {
|
||||
expect(
|
||||
keyboardInset(844, {
|
||||
height: 844 - (KEYBOARD_MIN_PX - 1),
|
||||
offsetTop: 0,
|
||||
scale: 1,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("does not read pinch-zoom as a keyboard", () => {
|
||||
expect(keyboardInset(844, { height: 422, offsetTop: 0, scale: 2 })).toBe(0);
|
||||
});
|
||||
|
||||
it("is zero where the browser has no visual viewport", () => {
|
||||
expect(keyboardInset(844, null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeViewport extends EventTarget {
|
||||
height = 844;
|
||||
offsetTop = 0;
|
||||
scale = 1;
|
||||
}
|
||||
|
||||
function fakeRoot() {
|
||||
const props = new Map<string, string>();
|
||||
const attrs = new Map<string, string>();
|
||||
return {
|
||||
props,
|
||||
attrs,
|
||||
el: {
|
||||
style: {
|
||||
setProperty: (k: string, v: string) => props.set(k, v),
|
||||
removeProperty: (k: string) => props.delete(k),
|
||||
},
|
||||
setAttribute: (k: string, v: string) => attrs.set(k, v),
|
||||
removeAttribute: (k: string) => attrs.delete(k),
|
||||
} as unknown as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe("trackKeyboardInset", () => {
|
||||
it("publishes the inset while the keyboard is open and clears it after", () => {
|
||||
const viewport = new FakeViewport();
|
||||
const root = fakeRoot();
|
||||
const stop = trackKeyboardInset(
|
||||
{
|
||||
innerHeight: 844,
|
||||
visualViewport: viewport as unknown as VisualViewport,
|
||||
},
|
||||
root.el,
|
||||
);
|
||||
expect(root.attrs.has("data-keyboard")).toBe(false);
|
||||
|
||||
viewport.height = 508;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(root.props.get("--keyboard-inset")).toBe("336px");
|
||||
expect(root.attrs.get("data-keyboard")).toBe("open");
|
||||
|
||||
viewport.offsetTop = 100;
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
expect(root.props.get("--keyboard-inset")).toBe("236px");
|
||||
|
||||
viewport.height = 844;
|
||||
viewport.offsetTop = 0;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(root.props.has("--keyboard-inset")).toBe(false);
|
||||
expect(root.attrs.has("data-keyboard")).toBe(false);
|
||||
|
||||
viewport.height = 508;
|
||||
stop();
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(root.attrs.has("data-keyboard")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing without a visual viewport", () => {
|
||||
const root = fakeRoot();
|
||||
const stop = trackKeyboardInset(
|
||||
{ innerHeight: 844, visualViewport: null },
|
||||
root.el,
|
||||
);
|
||||
stop();
|
||||
expect(root.attrs.size).toBe(0);
|
||||
expect(root.props.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* How much of the layout viewport the on-screen keyboard covers.
|
||||
*
|
||||
* iOS Safari, and Chrome on Android by default (`interactive-widget` left at
|
||||
* `resizes-visual`), do not shrink the layout viewport when the keyboard
|
||||
* opens; only the visual viewport shrinks. Everything `position: fixed` stays
|
||||
* anchored to the layout viewport, so a bottom sheet keeps its footer (Save,
|
||||
* the Coach composer) behind the keyboard. The covered height is what is left
|
||||
* of the layout viewport below the visual one:
|
||||
*
|
||||
* innerHeight - (visualViewport.offsetTop + visualViewport.height)
|
||||
*
|
||||
* Two things shrink the visual viewport without a keyboard, and neither may
|
||||
* read as one: pinch-zoom (the scale moves off 1) and the few dozen pixels a
|
||||
* collapsing toolbar can account for. Hence the scale check and the floor.
|
||||
*
|
||||
* Published as the `--keyboard-inset` custom property and `data-keyboard` on
|
||||
* `<html>`; the rules that use them live in `globals.css`.
|
||||
*/
|
||||
|
||||
/** Below this, the difference is browser chrome, not a keyboard. */
|
||||
export const KEYBOARD_MIN_PX = 120;
|
||||
|
||||
export interface VisualViewportLike {
|
||||
height: number;
|
||||
offsetTop: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function keyboardInset(
|
||||
innerHeight: number,
|
||||
viewport: VisualViewportLike | null | undefined,
|
||||
): number {
|
||||
if (!viewport) return 0;
|
||||
if (Math.abs(viewport.scale - 1) > 0.01) return 0;
|
||||
const covered = innerHeight - viewport.offsetTop - viewport.height;
|
||||
return covered >= KEYBOARD_MIN_PX ? Math.round(covered) : 0;
|
||||
}
|
||||
|
||||
export const KEYBOARD_INSET_PROPERTY = "--keyboard-inset";
|
||||
export const KEYBOARD_ATTRIBUTE = "data-keyboard";
|
||||
|
||||
/**
|
||||
* Track the keyboard on `win` and publish it on `root`. Returns the cleanup.
|
||||
* A browser without `visualViewport` gets nothing published, which leaves
|
||||
* every rule at its keyboard-closed default.
|
||||
*/
|
||||
export function trackKeyboardInset(
|
||||
win: Pick<Window, "innerHeight" | "visualViewport">,
|
||||
root: HTMLElement,
|
||||
): () => void {
|
||||
const viewport = win.visualViewport;
|
||||
if (!viewport) return () => {};
|
||||
|
||||
let current = -1;
|
||||
const update = () => {
|
||||
const inset = keyboardInset(win.innerHeight, viewport);
|
||||
if (inset === current) return;
|
||||
current = inset;
|
||||
if (inset > 0) {
|
||||
root.style.setProperty(KEYBOARD_INSET_PROPERTY, `${inset}px`);
|
||||
root.setAttribute(KEYBOARD_ATTRIBUTE, "open");
|
||||
} else {
|
||||
root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
|
||||
root.removeAttribute(KEYBOARD_ATTRIBUTE);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
viewport.addEventListener("resize", update);
|
||||
// iOS pans the visual viewport to keep the focused field in view, which
|
||||
// moves `offsetTop` without a resize.
|
||||
viewport.addEventListener("scroll", update);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", update);
|
||||
viewport.removeEventListener("scroll", update);
|
||||
root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
|
||||
root.removeAttribute(KEYBOARD_ATTRIBUTE);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user