diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx
index dcd800dc8..da1701e8e 100644
--- a/apps/docs/docs/getting-started/after-the-installation.mdx
+++ b/apps/docs/docs/getting-started/after-the-installation.mdx
@@ -194,11 +194,15 @@ To configure this, click on the small 3 dots at the top of the item in the edit
The tile menu contains the item editor and the separate move-and-resize tools.
-In the modal you can edit the app that you want to display:
+The side editor keeps the selected tile visible while you choose the app and adjust its display settings:
-
- Use the Edit item modal to choose the app and configure how its tile is displayed.
+
+ The selected tile stays highlighted while the rest of the board is subdued.
As soon as you save your changes, the tile on the board will refresh and display your app.
diff --git a/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png b/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png
index 8fe412aa7..795c7b5d9 100644
Binary files a/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png and b/apps/docs/docs/getting-started/img/onboarding-edit-item-current.png differ
diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx
index 2b3a79dcc..6b6f2ce06 100644
--- a/apps/nextjs/src/components/board/items/item-menu.tsx
+++ b/apps/nextjs/src/components/board/items/item-menu.tsx
@@ -3,6 +3,7 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
+import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
@@ -33,6 +34,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
+ const board = useRequiredBoard();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useI18n("item");
@@ -89,6 +91,7 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
integrationSupport: "supportedIntegrations" in definition,
settings,
itemId: item.id,
+ boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
{
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
index cb2f6913b..6c9a98362 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -277,6 +277,7 @@ const ItemSelectModalContent = ({
integrationData: [],
integrationSupport: false,
settings,
+ boardId: board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${customWidgetDefinition.name}`,
@@ -346,6 +347,7 @@ const ItemSelectModalContent = ({
integrationData: availableIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
+ boardId: board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${getWidgetName(kind, titleT)}`,
diff --git a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
index 0a6f448b2..a1941ab4e 100644
--- a/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
+++ b/apps/nextjs/src/components/board/items/lazy-widget-edit-modal.tsx
@@ -157,6 +157,8 @@ export const LazyWidgetEditModal = createModal>
defaultTitle(t) {
return t("item.edit.title");
},
+ presentation: "inspector",
+ withOverlay: false,
size: modalSizeForm,
closeOnClickOutside: false,
});
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index 13c40383b..1767298ff 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -160,7 +160,7 @@ export const WidgetContextMenu = ({
definition,
value: {
advancedOptions: item.advancedOptions,
- options: item.options,
+ options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
@@ -189,6 +189,8 @@ export const WidgetContextMenu = ({
),
integrationSupport: definition.supportedIntegrations !== undefined,
settings,
+ itemId: item.id,
+ boardId: board.id,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -197,11 +199,13 @@ export const WidgetContextMenu = ({
},
);
}, [
+ board.id,
definition,
integrationData,
item,
handleRefetch,
openModal,
+ options,
persistBoard,
settings,
updateItemAdvancedOptions,
diff --git a/packages/modals/src/modal-presentation.module.css b/packages/modals/src/modal-presentation.module.css
index 22949cd8d..ceb9df7fd 100644
--- a/packages/modals/src/modal-presentation.module.css
+++ b/packages/modals/src/modal-presentation.module.css
@@ -7,9 +7,11 @@
.inspectorContent {
--modal-content-flex: 0 0 100%;
--modal-content-max-height: min(88dvh, 52rem);
+ --modal-size: 100%;
width: 100%;
max-width: 100%;
+ min-width: 0;
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
@@ -23,8 +25,13 @@
--modal-content-flex: 0 0 min(34rem, 46vw);
--modal-content-height: 100dvh;
--modal-content-max-height: 100dvh;
+ --modal-size: min(34rem, 46vw);
width: min(34rem, 46vw);
border-radius: 0;
}
+
+ .inspectorInner:has([data-widget-inspector-side="left"]) {
+ justify-content: flex-start;
+ }
}
diff --git a/packages/modals/src/type.ts b/packages/modals/src/type.ts
index 5faa421d2..30df9ba4d 100644
--- a/packages/modals/src/type.ts
+++ b/packages/modals/src/type.ts
@@ -19,6 +19,7 @@ export type CreateModalOptions = Pick<
| "centered"
| "keepMounted"
| "withCloseButton"
+ | "withOverlay"
| "zIndex"
| "scrollAreaComponent"
| "yOffset"
diff --git a/packages/widgets/src/modals/widget-advanced-options-modal.tsx b/packages/widgets/src/modals/widget-advanced-options-modal.tsx
index 184fd4678..c148f8b88 100644
--- a/packages/widgets/src/modals/widget-advanced-options-modal.tsx
+++ b/packages/widgets/src/modals/widget-advanced-options-modal.tsx
@@ -17,7 +17,10 @@ export const WidgetAdvancedOptionsModal = createModal(({ actions, in
const t = useI18n("item.edit");
const theme = useMantineTheme();
const form = useForm({
- initialValues: innerProps.advancedOptions,
+ initialValues: {
+ ...innerProps.advancedOptions,
+ title: innerProps.advancedOptions.title ?? "",
+ },
});
const handleSubmit = (values: BoardItemAdvancedOptions) => {
innerProps.onSuccess({
diff --git a/packages/widgets/src/modals/widget-edit-focus-overlay.tsx b/packages/widgets/src/modals/widget-edit-focus-overlay.tsx
new file mode 100644
index 000000000..77fec2961
--- /dev/null
+++ b/packages/widgets/src/modals/widget-edit-focus-overlay.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useState } from "react";
+import { Box, getDefaultZIndex, Overlay, Portal } from "@mantine/core";
+import { useIsomorphicEffect } from "@mantine/hooks";
+
+import classes from "./widget-edit-modal.module.css";
+
+interface FocusRect {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+}
+
+export type WidgetInspectorSide = "left" | "right";
+
+const focusGap = 6;
+
+const getFocusRect = (element: HTMLElement): FocusRect | null => {
+ const rect = element.getBoundingClientRect();
+ const top = Math.max(4, rect.top - focusGap);
+ const left = Math.max(4, rect.left - focusGap);
+ const right = Math.min(window.innerWidth - 4, rect.right + focusGap);
+ const bottom = Math.min(window.innerHeight - 4, rect.bottom + focusGap);
+
+ if (right <= left || bottom <= top) return null;
+
+ return {
+ top,
+ left,
+ width: right - left,
+ height: bottom - top,
+ };
+};
+
+const focusRectsMatch = (current: FocusRect | null, next: FocusRect | null) => {
+ if (current === next) return true;
+ if (!current || !next) return false;
+
+ return (
+ current.top === next.top &&
+ current.left === next.left &&
+ current.width === next.width &&
+ current.height === next.height
+ );
+};
+
+export const useWidgetEditFocus = (itemId: string | undefined) => {
+ const [focusRect, setFocusRect] = useState(null);
+
+ useIsomorphicEffect(() => {
+ if (!itemId) {
+ setFocusRect(null);
+ return;
+ }
+
+ const element = document.querySelector(`[data-item-id="${CSS.escape(itemId)}"]`);
+ if (!element) {
+ setFocusRect(null);
+ return;
+ }
+
+ let animationFrame = 0;
+ const update = () => {
+ animationFrame = 0;
+ const nextRect = getFocusRect(element);
+ setFocusRect((current) => (focusRectsMatch(current, nextRect) ? current : nextRect));
+ };
+ const scheduleUpdate = () => {
+ if (animationFrame !== 0) return;
+ animationFrame = window.requestAnimationFrame(update);
+ };
+
+ update();
+ const resizeObserver = new ResizeObserver(scheduleUpdate);
+ resizeObserver.observe(element);
+ window.addEventListener("resize", scheduleUpdate);
+ window.addEventListener("scroll", scheduleUpdate, true);
+
+ return () => {
+ resizeObserver.disconnect();
+ window.removeEventListener("resize", scheduleUpdate);
+ window.removeEventListener("scroll", scheduleUpdate, true);
+ window.cancelAnimationFrame(animationFrame);
+ };
+ }, [itemId]);
+
+ const inspectorSide: WidgetInspectorSide =
+ focusRect && focusRect.left + focusRect.width / 2 > window.innerWidth / 2 ? "left" : "right";
+
+ return { focusRect, inspectorSide };
+};
+
+export const WidgetEditFocusOverlay = ({ focusRect }: { focusRect: FocusRect | null }) => (
+
+ {focusRect ? (
+
+ ) : (
+
+ )}
+
+);
diff --git a/packages/widgets/src/modals/widget-edit-modal.module.css b/packages/widgets/src/modals/widget-edit-modal.module.css
new file mode 100644
index 000000000..65f0bf333
--- /dev/null
+++ b/packages/widgets/src/modals/widget-edit-modal.module.css
@@ -0,0 +1,74 @@
+.panel {
+ min-width: 0;
+}
+
+.settings {
+ min-width: 0;
+}
+
+.switchGrid {
+ align-items: stretch;
+}
+
+.switchOption {
+ display: flex;
+ min-height: 4.25rem;
+ align-items: center;
+ padding: 0.625rem 0.75rem;
+ border: 1px solid var(--mantine-color-default-border);
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-color-body);
+}
+
+.switchOption > *,
+.switchOption :global(.mantine-Switch-root),
+.switchOption :global(.mantine-Switch-body) {
+ width: 100%;
+}
+
+.switchOption :global(.mantine-Switch-body) {
+ flex-direction: row-reverse;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--mantine-spacing-sm);
+}
+
+.switchOption :global(.mantine-Switch-labelWrapper) {
+ min-width: 0;
+ flex: 1;
+}
+
+.footer {
+ position: sticky;
+ z-index: 2;
+ bottom: calc(var(--mantine-spacing-md) * -1);
+ margin-inline: calc(var(--mantine-spacing-md) * -1);
+ margin-bottom: calc(var(--mantine-spacing-md) * -1);
+ padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
+ border-top: 1px solid var(--mantine-color-default-border);
+ background: color-mix(in srgb, var(--mantine-color-body) 96%, transparent);
+ backdrop-filter: blur(10px);
+}
+
+.focusFrame {
+ position: fixed;
+ pointer-events: none;
+ border: 2px solid var(--mantine-primary-color-filled);
+ border-radius: var(--mantine-radius-md);
+ box-shadow:
+ 0 0 0 2px color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent),
+ 0 0 2.5rem color-mix(in srgb, var(--mantine-primary-color-filled) 28%, transparent),
+ 0 0 0 100vmax rgb(0 0 0 / 0.58);
+ clip-path: inset(-100vmax);
+ transition:
+ top 160ms ease,
+ left 160ms ease,
+ width 160ms ease,
+ height 160ms ease;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .focusFrame {
+ transition: none;
+ }
+}
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 61bfeac38..0833612fb 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import type { ComponentType, FormEvent } from "react";
-import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
+import { Box, Button, Group, SimpleGrid, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form";
import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
@@ -11,7 +11,7 @@ import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
-import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
+import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
@@ -26,8 +26,10 @@ import { OPTIONS_SUPER_REFINE } from "../options";
import type { IntegrationSelectOption } from "../widget-integration-select";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import { WidgetAdvancedOptionsModal } from "./widget-advanced-options-modal";
+import { useWidgetEditFocus, WidgetEditFocusOverlay } from "./widget-edit-focus-overlay";
import type { EmbeddedAppEditFormHandle } from "./embedded-app-edit-form";
import { EmbeddedAppEditForm } from "./embedded-app-edit-form";
+import classes from "./widget-edit-modal.module.css";
export interface WidgetEditModalState {
options: Record;
@@ -81,6 +83,8 @@ export const WidgetEditModal = createModal>(({
const [mountedIntegrationIds, setMountedIntegrationIds] = useState([]);
const [activeIntegrationId, setActiveIntegrationId] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const widgetFormId = useId();
+ const { focusRect, inspectorSide } = useWidgetEditFocus(innerProps.itemId);
z.config({
customError: zodErrorMap(t),
@@ -231,63 +235,167 @@ export const WidgetEditModal = createModal>(({
}
: undefined;
+ const selectedIntegrationKinds = innerProps.integrationData
+ .filter(({ id }) => form.values.integrationIds.includes(id))
+ .map(({ kind }) => kind);
+ const visibleOptions = Object.entries(options).filter(([, value]) => {
+ const Input = getInputForType(value.type);
+ if (!Input) return false;
+ return !value.shouldHide?.(form.values.options as never, selectedIntegrationKinds);
+ });
+ const switchOptions = visibleOptions.filter(([, value]) => value.type === "switch");
+ const otherOptions = visibleOptions.filter(([, value]) => value.type !== "switch");
+
const widgetFormContent = (
-
- {canConfigureWidget && innerProps.integrationSupport && (
- 1}
- withAsterisk={integrationsRequired}
- onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
- {...form.getInputProps("integrationIds")}
- />
- )}
- {canConfigureWidget &&
- Object.entries(options).map(([key, value]) => {
- const Input = getInputForType(value.type);
+
+ );
+
+ return (
+ <>
+
+
+
+
+ {showResourceTabs ? (
+
+
+ {tItem("tab.widget")}
+ {showAppTab && {tItem("tab.app")}}
+ {showIntegrationTab && {tItem("tab.integration")}}
+
+
+ {widgetFormContent}
+
+ {showAppTab && (
+
+ {appId && }
+
+ )}
+ {showIntegrationTab && (
+
+
+ {displayedIntegrationId === null && (
+ <>
+
+ {tItem("integration.description")}
+
+ {selectedIntegrations.map((integration) => {
+ const canEdit = editableIntegrationIds.includes(integration.id);
+ return (
+
+
+
+
+
+ {integration.name}
+
+
+ {integration.url}
+
+
+
+ {canEdit ? (
+ }
+ onClick={() => beginEditingIntegration(integration.id)}
+ aria-label={tItem("integration.editLabel", { name: integration.name })}
+ >
+ {tItem("integration.action")}
+
+ ) : (
+
+ {tItem("integration.fullAccessRequired")}
+
+ )}
+
+ );
+ })}
+ >
+ )}
+ {displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
+ }
+ onClick={() => setActiveIntegrationId(null)}
+ style={{ alignSelf: "start" }}
+ >
+ {tCommon("action.previous")}
+
+ )}
+ {integrationIdsToRender.map((integrationId) => (
+
+ {IntegrationEditForm && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+ ) : (
+ widgetFormContent
+ )}
+
+
- }
- />
- )}
-
- );
-
- return (
-
-
+ >
);
}).withOptions({
keepMounted: true,
defaultTitle(t) {
return t("item.edit.title");
},
+ presentation: "inspector",
+ withOverlay: false,
size: modalSizeForm,
closeOnClickOutside: false,
});