feat(ui): add situational tips system (#6648)

This commit is contained in:
Thomas authored and GitHub committed 2026-08-21 18:58:52 +02:00
1 parent 3c2f95d529
commit f0f9539466
20 files changed
+694 -493

No files matched your search

@@ -6,19 +6,19 @@ tags:
Homarr has the following keyboard shortcuts:
| Shortcut | Description |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
| Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
## Board editing
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
+5 -3
View File
@@ -210,7 +210,8 @@ Containers as destinations before the widget is created, so the common flow does
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
selection. A move is applied only when every selected item fits in the destination.
selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
selected item fits in the destination.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -329,8 +330,9 @@ This will disable the status check for apps.
Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
instead.
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
not use this widget menu, and available controls follow your board and integration permissions.
@@ -1,11 +1,13 @@
"use client";
import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardEmptyState } from "~/components/board/board-empty-state";
@@ -33,6 +35,7 @@ export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const tPreview = useScopedI18n("board.setting.section.layout.preview");
const tTips = useScopedI18n("tips");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
@@ -129,6 +132,16 @@ export const ClientBoard = () => {
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionProvider>
@@ -139,6 +139,49 @@ describe("BoardAdvancedFocusProvider preview interaction", () => {
expect(focus.active).toBeNull();
});
test("promotes a Shift preview to a persistent view with Shift+Control", async () => {
await startPreview();
await act(async () => vi.advanceTimersByTime(500));
expect(focus.active?.activation).toBe("preview");
await act(async () =>
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "Control", ctrlKey: true, shiftKey: true, cancelable: true }),
),
);
expect(focus.active?.activation).toBe("manual");
await act(async () => window.dispatchEvent(new KeyboardEvent("keyup", { key: "Shift" })));
expect(focus.active?.activation).toBe("manual");
});
test("promotes a Shift preview to a persistent view with Shift+Meta", async () => {
await startPreview();
await act(async () => vi.advanceTimersByTime(500));
await act(async () =>
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "Meta", metaKey: true, shiftKey: true, cancelable: true }),
),
);
expect(focus.active?.activation).toBe("manual");
});
test("opens a persistent view when the keep-open modifiers are held before the delay", async () => {
const source = host.querySelector<HTMLElement>("[data-testid='source']");
if (!source) throw new Error("Expected source fixture");
await act(async () => {
focus.hover("widget-1", source);
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }));
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "Shift", ctrlKey: true, shiftKey: true, cancelable: true }),
);
});
await act(async () => vi.advanceTimersByTime(500));
expect(focus.active?.activation).toBe("manual");
});
test("cancels the hold feedback and activation when the source is left early", async () => {
await startPreview();
@@ -55,6 +55,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
const activeRef = useRef<ActiveFocus | null>(null);
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
const shiftHeldRef = useRef(false);
const keepOpenHeldRef = useRef(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -151,10 +152,31 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
hoverTimerRef.current = setTimeout(() => {
hoverTimerRef.current = null;
setIsHoldPending(false);
open(hovered.itemId, hovered.source, { activation: "preview" });
const activation = keepOpenHeldRef.current ? "manual" : "preview";
open(hovered.itemId, hovered.source, {
activation,
autofocusClose: activation === "manual",
restoreFocusTarget: hovered.source,
});
}, HOVER_DELAY_MS);
}, [cancelHoverTimer, open]);
const promotePreviewToManual = useCallback(() => {
const current = activeRef.current;
if (!current || current.activation !== "preview") return false;
cancelHoverTimer();
cancelPreviewLeaveTimer();
cancelCloseTimer();
updateActive({
...current,
activation: "manual",
autofocusClose: true,
restorePreviewFocus: false,
phase: "visible",
});
return true;
}, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, updateActive]);
const hover = useCallback(
(itemId: string, source: HTMLElement) => {
hoveredRef.current = { itemId, source };
@@ -268,19 +290,37 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return;
}
if (isEditMode) return;
if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
if (event.repeat || isEditableTarget(event.target)) return;
const isKeepOpenModifier = event.key === "Control" || event.key === "Meta";
const hasKeepOpenShortcut = event.shiftKey && (event.ctrlKey || event.metaKey);
if (isKeepOpenModifier && hasKeepOpenShortcut) {
event.preventDefault();
keepOpenHeldRef.current = true;
if (!promotePreviewToManual()) startHoverTimer();
return;
}
if (event.key !== "Shift") return;
if (activeRef.current?.activation === "manual") return;
shiftHeldRef.current = true;
keepOpenHeldRef.current = hasKeepOpenShortcut;
startHoverTimer();
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === "Control" || event.key === "Meta") {
keepOpenHeldRef.current = false;
return;
}
if (event.key !== "Shift") return;
shiftHeldRef.current = false;
keepOpenHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current?.activation === "preview") close(false);
};
const handleBlur = () => {
shiftHeldRef.current = false;
keepOpenHeldRef.current = false;
cancelHoverTimer();
if (activeRef.current?.activation === "preview") close(false);
};
@@ -296,7 +336,15 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
cancelPreviewLeaveTimer();
cancelCloseTimer();
};
}, [cancelCloseTimer, cancelHoverTimer, cancelPreviewLeaveTimer, close, isEditMode, startHoverTimer]);
}, [
cancelCloseTimer,
cancelHoverTimer,
cancelPreviewLeaveTimer,
close,
isEditMode,
promotePreviewToManual,
startHoverTimer,
]);
useEffect(() => {
if (isEditMode && activeRef.current) close(false);
@@ -18,10 +18,11 @@ export interface CreateItemInput {
integrationIds?: string[];
targetSectionId?: string;
advancedOptions?: BoardItemAdvancedOptions;
size?: { width: number; height: number };
}
export const createItemCallback =
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions }: CreateItemInput) =>
({ id, kind, options = {}, integrationIds = [], targetSectionId, advancedOptions, size }: CreateItemInput) =>
(previous: Board): Board => {
const firstCanvasSection = previous.sections
.filter(
@@ -40,10 +41,10 @@ export const createItemCallback =
if (!targetSection) return previous;
let itemLayouts = createItemLayouts(previous, targetSection, kind);
let itemLayouts = createItemLayouts(previous, targetSection, kind, size);
if (itemLayouts.length === 0 && firstCanvasSection && targetSection.id !== firstCanvasSection.id) {
targetSection = firstCanvasSection;
itemLayouts = createItemLayouts(previous, targetSection, kind);
itemLayouts = createItemLayouts(previous, targetSection, kind, size);
}
if (itemLayouts.length === 0) return previous;
const widget = {
@@ -74,9 +75,10 @@ const createItemLayouts = (
board: Board,
currentSection: EmptySection | ContainerSection,
kind: WidgetKind,
requestedSize?: { width: number; height: number },
): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
const itemSize = requestedSize ?? getWidgetItemSize(kind);
return layouts.flatMap((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
@@ -382,7 +382,7 @@ const LoadedBoardItemContent = ({
className={itemContentClasses.advancedFocusTrigger}
aria-expanded={isAdvanced}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts="Shift+Enter"
aria-keyshortcuts="Shift+Control Shift+Meta"
data-advanced-focus-trigger
onClick={openAdvancedView}
>
@@ -0,0 +1,63 @@
import { z } from "zod/v4";
import { widgetKinds } from "@homarr/definitions";
import { itemAdvancedOptionsSchema } from "@homarr/validation/shared";
import type { Item } from "~/app/[locale]/boards/_types";
const clipboardPrefix = "homarr-board-items:v1\n";
const clipboardPayloadSchema = z.object({
version: z.literal(1),
items: z
.array(
z.object({
kind: z.enum(widgetKinds),
options: z.record(z.string(), z.unknown()),
integrationIds: z.array(z.string()),
advancedOptions: itemAdvancedOptionsSchema,
size: z.object({
width: z.number().int().min(1).max(64),
height: z.number().int().min(1).max(64),
}),
}),
)
.min(1)
.max(100),
});
export type BoardItemClipboardPayload = z.infer<typeof clipboardPayloadSchema>;
export const serializeBoardItemsForClipboard = (items: readonly Item[], layoutId: string): string => {
const clipboardItems = items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return [];
return [
{
kind: item.kind,
options: item.options,
integrationIds: item.integrationIds,
advancedOptions: item.advancedOptions,
size: { width: layout.width, height: layout.height },
},
];
});
if (clipboardItems.length === 0) throw new Error("No selected items are available in the current layout");
return `${clipboardPrefix}${JSON.stringify({ version: 1, items: clipboardItems })}`;
};
export const isBoardItemClipboardText = (value: string): boolean => value.startsWith(clipboardPrefix);
export const parseBoardItemClipboard = (value: string): BoardItemClipboardPayload | null => {
if (!isBoardItemClipboardText(value)) return null;
try {
const result = clipboardPayloadSchema.safeParse(JSON.parse(value.slice(clipboardPrefix.length)));
return result.success ? result.data : null;
} catch {
return null;
}
};
@@ -8,19 +8,28 @@ import { notifications } from "@mantine/notifications";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/common";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
import { createItemCallback } from "~/components/board/items/actions/create-item";
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
import { playPopSound, playTrashSound } from "../audio/board-sounds";
import {
isBoardItemClipboardText,
parseBoardItemClipboard,
serializeBoardItemsForClipboard,
} from "./board-item-clipboard";
interface BoardSelectionContextValue {
selectedItemIds: Set<string>;
toggleSelectItem: (id: string) => void;
clearSelection: () => void;
isSelected: (id: string) => boolean;
copySelectedItems: () => Promise<void>;
pasteItems: () => Promise<void>;
removeSelectedItems: () => void;
moveSelectedItemsToSection: (sectionId: string) => void;
}
@@ -37,7 +46,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const tSelection = useScopedI18n("item.selection");
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
(updater: (previous: typeof board) => typeof board) =>
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
@@ -45,8 +54,7 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
message: tSelection("saveErrorMessage"),
});
},
});
},
}),
[tSelection, updateAndPersistBoard],
);
@@ -90,6 +98,95 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
const copySelectedItems = useCallback(async () => {
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
if (selectedItems.length === 0) return;
try {
const value = serializeBoardItemsForClipboard(selectedItems, currentLayoutId);
await navigator.clipboard.writeText(value);
showSuccessNotification({
title: t("common.success"),
message: tSelection("copied", { count: selectedItems.length }),
});
} catch {
showErrorNotification({
title: t("common.error"),
message: tSelection("copyError"),
});
}
}, [board.items, currentLayoutId, selectedItemIds, t, tSelection]);
const pasteItemsFromText = useCallback(
(value: string) => {
const payload = parseBoardItemClipboard(value);
if (!payload) {
showErrorNotification({ title: t("common.error"), message: tSelection("pasteInvalid") });
return;
}
const pastedIds = payload.items.map(() => createId());
let didPasteAll = false;
persistBoard((previous) => {
let next = previous;
try {
payload.items.forEach((item, index) => {
const id = pastedIds[index];
if (!id) throw new Error("Missing pasted item ID");
next = createItemCallback({ ...item, id })(next);
});
} catch {
return previous;
}
didPasteAll = next.items.length === previous.items.length + payload.items.length;
return didPasteAll ? next : previous;
});
if (!didPasteAll) {
showErrorNotification({ title: t("common.error"), message: tSelection("pasteUnavailable") });
return;
}
setSelectedItemIds(new Set(pastedIds));
playPopSound();
showSuccessNotification({
title: t("common.success"),
message: tSelection("pasted", { count: pastedIds.length }),
});
},
[persistBoard, t, tSelection],
);
const pasteItems = useCallback(async () => {
try {
pasteItemsFromText(await navigator.clipboard.readText());
} catch {
showErrorNotification({ title: t("common.error"), message: tSelection("pasteError") });
}
}, [pasteItemsFromText, t, tSelection]);
useEffect(() => {
if (!isEditMode) return;
const handlePaste = (event: ClipboardEvent) => {
const target = event.target;
if (
target instanceof Element &&
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox']")
)
return;
const value = event.clipboardData?.getData("text/plain") ?? "";
if (!isBoardItemClipboardText(value)) return;
event.preventDefault();
pasteItemsFromText(value);
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [isEditMode, pasteItemsFromText]);
const removeSelectedItems = useCallback(() => {
if (selectedItemIds.size === 0) return;
@@ -252,10 +349,21 @@ export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
toggleSelectItem,
clearSelection,
isSelected,
copySelectedItems,
pasteItems,
removeSelectedItems,
moveSelectedItemsToSection,
}),
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
[
clearSelection,
copySelectedItems,
isSelected,
moveSelectedItemsToSection,
pasteItems,
removeSelectedItems,
selectedItemIds,
toggleSelectItem,
],
);
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
@@ -15,7 +15,7 @@ import {
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
import { IconCheck, IconClipboard, IconCopy, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
@@ -31,7 +31,14 @@ const MAX_AVATARS_DISPLAYED = 5;
export const BoardSelectionToolbar = () => {
const [isEditMode] = useEditMode();
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
const {
selectedItemIds,
clearSelection,
copySelectedItems,
pasteItems,
removeSelectedItems,
moveSelectedItemsToSection,
} = useBoardSelection();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
@@ -201,14 +208,32 @@ export const BoardSelectionToolbar = () => {
</HoverCard.Dropdown>
</HoverCard>
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
{t("app.action.select.multiSelectTip")}
</Text>
<Divider orientation="vertical" />
{/* Actions */}
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Button
size="xs"
variant="light"
color="blue"
leftSection={<IconCopy size={15} />}
onClick={() => void copySelectedItems()}
>
{tSelection("copy")}
</Button>
<Tooltip label={tSelection("pasteShortcut")} withArrow>
<Button
size="xs"
variant="light"
color="blue"
leftSection={<IconClipboard size={15} />}
onClick={() => void pasteItems()}
>
{tSelection("paste")}
</Button>
</Tooltip>
{placementOptions.length > 1 && (
<Menu position="top" withArrow shadow="md">
<Menu.Target>
@@ -1,438 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppSelectModal } from "./app-select-modal";
const AppSelectModalComponent = AppSelectModal.component;
const sampleApps = [
{
id: "app-1",
name: "Sonarr",
description: "Manage TV series downloads",
iconUrl: "https://example.com/sonarr.png",
href: "http://sonarr.local",
},
{
id: "app-2",
name: "Radarr",
description: "Manage movie collections",
iconUrl: "https://example.com/radarr.png",
href: "http://radarr.local",
},
{
id: "app-3",
name: "Plex",
description: "Stream your media collection",
iconUrl: "https://example.com/plex.png",
href: "http://plex.local",
},
];
const mockUseQuery = vi.fn(() => ({
data: sampleApps,
isPending: false,
}));
const mockOpenQuickAddAppModal = vi.fn();
vi.mock("@homarr/api/client", () => ({
clientApi: {
app: {
selectable: {
useQuery: () => mockUseQuery(),
},
},
},
}));
vi.mock("@homarr/modals", async () => {
const actual = await vi.importActual<Record<string, unknown>>("@homarr/modals");
return {
...actual,
useModalAction: () => ({
openModal: mockOpenQuickAddAppModal,
}),
};
});
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string, values?: { count?: number }) => {
const count = values?.count ?? 0;
const translations: Record<string, string> = {
"app.action.select.search": "Search apps",
"app.action.select.title": "Select application",
"app.action.select.selected": "Selected",
"app.action.select.toggle": "Click to select",
"app.action.select.noResults": "No apps found",
"app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
"app.action.select.selectedCount": `${count} selected`,
"app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
"app.action.select.customApplication": "Custom application",
"app.action.select.application": "Application",
"app.action.create.title": "Add custom app",
"app.action.create.description": "Configure custom URL and icon",
"common.action.discard": "Discard",
"common.action.add": "Add",
};
return translations[key] ?? key;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
vi.clearAllMocks();
mockUseQuery.mockReturnValue({
data: sampleApps,
isPending: false,
});
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
describe("AppSelectModal", () => {
describe("single selection mode", () => {
it("renders app cards with name, description, and icon", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Sonarr");
expect(host.textContent).toContain("Radarr");
expect(host.textContent).toContain("Plex");
expect(host.textContent).toContain("Manage TV series downloads");
});
it("selects an app and closes modal when card is clicked", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
expect(sonarrCard).toBeDefined();
await act(async () => {
sonarrCard?.click();
});
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
expect(closeModal).toHaveBeenCalledOnce();
});
});
describe("multi selection mode", () => {
it("toggles multiple app selections and submits batch on button click", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
// Select Sonarr
await act(async () => {
sonarrCard?.click();
});
// Modal stays open, 1 app selected
expect(closeModal).not.toHaveBeenCalled();
expect(host.textContent).toContain("1 app selected");
// Select Radarr
await act(async () => {
radarrCard?.click();
});
expect(host.textContent).toContain("2 apps selected");
// Submit multi select
const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
expect(submitBtn).toBeDefined();
await act(async () => {
submitBtn?.click();
});
expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
expect(closeModal).toHaveBeenCalledOnce();
});
it("unselects an app when clicked a second time", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
// Select Sonarr
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).toContain("1 app selected");
// Unselect Sonarr
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).not.toContain("1 app selected");
});
it("clears selection when Discard button is clicked", async () => {
const closeModal = vi.fn();
const onSelectMany = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
</MantineProvider>,
),
);
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
await act(async () => {
sonarrCard?.click();
});
expect(host.textContent).toContain("1 app selected");
const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Discard"),
);
await act(async () => {
discardBtn?.click();
});
expect(host.textContent).not.toContain("app selected");
});
});
describe("search and filtering", () => {
it("filters apps in real-time by search query (case-insensitive)", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
expect(searchInput).not.toBeNull();
// Type "plex"
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "plex");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("Plex");
expect(host.textContent).not.toContain("Sonarr");
expect(host.textContent).not.toContain("Radarr");
});
it("selects the single matching app when Enter key is pressed in search input", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "sonarr");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await act(async () => {
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
expect(closeModal).toHaveBeenCalledOnce();
});
it("displays empty state message when search yields no results", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
const searchInput = host.querySelector("input");
await act(async () => {
if (searchInput) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(searchInput, "non-matching-query");
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
}
});
expect(host.textContent).toContain("No apps found");
});
});
describe("custom app creation card", () => {
it("renders custom app creation card when withCreate is true", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
</MantineProvider>,
),
);
expect(host.textContent).toContain("Add custom app");
expect(host.textContent).toContain("Configure custom URL and icon");
});
it("omits custom app creation card when withCreate is false", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
</MantineProvider>,
),
);
expect(host.textContent).not.toContain("Add custom app");
});
it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
const closeModal = vi.fn();
const onSelect = vi.fn();
await act(async () =>
root.render(
<MantineProvider>
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
</MantineProvider>,
),
);
const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("Add custom app"),
);
expect(createCard).toBeDefined();
await act(async () => {
createCard?.click();
});
expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
// Trigger the onClose callback passed to QuickAddAppModal
const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
const newCustomApp = {
id: "custom-app-1",
name: "Custom Home Assistant",
description: "Local instance",
iconUrl: "https://example.com/ha.png",
href: "http://ha.local",
};
await act(async () => {
options.onClose(newCustomApp);
});
expect(onSelect).toHaveBeenCalledWith(newCustomApp);
expect(closeModal).toHaveBeenCalledOnce();
});
});
});
@@ -13,13 +13,13 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import { IconBulb, IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { selectGridCols, SelectableCard } from "@homarr/ui";
import { FloatingTip, selectGridCols, SelectableCard } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -88,7 +88,18 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
return (
<Stack gap="md">
{/* Top Search Input & Multi-Select Tip */}
<FloatingTip
opened={multiSelect}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{t("tips.multiSelectApps")}
</FloatingTip>
{/* Top Search Input */}
<Stack gap={6}>
<Input
value={search}
@@ -103,18 +114,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
}
}}
/>
{multiSelect && (
<Group justify="space-between" px={2}>
<Text size="xs" c="dimmed">
{t("app.action.select.multiSelectTip")}
</Text>
{selectedAppIds.size > 0 && (
<Badge variant="light" color="primaryColor" size="xs">
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
</Badge>
)}
</Group>
)}
</Stack>
{/* Scrollable Container with App Cards */}
@@ -207,13 +206,6 @@ const AppCard = ({
selected={isSelected}
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
title={app.name}
topRight={
isSelected ? (
<Badge variant="dot" color="primaryColor" size="xs">
{t("app.action.select.selected")}
</Badge>
) : null
}
description={app.description}
footerLeft={
<Text size="xs" c="dimmed">
+13 -1
View File
@@ -822,7 +822,6 @@
"toggle": "Click to select",
"selected": "Selected",
"multiple": "Add {count}",
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
"customApplication": "Custom application",
@@ -836,6 +835,10 @@
"add": "Add an app"
}
},
"tips": {
"dismiss": "Dismiss tip",
"multiSelectApps": "Tip: Hold Ctrl / ⌘ to select multiple apps at once"
},
"customWidget": {
"betaBanner": {
"title": "Custom Widgets are in Beta",
@@ -2786,6 +2789,15 @@
"selection": {
"summary": "{count, plural, one {# selected item} other {# selected items}}",
"selected": "{count, plural, one {# selected} other {# selected}}",
"copy": "Copy",
"copied": "{count, plural, one {# item copied to the clipboard} other {# items copied to the clipboard}}",
"copyError": "The selected items could not be copied. Your browser may not allow clipboard access.",
"paste": "Paste",
"pasteShortcut": "Paste copied items here (Ctrl / ⌘ + V)",
"pasted": "{count, plural, one {# item pasted} other {# items pasted}}",
"pasteError": "The clipboard could not be read. Your browser may not allow clipboard access.",
"pasteInvalid": "The clipboard does not contain valid Homarr board items.",
"pasteUnavailable": "The copied items do not fit on this board.",
"removed": "{count, plural, one {# item removed} other {# items removed}}",
"move": "Move",
"moveTo": "Move selected items",
@@ -0,0 +1,17 @@
.root {
position: fixed;
inset-inline: var(--mantine-spacing-md);
bottom: var(--floating-tip-bottom);
z-index: var(--floating-tip-z-index);
display: flex;
justify-content: center;
pointer-events: none;
}
.alert {
width: fit-content;
max-width: min(var(--floating-tip-max-width), calc(100vw - 2 * var(--mantine-spacing-md)));
pointer-events: auto;
box-shadow: var(--mantine-shadow-xl);
backdrop-filter: blur(12px);
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly root: string;
readonly alert: string;
};
export default classes;
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FloatingTip } from "./floating-tip";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
window.localStorage.clear();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
document.body.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.useRealTimers();
});
describe("FloatingTip", () => {
it("shows after the configured delay and auto-dismisses", async () => {
await act(async () =>
root.render(
<MantineProvider>
<FloatingTip opened showDelay={2_000} dismissAfter={3_000} transitionDuration={0}>
Select several apps
</FloatingTip>
</MantineProvider>,
),
);
expect(document.body.textContent).not.toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(2_000));
expect(document.body.textContent).toContain("Select several apps");
await act(async () => vi.advanceTimersByTime(3_000));
expect(document.body.textContent).not.toContain("Select several apps");
});
it("provides an accessible close button and remembers dismissal", async () => {
const onDismiss = vi.fn();
const renderTip = (opened: boolean) =>
root.render(
<MantineProvider>
<FloatingTip
opened={opened}
showDelay={0}
transitionDuration={0}
closable
closeButtonLabel="Dismiss tip"
rememberDismissal
storageKey="selection"
onDismiss={onDismiss}
>
Select several apps
</FloatingTip>
</MantineProvider>,
);
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
const closeButton = document.body.querySelector<HTMLButtonElement>('button[aria-label="Dismiss tip"]');
expect(closeButton).not.toBeNull();
await act(async () => closeButton?.click());
expect(onDismiss).toHaveBeenCalledOnce();
expect(window.localStorage.getItem("homarr:floating-tip:selection")).toBe("dismissed");
await act(async () => renderTip(false));
await act(async () => renderTip(true));
await act(async () => vi.advanceTimersByTime(0));
expect(document.body.textContent).not.toContain("Select several apps");
});
});
@@ -0,0 +1,205 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Box, createVarsResolver, factory, Portal, rem, Transition, useProps, useStyles } from "@mantine/core";
import type { AlertProps, BoxProps, ElementProps, Factory, MantineTransition, StylesApiProps } from "@mantine/core";
import classes from "./floating-tip.module.css";
export type FloatingTipStylesNames = "root" | "alert";
export type FloatingTipCssVariables = {
root: "--floating-tip-bottom" | "--floating-tip-max-width" | "--floating-tip-z-index";
};
interface FloatingTipBaseProps
extends BoxProps, StylesApiProps<FloatingTipFactory>, Omit<ElementProps<"div">, "title"> {
/** Controls whether this tip cycle should run. */
opened: boolean;
/** Delay before the tip appears, in milliseconds. */
showDelay?: number;
/** Time the tip remains visible, in milliseconds. Set to `null` to keep it open. */
dismissAfter?: number | null;
/** Enter and exit animation duration, in milliseconds. */
transitionDuration?: number;
/** Mantine transition used to show and hide the tip. */
transition?: MantineTransition;
/** Distance from the bottom edge of the viewport. */
bottomOffset?: number | string;
/** Maximum width of the alert. */
maxWidth?: number | string;
/** Stack order of the floating tip. */
zIndex?: number;
/** Props forwarded to the underlying Mantine Alert. */
alertProps?: Omit<
AlertProps,
"children" | "classNames" | "closeButtonLabel" | "onClose" | "styles" | "withCloseButton"
>;
/** Called when the user closes the tip. */
onDismiss?: () => void;
}
type FloatingTipCloseProps =
| { closable: true; closeButtonLabel: string }
| { closable?: false; closeButtonLabel?: never };
type FloatingTipPersistenceProps =
| { rememberDismissal: true; storageKey: string }
| { rememberDismissal?: false; storageKey?: never };
export type FloatingTipProps = FloatingTipBaseProps & FloatingTipCloseProps & FloatingTipPersistenceProps;
export type FloatingTipFactory = Factory<{
props: FloatingTipProps;
ref: HTMLDivElement;
stylesNames: FloatingTipStylesNames;
vars: FloatingTipCssVariables;
}>;
const defaultProps = {
showDelay: 0,
dismissAfter: null,
transitionDuration: 200,
transition: "slide-up",
bottomOffset: 24,
maxWidth: 520,
zIndex: 1000,
closable: false,
rememberDismissal: false,
} satisfies Partial<FloatingTipProps>;
const toCssSize = (value: number | string | undefined) => (typeof value === "number" ? rem(value) : value);
const varsResolver = createVarsResolver<FloatingTipFactory>((_theme, { bottomOffset, maxWidth, zIndex }) => ({
root: {
"--floating-tip-bottom": toCssSize(bottomOffset),
"--floating-tip-max-width": toCssSize(maxWidth),
"--floating-tip-z-index": zIndex?.toString(),
},
}));
const getDismissalStorageKey = (storageKey: string) => `homarr:floating-tip:${storageKey}`;
export const FloatingTip = factory<FloatingTipFactory>((_props) => {
const props = useProps("FloatingTip", defaultProps, _props);
const {
ref,
classNames,
className,
style,
styles,
unstyled,
vars,
attributes,
opened,
showDelay,
dismissAfter,
transitionDuration,
transition,
bottomOffset: _bottomOffset,
maxWidth: _maxWidth,
zIndex: _zIndex,
closable,
closeButtonLabel,
rememberDismissal,
storageKey,
alertProps,
onDismiss,
children,
...others
} = props;
const [visible, setVisible] = useState(false);
const showTimerRef = useRef<number | null>(null);
const dismissTimerRef = useRef<number | null>(null);
const clearTimers = useCallback(() => {
if (showTimerRef.current !== null) window.clearTimeout(showTimerRef.current);
if (dismissTimerRef.current !== null) window.clearTimeout(dismissTimerRef.current);
showTimerRef.current = null;
dismissTimerRef.current = null;
}, []);
useEffect(() => {
clearTimers();
setVisible(false);
if (!opened) return;
if (rememberDismissal) {
try {
if (window.localStorage.getItem(getDismissalStorageKey(storageKey)) === "dismissed") return;
} catch {
// Storage can be unavailable in private browsing or hardened environments.
}
}
showTimerRef.current = window.setTimeout(() => {
setVisible(true);
showTimerRef.current = null;
if (dismissAfter !== null) {
dismissTimerRef.current = window.setTimeout(() => {
setVisible(false);
dismissTimerRef.current = null;
}, dismissAfter);
}
}, showDelay);
return clearTimers;
}, [clearTimers, dismissAfter, opened, rememberDismissal, showDelay, storageKey]);
const handleDismiss = () => {
clearTimers();
setVisible(false);
if (rememberDismissal) {
try {
window.localStorage.setItem(getDismissalStorageKey(storageKey), "dismissed");
} catch {
// The tip still closes for this render even when persistence is unavailable.
}
}
onDismiss?.();
};
const getStyles = useStyles<FloatingTipFactory>({
name: "FloatingTip",
classes,
props,
className,
style,
classNames,
styles,
unstyled,
vars,
attributes,
varsResolver,
});
return (
<Portal>
<Transition mounted={visible} transition={transition} duration={transitionDuration} timingFunction="ease">
{(transitionStyle) => {
const rootStyles = getStyles("root");
return (
<Box ref={ref} {...others} {...rootStyles} style={[rootStyles.style, transitionStyle]}>
<Alert
{...alertProps}
{...getStyles("alert")}
withCloseButton={closable}
closeButtonLabel={closeButtonLabel}
onClose={handleDismiss}
>
{children}
</Alert>
</Box>
);
}}
</Transition>
</Portal>
);
});
FloatingTip.displayName = "@homarr/ui/FloatingTip";
FloatingTip.classes = classes;
@@ -0,0 +1,7 @@
export { FloatingTip } from "./floating-tip";
export type {
FloatingTipCssVariables,
FloatingTipFactory,
FloatingTipProps,
FloatingTipStylesNames,
} from "./floating-tip";
+1
View File
@@ -20,6 +20,7 @@ export type {
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
export * from "./floating-tip";
export { MaskedImage } from "./masked-image";
export { MaskedOrNormalImage } from "./masked-or-normal-image";
export { LanguageIcon } from "./language-icon";