chore: reconcile widget layer after stack rebase
This commit is contained in:
1 parent
09b82ba171
commit
bd4bbcaca2
23 files changed
+101
-814
No files matched your search
@@ -83,7 +83,7 @@ describe("AdvancedFocusManualSurface", () => {
|
||||
expect(dialog?.id).toBe("advanced-focus-test");
|
||||
expect(dialog?.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog?.closest("[data-portal]")).not.toBeNull();
|
||||
expect(dialog?.closest(".grid-stack-item")).toBeNull();
|
||||
expect(dialog?.closest("[data-grid-item-id]")).toBeNull();
|
||||
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
|
||||
"Test widget advanced view",
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
|
||||
import { getFirstEmptyPosition } from "../empty-position";
|
||||
|
||||
describe("get first empty position", () => {
|
||||
test.each([
|
||||
@@ -106,39 +106,7 @@ describe("get first empty position", () => {
|
||||
|
||||
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
|
||||
});
|
||||
|
||||
test("does not place a wide item beyond the right edge", () => {
|
||||
const elements = createElementsFromLayout([["a", "a", "a", " "]]);
|
||||
|
||||
expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("get nearest empty position", () => {
|
||||
test("keeps the preferred cell when it fits", () => {
|
||||
expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("chooses the nearest available cell when the preferred cell is occupied", () => {
|
||||
const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
|
||||
|
||||
expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
|
||||
xOffset: 2,
|
||||
yOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns undefined when the item cannot fit within a bounded section", () => {
|
||||
expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const createElementsFromLayout = (layout: string[][]) => {
|
||||
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
|
||||
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
|
||||
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: readonly QueryKey[]) => {
|
||||
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
|
||||
};
|
||||
@@ -172,7 +172,7 @@ describe("matchesWidgetItemQuery", () => {
|
||||
integrationIds: ["beszel-1"],
|
||||
systemId: "system-1",
|
||||
timePeriod: "1h",
|
||||
includeDocker: false,
|
||||
includeDocker: true,
|
||||
},
|
||||
};
|
||||
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
.suppressTouchCallout {
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { BoxProps } from "@mantine/core";
|
||||
import { Box, Menu } from "@mantine/core";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
|
||||
import { ItemSelectModal } from "../../items/item-select-modal";
|
||||
import { useBoardPermissions } from "../../permissions/client";
|
||||
import type { BoardPlacementHint } from "../../items/actions/create-item";
|
||||
import type { UseGridstackRefs } from "./use-gridstack";
|
||||
import classes from "./board-empty-space-context-menu.module.css";
|
||||
import { hasExceededLongPressMoveTolerance } from "./long-press";
|
||||
|
||||
const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
|
||||
|
||||
interface MenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
placement: BoardPlacementHint;
|
||||
}
|
||||
|
||||
interface Props extends BoxProps {
|
||||
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
|
||||
refs: UseGridstackRefs;
|
||||
}
|
||||
|
||||
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { openModal } = useModalAction(ItemSelectModal);
|
||||
const [isEditMode] = useEditMode();
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
|
||||
const touchGuardElement = useRef<HTMLDivElement | null>(null);
|
||||
const returnFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
|
||||
const closeMenu = (afterFocusRestore?: (returnFocus: HTMLElement | null) => void) => {
|
||||
const returnFocus = returnFocusRef.current;
|
||||
returnFocusRef.current = null;
|
||||
setMenu(null);
|
||||
requestAnimationFrame(() => {
|
||||
returnFocus?.focus();
|
||||
afterFocusRestore?.(returnFocus);
|
||||
});
|
||||
};
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer.current !== null) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
|
||||
touchGuardElement.current = null;
|
||||
longPressPointer.current = null;
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
|
||||
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
|
||||
const wrapper = refs.wrapperElement.current;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!(target instanceof Element) || !wrapper || !gridstack) return null;
|
||||
if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
|
||||
|
||||
const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
|
||||
const rowLimit = section.kind === "dynamic" ? section.height : undefined;
|
||||
if (
|
||||
cell.x < 0 ||
|
||||
cell.y < 0 ||
|
||||
cell.x >= gridstack.getColumn() ||
|
||||
(rowLimit !== undefined && cell.y >= rowLimit) ||
|
||||
!gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
|
||||
};
|
||||
|
||||
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (!enabled) return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
returnFocusRef.current = event.currentTarget;
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (!enabled || event.pointerType !== "touch") return;
|
||||
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
|
||||
if (!placement) return;
|
||||
const returnFocus = event.currentTarget;
|
||||
clearLongPress();
|
||||
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
||||
touchGuardElement.current = event.currentTarget;
|
||||
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
returnFocusRef.current = returnFocus;
|
||||
setMenu({ x: event.clientX, y: event.clientY, placement });
|
||||
navigator.vibrate?.(20);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const pointer = longPressPointer.current;
|
||||
if (
|
||||
!pointer ||
|
||||
pointer.pointerId !== event.pointerId ||
|
||||
!hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearLongPress();
|
||||
};
|
||||
|
||||
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!enabled ||
|
||||
event.currentTarget !== event.target ||
|
||||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const wrapper = event.currentTarget;
|
||||
const gridstack = refs.gridstack.current;
|
||||
if (!gridstack) return;
|
||||
|
||||
const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
|
||||
for (let yOffset = 0; yOffset < rowCount; yOffset++) {
|
||||
for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
|
||||
if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
event.preventDefault();
|
||||
returnFocusRef.current = wrapper;
|
||||
setMenu({
|
||||
x: rect.left + 24,
|
||||
y: rect.top + 24,
|
||||
placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
{...boxProps}
|
||||
ref={refs.wrapper}
|
||||
tabIndex={enabled ? 0 : undefined}
|
||||
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerLeave={handlePointerEnd}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Menu
|
||||
opened={menu !== null}
|
||||
withInitialFocusPlaceholder={false}
|
||||
onChange={(opened) => {
|
||||
if (!opened) closeMenu();
|
||||
}}
|
||||
withinPortal
|
||||
position="bottom-start"
|
||||
returnFocus={false}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
data-autofocus
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={() => {
|
||||
const placement = menu?.placement;
|
||||
closeMenu((returnFocus) => {
|
||||
if (!placement) return;
|
||||
openModal(
|
||||
{ placement, board, persistImmediately: !isEditMode },
|
||||
{
|
||||
onClose: () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (returnFocus?.isConnected) returnFocus.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("item.create.addAtPosition")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { hasExceededLongPressMoveTolerance } from "./long-press";
|
||||
|
||||
describe("hasExceededLongPressMoveTolerance", () => {
|
||||
test("allows small touch jitter", () => {
|
||||
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 106 })).toBe(false);
|
||||
});
|
||||
|
||||
test("allows movement exactly at the threshold", () => {
|
||||
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 108 })).toBe(false);
|
||||
});
|
||||
|
||||
test("cancels a deliberate scroll gesture", () => {
|
||||
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 100, y: 116 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
export const longPressMoveTolerancePx = 10;
|
||||
|
||||
export const hasExceededLongPressMoveTolerance = (
|
||||
start: { x: number; y: number },
|
||||
current: { x: number; y: number },
|
||||
tolerance = longPressMoveTolerancePx,
|
||||
) => Math.hypot(current.x - start.x, current.y - start.y) > tolerance;
|
||||
@@ -5,7 +5,7 @@ export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ e
|
||||
|
||||
// Native server-side import keeps the completed-user client graph lean while
|
||||
// giving first-time users one stable board tree instead of swapping a
|
||||
// Suspense fallback after GridStack has mounted.
|
||||
// Suspense fallback after the board grid has mounted.
|
||||
const { BoardTourProvider } = await import("./board-tour");
|
||||
return <BoardTourProvider>{children}</BoardTourProvider>;
|
||||
};
|
||||
Reference in new issue
Block a user