Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/components/chat/ExpandedImageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="ghost"
className="absolute left-[calc(--spacing(2)+var(--safe-area-left))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-[calc(--spacing(6)+var(--safe-area-left))]"
className="absolute left-2 top-1/2 z-20 ml-(--safe-area-left) -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
aria-label="Previous image"
onClick={() => navigateImage(-1)}
>
Expand Down Expand Up @@ -212,7 +212,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="ghost"
className="absolute right-[calc(--spacing(2)+var(--safe-area-right))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-[calc(--spacing(6)+var(--safe-area-right))]"
className="absolute right-2 top-1/2 z-20 mr-(--safe-area-right) -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
aria-label="Next image"
onClick={() => navigateImage(1)}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/chatFind/ChatFind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ function ChatFindSession(props: ChatFindProps) {
return (
<div
ref={barRef}
className="absolute top-2 right-[calc(--spacing(3)+var(--safe-area-right))] z-20 max-w-[calc(100%-1.5rem-var(--safe-area-right))]"
className="absolute top-2 right-3 z-20 mr-(--safe-area-right) max-w-[calc(100%-1.5rem-var(--safe-area-right))]"
>
<FindBar
query={query}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/terminal/TerminalFindBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function TerminalFindBar({
placeholder="Find in terminal"
inputRef={inputRef}
testIdPrefix="terminal-find"
className="absolute top-2 right-[calc(--spacing(3)+var(--safe-area-right))] z-20"
className="absolute top-2 right-3 z-20 mr-(--safe-area-right)"
canStep={query !== ""}
/>
);
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/components/ui/dialog.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,37 @@ describe("Dialog", () => {
}
});

it("keeps a phone's bottom sheet whole while the page reads as off screen for a moment", async () => {
await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height);
const mounted = await render(
<Dialog open>
<DialogPopup>
<DialogHeader>
<DialogTitle>Connect to a computer</DialogTitle>
</DialogHeader>
<input aria-label="Code" className="m-6" />
</DialogPopup>
</Dialog>,
);

try {
await vi.waitFor(() => {
expect(popupRect()?.bottom).toBeCloseTo(window.innerHeight, 0);
});
const height = popupRect()!.height;

// A folding phone moving the page between its screens: none of it
// shows, and read as a keyboard that squeezed the sheet to nothing.
showOnScreenKeyboard({ height: window.visualViewport!.height, pageScrolledUpBy: 0 });
await nextFrames(5);

expect(popupRect()?.height).toBeCloseTo(height, 0);
expect(popupRect()?.bottom).toBeCloseTo(window.innerHeight, 0);
} finally {
await mounted.unmount();
}
});

it("keeps a tall sheet inside what's visible when Safari scrolls the page up for the keyboard", async () => {
await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height);
const mounted = await render(
Expand Down
17 changes: 7 additions & 10 deletions apps/web/src/hooks/useOnScreenKeyboardInset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { type CSSProperties, useCallback, useSyncExternalStore } from "react";

import { readVisualViewportInset, type VisualViewportEdge } from "~/lib/onScreenKeyboard";
import {
readVisualViewportInset,
type VisualViewportEdge,
watchOnScreenKeyboard,
} from "~/lib/onScreenKeyboard";

function getServerSnapshot(): number {
return 0;
Expand All @@ -9,15 +13,8 @@ function getServerSnapshot(): number {
function useVisualViewportInset(edge: VisualViewportEdge, enabled: boolean): number {
const subscribe = useCallback(
(callback: () => void) => {
if (!enabled || typeof window === "undefined") return () => {};
const viewport = window.visualViewport;
if (!viewport) return () => {};
viewport.addEventListener("resize", callback);
viewport.addEventListener("scroll", callback);
return () => {
viewport.removeEventListener("resize", callback);
viewport.removeEventListener("scroll", callback);
};
if (!enabled) return () => {};
return watchOnScreenKeyboard(callback);
},
[enabled],
);
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/lib/onScreenKeyboard.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,25 @@ describe("installOnScreenKeyboardFlag", () => {
window.visualViewport?.dispatchEvent(new Event("resize"));
expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
});

it("does not take a page that is off screen for a moment for a keyboard", () => {
const root = document.documentElement;
uninstall = installOnScreenKeyboardFlag();

// A folding phone moving the page from one of its screens to the other:
// for a moment none of the page shows.
showOnScreenKeyboard({ height: window.visualViewport!.height, pageScrolledUpBy: 0 });
expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
});

it("looks again when the window resizes with no word from the visual viewport", () => {
const root = document.documentElement;
uninstall = installOnScreenKeyboardFlag();
const viewport = window.visualViewport!;

vi.spyOn(viewport, "height", "get").mockReturnValue(viewport.height - 260);
expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
window.dispatchEvent(new Event("resize"));
expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(true);
});
});
62 changes: 51 additions & 11 deletions apps/web/src/lib/onScreenKeyboard.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,66 @@
export type VisualViewportEdge = "top" | "bottom";

/** Pinch zoom also shrinks the visual viewport; only a 1:1 scale reliably means
* the height lost is the on-screen keyboard's. */
function unzoomedVisualViewport(): VisualViewport | null {
/**
* The least of the window a visual viewport can show and still be read as a
* page with a keyboard over it. No on-screen keyboard takes nine tenths of
* its window (a small phone on its side keeps about a third). A reading
* under this is taken to mean the page is off screen for a moment. That is
* the best explanation, not a measured one, for an iPhone Duo's sheets and
* drawers opening squeezed to nothing after a fold: read as a keyboard, a
* visual viewport of about no height pads them by the whole window, and
* the reading can outlast the fold with no resize event to correct it.
*/
const VISIBLE_PAGE_MIN_SHARE_OF_WINDOW = 0.1;

/**
* The visual viewport, when what it has lost can be an on-screen keyboard's
* doing. Pinch zoom shrinks it too, so only a 1:1 scale counts, and so does
* only a viewport that still shows some of the page.
*/
function keyboardVisualViewport(): VisualViewport | null {
const viewport = typeof window === "undefined" ? null : window.visualViewport;
if (!viewport || Math.abs(viewport.scale - 1) > 0.01) {
if (
!viewport ||
Math.abs(viewport.scale - 1) > 0.01 ||
viewport.height < window.innerHeight * VISIBLE_PAGE_MIN_SHARE_OF_WINDOW
) {
return null;
}
return viewport;
}

/**
* Calls back when an on-screen keyboard may have come, gone or moved the
* page. The window's own resize counts too: the readings compare the visual
* viewport with the window, and the window can change size (a folding phone,
* a turned one) with no event on the visual viewport. Returns the way to stop.
*/
export function watchOnScreenKeyboard(onChange: () => void): () => void {
const viewport = typeof window === "undefined" ? undefined : window.visualViewport;
if (!viewport) {
return () => {};
}
viewport.addEventListener("resize", onChange);
viewport.addEventListener("scroll", onChange);
window.addEventListener("resize", onChange);
return () => {
viewport.removeEventListener("resize", onChange);
viewport.removeEventListener("scroll", onChange);
window.removeEventListener("resize", onChange);
};
}

/**
* How much of the layout viewport an on-screen keyboard leaves out of view at
* one edge, in px: at the bottom, what the keyboard covers; at the top, how far
* iOS Safari scrolled the page up to show the focused field above it. Only
* browsers whose keyboard slides over the page (iOS Safari) report anything;
* one that resizes the page instead (Android Chrome, which honors
* `interactive-widget=resizes-content`) reports 0, as does a pinch-zoomed page.
* `interactive-widget=resizes-content`) reports 0, as does a pinch-zoomed page
* and one that is off screen for a moment.
*/
export function readVisualViewportInset(edge: VisualViewportEdge): number {
const viewport = unzoomedVisualViewport();
const viewport = keyboardVisualViewport();
if (!viewport) {
return 0;
}
Expand All @@ -31,7 +72,7 @@ export function readVisualViewportInset(edge: VisualViewportEdge): number {

/** The visual viewport's whole shrink, whichever edge Safari scrolled it from. */
function readOnScreenKeyboardHeight(): number {
const viewport = unzoomedVisualViewport();
const viewport = keyboardVisualViewport();
return viewport ? Math.max(0, Math.round(window.innerHeight - viewport.height)) : 0;
}

Expand Down Expand Up @@ -60,8 +101,7 @@ export const ON_SCREEN_KEYBOARD_ATTRIBUTE = "data-on-screen-keyboard";
* Returns the way to undo it.
*/
export function installOnScreenKeyboardFlag(): () => void {
const viewport = typeof window === "undefined" ? undefined : window.visualViewport;
if (!viewport) {
if (typeof window === "undefined" || !window.visualViewport) {
return () => {};
}
const root = document.documentElement;
Expand All @@ -72,9 +112,9 @@ export function installOnScreenKeyboardFlag(): () => void {
);
};
sync();
viewport.addEventListener("resize", sync);
const stopWatching = watchOnScreenKeyboard(sync);
return () => {
viewport.removeEventListener("resize", sync);
stopWatching();
root.removeAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE);
};
}
Loading