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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user