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

@@ -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>