From e8aeca96021f478da9326cd31f27163099a3e918 Mon Sep 17 00:00:00 2001 From: Douwe Zijlstra Date: Mon, 14 Sep 2026 15:48:57 +0200 Subject: [PATCH] fix: open the tag picker from the labels hover quick-action The Labels quick-action on an email row called `onSetTag(null)`, which clears every tag on the message rather than offering one to apply. On an untagged message that writes nothing, so the button appeared dead: no picker, no feedback. Assigning a tag from the opened email worked, since the reading pane renders a real picker. Clicking the row's tag icon now opens the same `TagPicker` used by the context menu and the reading pane, wired to the existing `onSetTag` handler, so a tag can be applied or removed without opening the message. The picker is portalled to the body: every row sits inside a `transform`ed virtual item, which would otherwise make a `fixed` child scroll with the list and clip it at the scroller's edge. It flips above the row near the viewport bottom, and dismisses on outside click, Escape, scroll or resize. The hover toolbar stays visible while the picker is open, since the row is no longer hovered once the pointer moves onto it. Covered by integration/tests/14-hover-tag.spec.ts, which checks the picker opens, the keyword reaches the server under `$label:`, a second click removes it, and the click does not fall through and open the message. Co-Authored-By: Claude Opus 5 (1M context) --- components/email/email-hover-actions.tsx | 102 ++++++++++++++++++- integration/tests/14-hover-tag.spec.ts | 119 +++++++++++++++++++++++ 2 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 integration/tests/14-hover-tag.spec.ts diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx index a7cf5e866..e4eeeb19e 100644 --- a/components/email/email-hover-actions.tsx +++ b/components/email/email-hover-actions.tsx @@ -1,5 +1,7 @@ "use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Email } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; import type { HoverAction } from "@/stores/settings-store"; @@ -7,6 +9,8 @@ import { cn } from "@/lib/utils"; import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react"; import { useTranslations } from "next-intl"; import { useIsMobile } from "@/hooks/use-media-query"; +import { TagPicker } from "@/components/email/tag-picker"; +import { getEmailTagIds } from "@/lib/thread-utils"; interface EmailHoverActionsProps { email: Email; @@ -69,6 +73,10 @@ const CORNER_CLASSES = { 'bottom-left': 'bottom-1 left-1', } as const; +/** Roughly the picker's height; used to flip it above the row near the viewport bottom. */ +const TAG_PICKER_MAX_HEIGHT = 320; +const TAG_PICKER_WIDTH = 224; + export function EmailHoverActions({ email, backgroundClassName = "bg-muted", @@ -88,8 +96,43 @@ export function EmailHoverActions({ const t = useTranslations("settings.email_behavior.hover_actions"); const isMobile = useIsMobile(); + // The tag quick-action opens the same picker the context menu and the reading + // pane use. It is portalled to the body because every row sits inside a + // `transform`ed virtual item, which would make a `fixed` child scroll with the + // list and clip it at the scroller's edge. + const [tagPickerPos, setTagPickerPos] = useState<{ top: number; left: number } | null>(null); + const pickerRef = useRef(null); + const tagButtonRef = useRef(null); + const closeTagPicker = useCallback(() => setTagPickerPos(null), []); + + useEffect(() => { + if (!tagPickerPos) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + // The anchor is left to its own click handler, which toggles the picker + // shut - closing here first would let that click reopen it. + if (tagButtonRef.current?.contains(target)) return; + if (!pickerRef.current?.contains(target)) closeTagPicker(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closeTagPicker(); + }; + // The anchor moves with the list, so scrolling dismisses rather than chases it. + document.addEventListener("pointerdown", onPointerDown, true); + document.addEventListener("keydown", onKeyDown); + window.addEventListener("scroll", closeTagPicker, true); + window.addEventListener("resize", closeTagPicker); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + document.removeEventListener("keydown", onKeyDown); + window.removeEventListener("scroll", closeTagPicker, true); + window.removeEventListener("resize", closeTagPicker); + }; + }, [tagPickerPos, closeTagPicker]); + const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const currentTagIds = getEmailTagIds(email.keywords); const hoverBackgroundClassName = backgroundClassName; if (isMobile) return null; @@ -111,9 +154,28 @@ export function EmailHoverActions({ case "archive": onArchive?.(); break; - case "tag": - onSetTag?.(null); + case "tag": { + // Previously this cleared every tag on the message, which is a no-op on + // an untagged one - hence "clicking it does nothing". Open the picker. + if (!onSetTag) break; + tagButtonRef.current = e.currentTarget as HTMLElement; + const rect = e.currentTarget.getBoundingClientRect(); + setTagPickerPos((open) => + open + ? null + : { + top: + rect.bottom + TAG_PICKER_MAX_HEIGHT > window.innerHeight + ? Math.max(8, rect.top - TAG_PICKER_MAX_HEIGHT - 4) + : rect.bottom + 4, + left: Math.max( + 8, + Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8), + ), + }, + ); break; + } case "spam": if (isInJunk) onUndoSpam?.(); else onMarkAsSpam?.(); @@ -144,8 +206,13 @@ export function EmailHoverActions({ return (