"use client"; import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { createPortal } from "react-dom"; import { IconButton } from "@/components/ui/icon-button"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { Tag } from "@/components/icons"; import { TagPicker } from "./tag-picker"; import type { TagScope } from "@/hooks/use-account-tags"; const TAG_PICKER_WIDTH = 224; /** * The tag entry of the selection toolbar: opens the shared tag picker for * every selected message at once. (#1077) * * The picker is portalled to the body because the toolbar clips its overflow * to animate open and shut. */ export function BatchTagButton({ title, selectedIds, partialIds, onToggle, active, disabled, scope = null, }: { title: string; /** Tags every selected message carries. */ selectedIds: string[]; /** Tags only some of them carry. */ partialIds: string[]; onToggle: (tagId: string) => void; /** Whether there is a selection to tag; the picker shuts without one. */ active: boolean; disabled?: boolean; /** The account of the whole selection (commonTagScope); null: the user's own tags. */ scope?: TagScope | null; }) { const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const buttonRef = useRef(null); const close = useCallback(() => setPos(null), []); const isOpen = pos !== null && active; // The picker sits at the end of : the focus moves into it on open, // the arrow keys walk the tags, and the focus goes back to the button on // close (Escape, Tab, a pick that closes it). const { menuRef: pickerRef, onKeyDown: onMenuKeyDown } = useMenuNavigation({ open: isOpen, onClose: close, triggerRef: buttonRef, }); const onPickerKeyDown = (event: ReactKeyboardEvent) => { // Home and End belong to the text fields (filter, new tag). if ((event.key === "Home" || event.key === "End") && event.target instanceof HTMLInputElement) return; onMenuKeyDown(event); }; useEffect(() => { if (!pos) 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 (buttonRef.current?.contains(target)) return; if (!pickerRef.current?.contains(target)) close(); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") close(); }; document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("keydown", onKeyDown); window.addEventListener("resize", close); return () => { document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("keydown", onKeyDown); window.removeEventListener("resize", close); }; }, [pos, close, pickerRef]); return ( <> { const rect = event.currentTarget.getBoundingClientRect(); setPos((open) => open ? null : { top: rect.bottom + 4, left: Math.max(8, Math.min(rect.left, window.innerWidth - TAG_PICKER_WIDTH - 8)), }, ); }} aria-haspopup="menu" aria-expanded={isOpen} disabled={disabled} > {isOpen && createPortal(
, document.body, )} ); }