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.
This commit is contained in:
hh committed 2026-09-05 00:22:07 +02:00
1 parent 9c0e57e29b
commit b70331f7bd
8 files changed
+208 -26

No files matched your search

+42 -8
View File
@@ -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 };
+39 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Portal } from "bits-ui";
import type { Snippet } from "svelte";
import { onScreen, place } from "../lib/place";
import { onScreen, place, roomFor, viewport } from "../lib/place";
import { cn } from "../lib/utils";
import { useView } from "./context";
@@ -27,13 +27,21 @@
const view = useView();
// iOS animates the keyboard in; the viewport only tells the truth after.
const KEYBOARD_SETTLE_MS = 350;
let panel = $state<HTMLElement | null>(null);
let at = $state({ top: 0, left: 0 });
// Kept in state as well as on the node: the `style` attribute is rendered
// from `at`, so a re-render would drop a height only set imperatively.
let room = $state(0);
let placed = $state(false);
/**
* Placed by hand rather than by a floating layer: these panels are summoned
* by a key press, and a floating layer wants a trigger element to hang off.
* Measured against the visual viewport, so a phone's keyboard pushes the
* panel up instead of hiding it.
*/
function measure(): void {
const node = panel;
@@ -42,10 +50,13 @@
const boxes = [...document.querySelectorAll(anchor)].map((element) =>
element.getBoundingClientRect(),
);
at = place(onScreen(boxes), node.getBoundingClientRect(), {
width: window.innerWidth,
height: window.innerHeight,
});
const view = viewport(node.ownerDocument.defaultView ?? window);
// Applied to the node before it is measured: a keyboard that just took
// half the screen makes the panel shorter, and placing it against its old
// height would leave it hanging below the keys.
room = roomFor(view);
node.style.maxHeight = `${room}px`;
at = place(onScreen(boxes), node.getBoundingClientRect(), view);
placed = true;
}
@@ -65,12 +76,31 @@
const reflow = (): void => measure();
document.addEventListener("pointerdown", outside, true);
window.addEventListener("resize", reflow);
// The keyboard changes the visual viewport without resizing the window,
// and its animation takes a moment to settle.
const vv = win.visualViewport;
vv?.addEventListener("resize", reflow);
vv?.addEventListener("scroll", reflow);
const settle = win.setTimeout(reflow, KEYBOARD_SETTLE_MS);
// A panel whose body grew (an option list opened) re-places itself. Only
// the content height is watched: measure() sets max-height, so observing
// the panel itself would feed its own change back in.
const body = node.firstElementChild;
const watcher =
typeof ResizeObserver === "undefined" || !body
? null
: new ResizeObserver(reflow);
if (body) watcher?.observe(body);
win.addEventListener("keydown", keys, true);
return {
destroy() {
document.removeEventListener("pointerdown", outside, true);
window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", reflow);
vv?.removeEventListener("scroll", reflow);
win.clearTimeout(settle);
watcher?.disconnect();
win.removeEventListener("keydown", keys, true);
panel = null;
},
@@ -104,11 +134,13 @@
<Portal to={view.portal()}>
<div
class={cn(
"fixed z-50 overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
"fixed z-50 flex flex-col overflow-y-auto overscroll-contain rounded-xl border border-border bg-popover text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/45%)] outline-none",
className,
)}
role="dialog"
style="top: {at.top}px; left: {at.left}px; opacity: {placed ? 1 : 0}"
style="top: {at.top}px; left: {at.left}px; max-height: {room}px; opacity: {placed
? 1
: 0}"
tabindex="-1"
use:mount
>
+3 -1
View File
@@ -82,7 +82,9 @@
</script>
<div bind:this={box} class={cn("max-h-56 overflow-y-auto", className)}>
{#each shown as { item, ranges }, at (item.value)}
<!-- Keyed by position, not by value: a caller that offers the same value
twice should read oddly, never tear the list down. -->
{#each shown as { item, ranges }, at (`${at}:${item.value}`)}
<button
class={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors",
+10 -1
View File
@@ -82,13 +82,22 @@
label,
hint: monthDay.format(fromKey(key)),
});
return [
const offers = [
{ value: "", label: "No date" },
on(now, "Today"),
on(shiftDays(now, 1), "Tomorrow"),
on(nextFriday(now), "Friday"),
on(shiftDays(now, 7), "In a week"),
];
// Two offers can land on the same day - a Saturday's "Friday" is also
// "In a week" - and the list is keyed by the day it writes. The first
// name wins, which is the more specific one.
const seen = new Set<string>();
return offers.filter((option) => {
if (seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
});
const repeatOptions = $derived<Option[]>([
+10
View File
@@ -3,6 +3,7 @@ import ExternalLink from "@lucide/svelte/icons/external-link";
import FileText from "@lucide/svelte/icons/file-text";
import PenLine from "@lucide/svelte/icons/pen-line";
import ScanEye from "@lucide/svelte/icons/scan-eye";
import SquarePen from "@lucide/svelte/icons/square-pen";
import { STATUS_META } from "../lib/vocab";
import { STATUS_ORDER } from "../model/types";
import type { Task } from "../model/types";
@@ -60,6 +61,15 @@ export function taskMenu(view: ViewContext, task: Task): MenuItem[] {
},
{
icon: PenLine,
label: "Edit",
hint: "e",
onselect: () => {
view.store.cursorTask = task.id;
view.store.edit(task.id);
},
},
{
icon: SquarePen,
label: "Edit in Tasks",
onselect: () => void editInTasks(view, task),
},
+38 -8
View File
@@ -14,7 +14,7 @@
import { dueLabel, dueTone, fromKey } from "../lib/due";
import { labelHue } from "../lib/hue";
import type { Action } from "../lib/keymap";
import { onScreen } from "../lib/place";
import { onScreen, viewport } from "../lib/place";
import type { Box } from "../lib/place";
import { cn } from "../lib/utils";
import { PRIORITY_LABEL, STATUS_META } from "../lib/vocab";
@@ -63,6 +63,8 @@
label: string;
}
// Every row is a button first and a key second: on a phone the keys are not
// there to be pressed, so the lens has to be operable by thumb alone.
const rows = $derived<Row[]>(
task
? [
@@ -72,6 +74,7 @@
{ action: "taskBack", keys: ["⇧h"], label: "−day" },
{ action: "taskForward", keys: ["⇧l"], label: "+day" },
{ action: "taskToday", keys: ["⇧t"], label: "today" },
{ action: "taskUnschedule", keys: ["⇧x"], label: "no date" },
{ action: "taskOpen", keys: ["⏎"], label: "open note" },
]
: [],
@@ -85,13 +88,23 @@
* the middle, the way a window feels centred rather than measures it.
*/
function centre(node: HTMLElement): void {
const box = view.rootBox() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight);
const win = node.ownerDocument.defaultView ?? window;
const area = viewport(win);
const box =
view.rootBox() ??
new DOMRect(area.left ?? 0, area.top ?? 0, area.width, area.height);
const width = node.offsetWidth;
const height = node.offsetHeight;
const left = box.left + (box.width - width) / 2;
const top = box.top + (box.height - height) * 0.42;
node.style.left = `${Math.max(8, Math.min(left, window.innerWidth - width - 8))}px`;
node.style.top = `${Math.max(8, Math.min(top, window.innerHeight - height - 8))}px`;
// Kept inside what is actually visible: a phone's keyboard shrinks the
// viewport without touching the window, and the lens must stay reachable.
const minLeft = (area.left ?? 0) + 8;
const maxLeft = (area.left ?? 0) + area.width - width - 8;
const minTop = (area.top ?? 0) + 8;
const maxTop = (area.top ?? 0) + area.height - height - 8;
node.style.left = `${Math.max(minLeft, Math.min(left, Math.max(minLeft, maxLeft)))}px`;
node.style.top = `${Math.max(minTop, Math.min(top, Math.max(minTop, maxTop)))}px`;
}
/** The row or card under the cursor, wherever the current view draws it. */
@@ -151,6 +164,11 @@
watcher?.observe(node);
const reflow = (): void => centre(node);
window.addEventListener("resize", reflow);
// A keyboard (an editor opening behind the lens) moves the visible area
// without resizing the window.
const vv = (node.ownerDocument.defaultView ?? window).visualViewport;
vv?.addEventListener("resize", reflow);
vv?.addEventListener("scroll", reflow);
// A click on another task moves the lens rather than closing it; a click
// anywhere else puts it away.
@@ -166,6 +184,8 @@
destroy() {
watcher?.disconnect();
window.removeEventListener("resize", reflow);
vv?.removeEventListener("resize", reflow);
vv?.removeEventListener("scroll", reflow);
document.removeEventListener("pointerdown", outside, true);
// Only take the keyboard back if the lens had it; an editor that has
// just opened keeps its caret.
@@ -348,23 +368,33 @@
</div>
<div
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground"
class="flex flex-wrap items-center gap-x-0.5 gap-y-0.5 border-border/70 border-t bg-secondary/25 px-2 py-1.5 text-[11px] text-muted-foreground pointer-coarse:gap-x-1.5 pointer-coarse:gap-y-1.5 pointer-coarse:p-2"
>
{#each rows as row (row.action)}
<button
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
class="inline-flex h-6 items-center gap-1 whitespace-nowrap rounded-md px-1.5 outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:h-9 pointer-coarse:border pointer-coarse:border-border pointer-coarse:bg-popover pointer-coarse:px-3 pointer-coarse:text-xs pointer-coarse:text-foreground"
onclick={() => act(row.action)}
type="button"
>
{#each row.keys as key (key)}
<kbd class="kbd">{key}</kbd>
<kbd class="kbd pointer-coarse:hidden">{key}</kbd>
{/each}
{row.label}
</button>
{/each}
<span class="ml-auto whitespace-nowrap px-1.5">
<span class="ml-auto whitespace-nowrap px-1.5 pointer-coarse:hidden">
<kbd class="kbd">␣</kbd> close
</span>
<!-- No Escape key on a phone, so the lens needs a way out by thumb. -->
<button
class="ml-auto hidden h-9 items-center rounded-md border border-border bg-popover px-3 text-foreground text-xs pointer-coarse:inline-flex"
onclick={() => {
store.peeking = false;
}}
type="button"
>
close
</button>
</div>
</div>
</Portal>
+38 -1
View File
@@ -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);
});
});
+28
View File
@@ -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";