Pickers: anchor the desktop dropdown to its chip, never clipped

"The model selection in teach it back is kinda broken at least on desktop":
the desktop panels were position:absolute children of whatever container held
their chip. In chat that is the composer at the bottom of the column, which
works, but Teach it back keeps its chips inside a scrolling form card — so the
panel opened upward out of the card and its top rows (including the selected
model) were cut off. The probe measured top: -66px and no way to reach the
first rows by scrolling the list.

- New components/chat/PickerPopover.tsx: the desktop surface for both pickers,
  portalled to <body> and position:fixed, anchored to the opening chip
  ([data-picker-trigger][aria-expanded="true"]), opening on the side with room
  and capped to the viewport so the panel scrolls as a whole with its heading
  and note attached. It closes on an outside press, Escape, resize or a page
  scroll — but not on scrolling its own list.
- ContextPicker and ModelPicker use it on desktop; the phone sheets and their
  inner list caps are unchanged (the list max-height became a parameter: the
  sheet caps it, the desktop panel scrolls instead).
- teach-test pins it: the panel's rect is fully inside the viewport,
  elementFromPoint over it lands on the panel, it is not a descendant of the
  scrolling card, and picking a model updates the chip + freezmo.chat.model.v1
  (46 checks). Chat and the phone sheets re-verified unchanged (110 + 128).

Version 0.8.3, SW v105.
This commit is contained in:
Martin Dvořák 2026-10-18 21:55:47 +00:00
commit 705808d45a
7 changed files with 215 additions and 82 deletions

View file

@ -1,9 +1,10 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { PickerPopover } from "./PickerPopover";
import { CheckCircleIcon, FolderIcon, LayersIcon } from "@/components/icons";
import { useI18n } from "@/lib/i18n";
import { springSheet } from "@/lib/motion";
@ -35,7 +36,6 @@ export function ContextPicker({
const { t, plural } = useI18n();
const isPhone = useIsPhone();
const reduceMotion = useReducedMotion();
const panel = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
@ -46,21 +46,6 @@ export function ContextPicker({
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
/* Desktop dropdown: a press anywhere outside closes it, except on the
trigger chips, which toggle their own popover (see Composer). */
useEffect(() => {
if (!open || isPhone) return;
const onDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (panel.current?.contains(target)) return;
if (target.closest("[data-picker-trigger]")) return;
onClose();
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [open, isPhone, onClose]);
const selected = scope.kind === "decks" ? new Set(scope.deckIds) : new Set<string>();
const choose = (next: ChatScope) => {
@ -120,12 +105,15 @@ export function ContextPicker({
</button>
);
const body = (
const body = (listMax: string) => (
<div className="space-y-2">
{modeRow("all", t("chat.scope.all"), t("chat.scope.allNote"), <LayersIcon className="size-5" />)}
{modeRow("decks", t("chat.scope.pick"), t("chat.scope.pickNote"), <FolderIcon className="size-5" />)}
{scope.kind === "decks" ? (
<div className="max-h-[38dvh] space-y-1 overflow-y-auto overscroll-contain rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] p-2">
<div
className="space-y-1 overflow-y-auto overscroll-contain rounded-[var(--radius-control)] border border-[var(--glass-border)] bg-[var(--surface-sunken)] p-2"
style={{ maxHeight: listMax }}
>
{folders.map((folder) => {
const inFolder = decks.filter((deck) => deck.folderId === folder.id);
if (inFolder.length === 0) return null;
@ -211,7 +199,7 @@ export function ContextPicker({
>
<div className="sheet-grabber mx-auto" aria-hidden />
<h2 className="px-1 pb-3 pt-2 text-[15px] font-semibold text-[var(--text)]">{t("chat.scope.title")}</h2>
{body}
{body("38dvh")}
<p className="px-1 py-3 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div>
</div>
@ -221,25 +209,12 @@ export function ContextPicker({
}
return (
<AnimatePresence>
{open ? (
<motion.div
ref={panel}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.98 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.16, ease: "easeOut" }}
className="glass-panel absolute bottom-full left-0 z-40 mb-2 w-96 p-3"
role="dialog"
aria-label={t("chat.scope.title")}
>
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")}
</h2>
{body}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</motion.div>
) : null}
</AnimatePresence>
<PickerPopover open={open} onClose={onClose} label={t("chat.scope.title")} align="left" width="w-96">
<h2 className="px-1 pb-2 text-[13px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("chat.scope.title")}
</h2>
{body("none")}
<p className="px-1 pt-2 text-[11.5px] text-[var(--text-subtle)]">{t("chat.scope.note")}</p>
</PickerPopover>
);
}