feat(ui): add situational tips system (#6648)
This commit is contained in:
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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user