From b70331f7bd56a585a453ec6233d68830a6ba8ab6 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 00:22:07 +0200 Subject: [PATCH] fix(ui): panels stay above the keyboard, the day two offers share is listed once The date field would not open at all on some days: on a Friday "Friday" and "In a week" resolve to the same day, and the option list was keyed by the day it writes, so Svelte tore it down with each_key_duplicate. On a desktop this took the whole view with it - the same bug, not two. Duplicate offers are dropped (the more specific name wins) and the list is keyed by position, so no caller can tear it down that way again. Panels are placed against the visual viewport rather than the window, which a phone's keyboard shrinks without resizing anything: they scroll inside what is left, re-place as the keyboard animates in, and come back into view when the keyboard covers the very row they hang off - visible beats staying next to the anchor. The quick look centres the same way. Its actions are thumb-sized where the pointer is coarse, with the keycaps hidden - there is no keyboard to press them on - and it gains "no date" and a close button, since a phone has no Escape. Both menus gain a plain "Edit" beside "Edit in Tasks"; it was only ever reachable by pressing e. --- src/lib/place.ts | 50 ++++++++++++++++++++++++++++++------ src/ui/anchored-panel.svelte | 46 ++++++++++++++++++++++++++++----- src/ui/option-list.svelte | 4 ++- src/ui/task-editor.svelte | 11 +++++++- src/ui/task-menu.ts | 10 ++++++++ src/ui/task-peek.svelte | 46 +++++++++++++++++++++++++++------ tests/place.test.ts | 39 +++++++++++++++++++++++++++- tests/smoke.mjs | 28 ++++++++++++++++++++ 8 files changed, 208 insertions(+), 26 deletions(-) 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";