fix(base): keep view-tab context menu right-click-only, close on outside / Esc

Switching to <Menu> in the previous fix made left-click on a tab
also open the menu — Mantine Menu auto-toggles via its Target's
click handler in controlled mode, which we don't want (left-click
should switch view, only right-click should open the context menu).

Switch back to <Popover> (no auto-toggle on Target click) and wire
outside-click / Escape close paths manually with a useEffect that's
active only while the menu is open. Capture-phase mousedown so we
run before grid-container's outside-click logic. Left-click on the
tab now calls onClick (switch view) and dismisses the menu in the
same gesture if it happens to be open.
This commit is contained in:
Philipinho
2026-04-28 19:05:39 +01:00
parent 38f7ffefe0
commit 0ec30ba804
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { import {
Group, Group,
UnstyledButton, UnstyledButton,
@@ -6,7 +6,9 @@ import {
ActionIcon, ActionIcon,
Tooltip, Tooltip,
TextInput, TextInput,
Menu, Popover,
Stack,
Divider,
} from "@mantine/core"; } from "@mantine/core";
import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react"; import { IconPlus, IconPencil, IconTrash, IconTable } from "@tabler/icons-react";
import { IBaseView } from "@/features/base/types/base.types"; import { IBaseView } from "@/features/base/types/base.types";
@@ -15,6 +17,7 @@ import {
useDeleteViewMutation, useDeleteViewMutation,
} from "@/features/base/queries/base-view-query"; } from "@/features/base/queries/base-view-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import cellClasses from "@/features/base/styles/cells.module.css";
type ViewTabsProps = { type ViewTabsProps = {
views: IBaseView[]; views: IBaseView[];
@@ -147,6 +150,34 @@ function ViewTab({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [menuOpened, setMenuOpened] = useState(false); const [menuOpened, setMenuOpened] = useState(false);
const targetRef = useRef<HTMLButtonElement | null>(null);
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Mantine Popover's built-in closeOnClickOutside / closeOnEscape
// only fire when focus is inside the dropdown — but the menu opens
// via right-click (focus stays on body), so neither ever triggers.
// Wire the close paths ourselves while the menu is open. Capture
// phase mousedown so we run before grid-container's outside-click
// logic (which would otherwise blur the focused element first and
// cause weirdness).
useEffect(() => {
if (!menuOpened) return;
const onMouseDown = (e: MouseEvent) => {
const target = e.target as Node;
if (dropdownRef.current?.contains(target)) return;
if (targetRef.current?.contains(target)) return;
setMenuOpened(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpened(false);
};
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKey);
};
}, [menuOpened]);
if (isEditing) { if (isEditing) {
return ( return (
@@ -162,22 +193,27 @@ function ViewTab({
); );
} }
const handleTabClick = useCallback(() => {
// Left-click on the tab is "switch view"; if the menu happens to
// be open from a prior right-click, dismiss it as part of the
// same gesture so the user doesn't have to click twice.
if (menuOpened) setMenuOpened(false);
onClick();
}, [menuOpened, onClick]);
return ( return (
<Menu <Popover
opened={menuOpened} opened={menuOpened}
onChange={setMenuOpened} onClose={() => setMenuOpened(false)}
position="bottom-start" position="bottom-start"
shadow="md" shadow="md"
width={180} width={180}
withinPortal withinPortal
// Default Menu behavior: closeOnClickOutside, closeOnEscape, and
// closeOnItemClick all true — replaces the manual setMenuOpened
// calls and gives the popover the same outside-click / Esc
// semantics every other Mantine menu in the app has.
> >
<Menu.Target> <Popover.Target>
<UnstyledButton <UnstyledButton
onClick={onClick} ref={targetRef}
onClick={handleTabClick}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
setMenuOpened(true); setMenuOpened(true);
@@ -198,27 +234,41 @@ function ViewTab({
</Text> </Text>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Popover.Target>
<Menu.Dropdown> <Popover.Dropdown ref={dropdownRef} p={4}>
<Menu.Item <Stack gap={0}>
leftSection={<IconPencil size={14} />} <UnstyledButton
onClick={onRenameStart} className={cellClasses.menuItem}
> onClick={() => {
{t("Rename")} setMenuOpened(false);
</Menu.Item> onRenameStart();
{canDelete && ( }}
<> >
<Menu.Divider /> <Group gap={8} wrap="nowrap">
<Menu.Item <IconPencil size={14} />
leftSection={<IconTrash size={14} />} <Text size="sm">{t("Rename")}</Text>
onClick={onDelete} </Group>
color="red" </UnstyledButton>
> {canDelete && (
{t("Delete view")} <>
</Menu.Item> <Divider my={4} />
</> <UnstyledButton
)} className={cellClasses.menuItem}
</Menu.Dropdown> onClick={() => {
</Menu> setMenuOpened(false);
onDelete();
}}
style={{ color: "var(--mantine-color-red-6)" }}
>
<Group gap={8} wrap="nowrap">
<IconTrash size={14} />
<Text size="sm">{t("Delete view")}</Text>
</Group>
</UnstyledButton>
</>
)}
</Stack>
</Popover.Dropdown>
</Popover>
); );
} }