diff --git a/src/lib/place.ts b/src/lib/place.ts index b6d15a4..26450f4 100644 --- a/src/lib/place.ts +++ b/src/lib/place.ts @@ -13,6 +13,31 @@ export interface Spot { const GAP = 6; const PAD = 10; +export interface Viewport { + width: number; + height: number; + /** Where the visible area starts, once a keyboard has pushed it up. */ + top?: number; + left?: number; +} + +/** + * What is actually visible, which on a phone is not the window: the software + * keyboard shrinks the visual viewport and leaves `innerHeight` alone, so a + * panel placed against the window lands behind the keys. + */ +export function viewport(win: Window = window): Viewport { + const vv = win.visualViewport; + return vv + ? { width: vv.width, height: vv.height, top: vv.offsetTop, left: vv.offsetLeft } + : { width: win.innerWidth, height: win.innerHeight }; +} + +/** The tallest a panel may be here before it has to scroll its own body. */ +export function roomFor(view: Viewport): number { + return Math.max(160, view.height - 2 * PAD); +} + /** * The calendar draws its cards twice — once in the agenda and once in the * month grid — and hides whichever the width does not call for. Both answer @@ -31,24 +56,33 @@ export function onScreen(boxes: Box[]): Box | null { export function place( anchor: Box | null, panel: { width: number; height: number }, - view: { width: number; height: number }, + view: Viewport ): Spot { + const originTop = view.top ?? 0; + const originLeft = view.left ?? 0; + const bottom = originTop + view.height; + const right = originLeft + view.width; + if (!anchor) { return { - top: Math.max(PAD, (view.height - panel.height) / 2), - left: Math.max(PAD, (view.width - panel.width) / 2), + top: Math.max(originTop + PAD, originTop + (view.height - panel.height) / 2), + left: Math.max(originLeft + PAD, originLeft + (view.width - panel.width) / 2), }; } let top = anchor.top + anchor.height + GAP; - if (top + panel.height > view.height - PAD) { - const above = anchor.top - GAP - panel.height; - top = above >= PAD ? above : Math.max(PAD, view.height - PAD - panel.height); + if (top + panel.height > bottom - PAD) { + top = anchor.top - GAP - panel.height; } + // The anchor itself can be outside the visible area - a keyboard covers the + // row the panel hangs off - and then neither side of it is reachable. What + // is visible wins over staying next to the anchor. + top = Math.min(top, bottom - PAD - panel.height); + top = Math.max(top, originTop + PAD); const left = Math.min( - Math.max(PAD, anchor.left), - Math.max(PAD, view.width - PAD - panel.width), + Math.max(originLeft + PAD, anchor.left), + Math.max(originLeft + PAD, right - PAD - panel.width) ); return { top, left }; diff --git a/src/ui/anchored-panel.svelte b/src/ui/anchored-panel.svelte index 1bef234..22506b7 100644 --- a/src/ui/anchored-panel.svelte +++ b/src/ui/anchored-panel.svelte @@ -1,7 +1,7 @@
- {#each shown as { item, ranges }, at (item.value)} + + {#each shown as { item, ranges }, at (`${at}:${item.value}`)}
{#each rows as row (row.action)} {/each} - + ␣ close + +
diff --git a/tests/place.test.ts b/tests/place.test.ts index 98ab568..a0ae705 100644 --- a/tests/place.test.ts +++ b/tests/place.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { onScreen, place } from "../src/lib/place"; +import { onScreen, place, roomFor } from "../src/lib/place"; import type { Box } from "../src/lib/place"; const VIEW = { width: 1280, height: 800 }; @@ -58,3 +58,40 @@ describe("place", () => { expect(place(null, PANEL, VIEW)).toEqual({ top: 300, left: 480 }); }); }); + +// A phone's keyboard shrinks the visual viewport and leaves the window alone. +describe("place against a keyboard", () => { + const SHRUNK = { width: 390, height: 400, top: 0, left: 0 }; + + it("keeps the panel above the keyboard", () => { + const spot = place(row({ top: 380, left: 10, width: 370 }), PANEL, SHRUNK); + expect(spot.top + PANEL.height).toBeLessThanOrEqual(SHRUNK.height); + }); + + it("measures from where the visible area starts", () => { + const scrolled = { width: 390, height: 400, top: 120, left: 0 }; + const spot = place(null, PANEL, scrolled); + expect(spot.top).toBeGreaterThanOrEqual(scrolled.top); + expect(spot.top + PANEL.height).toBeLessThanOrEqual( + scrolled.top + scrolled.height + ); + }); + + it("comes back into view when the anchor itself is under the keyboard", () => { + // The row the panel hangs off is at 650, the keyboard leaves 380: neither + // above nor below the anchor is reachable, so the visible area wins. + const spot = place(row({ top: 650, height: 40 }), PANEL, SHRUNK); + expect(spot.top).toBeGreaterThanOrEqual(10); + expect(spot.top + PANEL.height).toBeLessThanOrEqual(SHRUNK.height); + }); +}); + +describe("roomFor", () => { + it("leaves the panel less height than the visible area", () => { + expect(roomFor({ width: 390, height: 400 })).toBeLessThan(400); + }); + + it("never collapses to nothing when the keyboard takes almost everything", () => { + expect(roomFor({ width: 390, height: 60 })).toBeGreaterThan(100); + }); +}); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 36d2194..eedc3ea 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -661,6 +661,34 @@ for (const [name, init] of [ store.tick(); } +// On a Friday "Friday" and "In a week" are the same day. The date field used +// to be keyed by that day and tore itself down, so the picker never opened. +{ + store.today = "2026-09-04"; + store.leaveToMain(); + flushSync(); + key(viewRoot, { key: "n", code: "KeyN" }); + flushSync(); + store.today = "2026-09-04"; + flushSync(); + const panel = panelOf(); + for (let i = 0; i < 3; i += 1) { + (dom.window.document.activeElement ?? panel).dispatchEvent( + new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Tab", code: "Tab" }), + ); + flushSync(); + } + check("the date field opens on a friday", panelOf() !== null && store.editing !== null); + check( + "the day two offers share is listed once", + (panel.textContent.match(/Sep 11/gu) ?? []).length === 1, + panel.textContent.slice(0, 200), + ); + panel.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, key: "Escape", code: "Escape" })); + flushSync(); + store.tick(); +} + // A request whose task vanished must not leave the view deaf. store.request = "priority"; store.cursorTask = "nothing/at/all#0";