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";