feat(widgets): add right-click context menu with auto-generated toggle actions

- Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu
- Extend WidgetDefinition with contextActions API for widget-specific actions
- Auto-generate context menu toggle items for switch-type widget options
- Add 'Search' context action to media-requests widgets (list + stats)
- Keep existing dots-button menu for discoverability in edit mode
- Add widgetStateRef to WidgetComponentProps for client-side-only state
- Add skipContextMenu opt-out on switch option definitions
This commit is contained in:
ajnart committed 2026-06-29 15:10:34 +02:00
1 parent 597b5fbc8d
commit 83b93e17da
7 files changed
+364 -25

No files matched your search

@@ -1,3 +1,4 @@
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -16,6 +17,7 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -30,30 +32,37 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const cardElement = (
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
);
return (
<>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
{cardElement}
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -83,6 +92,7 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -0,0 +1,273 @@
"use client";
import { useCallback, useMemo } from "react";
import { Menu } from "@mantine/core";
import {
IconCopy,
IconLayoutKanban,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import {
reduceWidgetOptionsWithDefaultValues,
widgetImports,
} from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
children: React.ReactNode;
}
export const WidgetContextMenu = ({
item,
widgetStateRef,
children,
}: WidgetContextMenuProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const t = useI18n();
const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const {
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
duplicateItem,
removeItem,
} = useItemActions();
const { data: integrationData, isPending } =
clientApi.integration.all.useQuery();
const currentDefinition = useMemo(
() => widgetImports[item.kind].definition,
[item.kind],
);
const { gridstack } = useSectionContext().refs;
const options = useMemo(
() =>
reduceWidgetOptionsWithDefaultValues(
item.kind,
settings,
item.options,
) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const autoToggleActions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(
settings,
) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions)
.filter(([, def]) => def.type === "switch" && !def.skipContextMenu)
.map<WidgetContextMenuAction>(([key]) => ({
key: `toggle-${key}`,
label: `widget.${item.kind}.option.${key}.label`,
onClick: () => {
const currentValue = options[key] as boolean | undefined;
const newOptions = { [key]: !currentValue };
updateItemOptions({
itemId: item.id,
newOptions: { ...options, ...newOptions },
});
},
}));
}, [currentDefinition, settings, options, item, updateItemOptions]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({
itemId: item.id,
newOptions: { ...options, ...partial },
});
},
integrationIds: item.integrationIds,
context: {
isEditMode,
boardId: board.id,
itemId: item.id,
},
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [
currentDefinition,
options,
item,
updateItemOptions,
isEditMode,
widgetStateRef,
]);
const openEditModal = useCallback(() => {
openModal(
{
kind: item.kind,
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,
});
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some(
(kind) => kind === integration.kind,
),
),
integrationSupport:
"supportedIntegrations" in currentDefinition,
settings,
appId:
item.kind === "app"
? (item.options.appId as string | undefined)
: undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (translateFn: any) => `${translateFn("item.edit.title")} - ${translateFn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
item,
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
settings,
]);
const openRemoveModal = useCallback(() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => {
removeItem({ itemId: item.id });
},
});
}, [openConfirmModal, tItem, removeItem, item.id]);
const allActions = useMemo(() => {
const actions: WidgetContextMenuAction[] = [];
if (isEditMode) {
actions.push({
key: "edit",
label: tItem("action.edit"),
icon: IconPencil,
onClick: openEditModal,
});
actions.push({
key: "moveResize",
label: tItem("action.moveResize"),
icon: IconLayoutKanban,
onClick: () => {
if (!gridstack.current) return;
openMoveModal({
item,
columnCount: gridstack.current.getColumn(),
gridStack: gridstack.current,
});
},
});
actions.push({
key: "duplicate",
label: tItem("action.duplicate"),
icon: IconCopy,
onClick: () => duplicateItem({ itemId: item.id }),
});
}
const widgetActions: WidgetContextMenuAction[] = [
...autoToggleActions,
...widgetContextActions,
];
actions.push(...widgetActions);
if (isEditMode) {
actions.push({
key: "remove",
label: tItem("action.remove"),
icon: IconTrash,
onClick: openRemoveModal,
color: "red",
});
}
return actions.filter((a) => !a.hidden);
}, [
isEditMode,
autoToggleActions,
widgetContextActions,
openEditModal,
openRemoveModal,
tItem,
gridstack,
item,
openMoveModal,
duplicateItem,
]);
if (isPending) return <>{children}</>;
return (
<Menu shadow="md" width={200}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{allActions.map((action) => {
const label = String(translateIfNecessary(t, action.label));
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
c={action.color}
>
{label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
};
+34 -1
View File
@@ -1,3 +1,4 @@
import type React from "react";
import type { LoaderComponent } from "next/dynamic";
import type { QueryClient } from "@tanstack/react-query";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -9,7 +10,35 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from ".";
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
import type { inferOptionsFromCreator, inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
export interface WidgetContextMenuAction {
key: string;
label: stringOrTranslation;
icon?: TablerIcon;
onClick: () => void;
hidden?: boolean;
disabled?: boolean;
color?: string;
}
export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
}
export interface WidgetContextActionProps<
TKind extends WidgetKind,
TOptions extends WidgetOptionsRecord = WidgetOptionsRecord,
> {
kind: TKind;
options: inferOptionsFromDefinition<TOptions>;
setOptions: (partial: Partial<inferOptionsFromDefinition<TOptions>>) => void;
integrationIds: string[];
context: WidgetContextMenuContext;
widgetStateRef: React.MutableRefObject<Record<string, unknown> | null>;
}
const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -56,6 +85,9 @@ export interface WidgetDefinition {
}
>
>;
contextActions?: (
props: Omit<WidgetContextActionProps<WidgetKind>, "kind">,
) => WidgetContextMenuAction[];
}
export interface WidgetProps<TKind extends WidgetKind> {
@@ -70,6 +102,7 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
width: number;
height: number;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
};
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
+1 -1
View File
@@ -61,7 +61,7 @@ import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetDefinition, WidgetContextMenuAction, WidgetContextActionProps, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
@@ -1,6 +1,7 @@
import { IconZoomQuestion } from "@tabler/icons-react";
import { IconSearch, IconZoomQuestion } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
@@ -14,5 +15,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
}),
}));
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
@@ -1,6 +1,7 @@
import { IconChartBar } from "@tabler/icons-react";
import { IconChartBar, IconSearch } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { createWidgetDefinition } from "../../definition";
@@ -9,5 +10,15 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ
createOptions() {
return {};
},
contextActions: ({ integrationIds }) => [
{
key: "search",
label: (t) => t("search.mode.media.action.search.label"),
icon: IconSearch,
onClick: () => {
openMediaRequestSearch({ integrationIds });
},
},
],
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component"));
+1
View File
@@ -13,6 +13,7 @@ import type { ReleasesRepository } from "./releases/releases-repository";
interface CommonInput<TType> {
defaultValue?: TType;
withDescription?: boolean;
skipContextMenu?: boolean;
}
interface TextInput extends CommonInput<string> {