Files
homarr/apps/nextjs/src/components/board/items/item-menu.tsx
T
ajnart 3237d5953f fix(board): render widget options above grid resize handles
The widget options ActionIcon rendered inside the widget Card, whose
containerType: size creates a stacking context, trapping its zIndex and
letting the dnd-kit resize handles paint over and block it.

Hoist BoardItemMenu out of the Card to the item root (same layer as the
title badge) and raise it to zIndex 30, above the handles (20). Error
recovery after edits is preserved via resetKeys=[item], which replaces
the resetErrorBoundary prop the in-card menu used to receive.
2026-08-16 14:15:14 +02:00

150 lines
5.3 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-item";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
interface BoardItemMenuProps {
item: SectionItem;
definition: WidgetDefinition;
resetErrorBoundary?: () => void;
}
export const BoardItemMenu = (props: BoardItemMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
if (!session || !isEditMode) return null;
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { integrations: integrationData = [], section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
// Reset error boundary on next render if item has been edited
useEffect(() => {
if (refResetErrorBoundaryOnNextRender.current) {
resetErrorBoundary?.();
refResetErrorBoundaryOnNextRender.current = false;
}
}, [item, resetErrorBoundary]);
const openEditModal = () => {
openModal(
{
kind: item.kind,
definition,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds, advancedOptions }) => {
updateItemOptions({
itemId: item.id,
newOptions: options,
});
updateItemAdvancedOptions({
itemId: item.id,
newAdvancedOptions: advancedOptions,
});
updateItemIntegrations({
itemId: item.id,
newIntegrations: integrationIds,
});
refResetErrorBoundaryOnNextRender.current = true;
},
integrationData: integrationData.filter(
(integration) =>
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
title(translate) {
return `${translate("item.edit.title")} - ${translate(`widget.${item.kind}.name`)}`;
},
},
);
};
return (
<Menu withinPortal position="right-start" arrowPosition="center" opened={isMenuOpen} onChange={setIsMenuOpen}>
<Menu.Target>
<ActionIcon
variant="default"
size={24}
radius="sm"
pos="absolute"
top={12}
right={8}
style={{ zIndex: 30 }}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
<IconDotsVertical size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
<Menu.Item
leftSection={<IconPencil size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
>
{tItem("action.edit")}
</Menu.Item>
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
openMoveModal({
entry: item,
sourceSectionId: section.id,
});
}}
>
{tItem("action.moveResize")}
</Menu.Item>
{canDuplicate && (
<Menu.Item leftSection={<IconCopy size={16} />} onClick={() => duplicateItem({ itemId: item.id })}>
{tItem("action.duplicate")}
</Menu.Item>
)}
<Menu.Divider />
<BoardRemoveConfirmationMenuItem
label={tItem("action.remove")}
confirmationLabel={tItem("remove.message")}
onConfirm={() => removeItem({ itemId: item.id })}
/>
</Menu.Dropdown>
</Menu>
);
};