fix: address widget review feedback

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent b1b17ad69f
commit a38e731209
79 files changed
+2138 -244

No files matched your search

@@ -135,7 +135,7 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
animation-duration: 1ms;
animation: none;
}
}
@@ -0,0 +1,439 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import { matchesWidgetItemQuery } from "./widget-query-scope";
type WidgetKind = keyof typeof widgetImports;
const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
itemId: "item-1",
boardId: "board-1",
integrationIds: ["integration-1"],
options: {},
runtimeQueries: [],
...overrides,
});
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
};
describe("matchesWidgetItemQuery", () => {
test("matches only the current Custom API definition", () => {
const scope = createScope({ options: { definitionId: "definition-1" } });
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe(
true,
);
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe(
false,
);
});
test("owns only the active compact or advanced Media Missing page size", () => {
const path = ["widget", "mediaOrganizer", "getData"];
const compactInput = { integrationIds: ["radarr-1"], pageSize: 20 };
const advancedInput = { integrationIds: ["radarr-1"], pageSize: 50 };
const compactScope = createScope({
integrationIds: ["radarr-1"],
runtimeQueries: [{ path, input: compactInput }],
});
const advancedScope = createScope({
integrationIds: ["radarr-1"],
runtimeQueries: [{ path, input: advancedInput }],
});
expect(matches("mediaMissing", path, compactInput, compactScope)).toBe(true);
expect(matches("mediaMissing", path, advancedInput, compactScope)).toBe(false);
expect(matches("mediaMissing", path, advancedInput, advancedScope)).toBe(true);
expect(matches("mediaMissing", path, compactInput, advancedScope)).toBe(false);
});
test("owns every Releases batch by item id", () => {
const scope = createScope({ itemId: "release-item" });
const path = ["widget", "releases", "getLatest"];
expect(matches("releases", path, { itemId: "release-item", repositories: [{ id: "one" }] }, scope)).toBe(true);
expect(matches("releases", path, { itemId: "sibling-item", repositories: [{ id: "one" }] }, scope)).toBe(false);
});
test("normalizes Immich's all-photos sentinel and separates Immich widget kinds", () => {
const carouselScope = createScope({
integrationIds: ["immich-1"],
options: { albumId: allPhotosAlbumId },
});
const serverStatsQuery: NormalizedWidgetQuery = {
path: ["widget", "immich", "getServerStats"],
input: { integrationId: "immich-1" },
};
const albumsQuery: NormalizedWidgetQuery = {
path: ["widget", "immich", "getAlbums"],
input: { integrationId: "immich-1" },
};
const compactServerScope = createScope({
integrationIds: ["immich-1"],
runtimeQueries: [serverStatsQuery],
});
const advancedServerScope = createScope({
integrationIds: ["immich-1"],
runtimeQueries: [serverStatsQuery, albumsQuery],
});
expect(
matches("immich-albumCarousel", ["widget", "immich", "getAlbum"], { integrationId: "immich-1" }, carouselScope),
).toBe(true);
expect(
matches(
"immich-albumCarousel",
["widget", "immich", "getAlbum"],
{ integrationId: "immich-1", albumId: "album-2" },
carouselScope,
),
).toBe(false);
expect(
matches(
"immich-albumCarousel",
["widget", "immich", "getServerStats"],
{ integrationId: "immich-1" },
carouselScope,
),
).toBe(false);
expect(matches("immich-serverStats", serverStatsQuery.path, serverStatsQuery.input, compactServerScope)).toBe(true);
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, compactServerScope)).toBe(false);
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, advancedServerScope)).toBe(true);
expect(
matches("immich-serverStats", ["widget", "immich", "getAlbum"], albumsQuery.input, advancedServerScope),
).toBe(false);
});
test("uses the resolved Beszel integration, system, and includeDocker input", () => {
const runtimeQuery: NormalizedWidgetQuery = {
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: ["beszel-2"],
systemId: "system-2",
timePeriod: "1h",
includeDocker: true,
},
};
const scope = createScope({
integrationIds: ["beszel-1", "beszel-2"],
options: { systemId: "beszel-2:system-2", timePeriod: "1h", showDockerCpu: true },
runtimeQueries: [runtimeQuery],
});
expect(matches("beszelSystemStats", runtimeQuery.path, runtimeQuery.input, scope)).toBe(true);
expect(
matches(
"beszelSystemStats",
runtimeQuery.path,
{ ...(runtimeQuery.input as Record<string, unknown>), includeDocker: false },
scope,
),
).toBe(false);
expect(
matches(
"beszelSystemStats",
runtimeQuery.path,
{ ...(runtimeQuery.input as Record<string, unknown>), systemId: "system-1" },
scope,
),
).toBe(false);
});
test("separates Beszel procedure families while retaining shared getSystems ownership", () => {
const scope = createScope({
integrationIds: ["beszel-1"],
options: { showHistory: true, maxHistoryItems: 10 },
});
const systemsInput = { integrationIds: ["beszel-1"] };
const alertsInput = { integrationIds: ["beszel-1"], includeHistory: true, maxHistoryItems: 10 };
expect(matches("beszelSystemGrid", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
expect(matches("beszelSystemTable", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
expect(matches("beszelAlerts", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(false);
expect(matches("beszelAlerts", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(true);
expect(matches("beszelSystemGrid", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(false);
});
test("owns only the active Beszel table detail query", () => {
const activeQuery: NormalizedWidgetQuery = {
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: ["beszel-1"],
systemId: "system-1",
timePeriod: "1h",
includeDocker: false,
},
};
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
expect(matches("beszelSystemTable", activeQuery.path, activeQuery.input, scope)).toBe(true);
expect(
matches(
"beszelSystemTable",
activeQuery.path,
{ ...(activeQuery.input as Record<string, unknown>), systemId: "system-2" },
scope,
),
).toBe(false);
expect(matches("beszelSystemGrid", activeQuery.path, activeQuery.input, scope)).toBe(false);
});
test("owns only Umami queries registered for the active view and layout", () => {
const eventQuery: NormalizedWidgetQuery = {
path: ["widget", "umami", "getMultiEventTimeSeries"],
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["signup", "view"] },
};
const topPagesQuery: NormalizedWidgetQuery = {
path: ["widget", "umami", "getTopPages"],
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", limit: 5 },
};
const eventsScope = createScope({ runtimeQueries: [eventQuery] });
const advancedScope = createScope({ runtimeQueries: [eventQuery, topPagesQuery] });
expect(matches("umami", eventQuery.path, eventQuery.input, eventsScope)).toBe(true);
expect(
matches(
"umami",
eventQuery.path,
{ integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["view", "signup"] },
eventsScope,
),
).toBe(false);
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, eventsScope)).toBe(false);
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, advancedScope)).toBe(true);
});
test("maps Media Request status filters and separates list from stats", () => {
const listScope = createScope({
integrationIds: ["request-1"],
options: { statusFilter: [], recentDays: 30, linksTargetNewTab: true },
});
const allStatuses = ["pending", "approved", "declined", "failed", "completed"];
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getLatestRequests"],
{ integrationIds: ["request-1"], statuses: allStatuses, recentDays: 30 },
listScope,
),
).toBe(true);
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getLatestRequests"],
{ integrationIds: ["request-1"], statuses: ["pending"], recentDays: 30 },
listScope,
),
).toBe(false);
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getStats"],
{ integrationIds: ["request-1"] },
listScope,
),
).toBe(false);
expect(
matches(
"mediaRequests-requestStats",
["widget", "mediaRequests", "getStats"],
{ integrationIds: ["request-1"] },
listScope,
),
).toBe(true);
});
test("separates Audio Stats stream details from Media Server filters", () => {
const statsQuery: NormalizedWidgetQuery = {
path: ["widget", "audioStats", "getStats"],
input: { integrationId: "audio-1" },
};
const streamsQuery: NormalizedWidgetQuery = {
path: ["widget", "mediaServer", "getCurrentStreams"],
input: { integrationIds: ["audio-1"], showOnlyPlaying: false },
};
const compactAudioScope = createScope({ integrationIds: ["audio-1"], runtimeQueries: [statsQuery] });
const advancedAudioScope = createScope({
integrationIds: ["audio-1"],
runtimeQueries: [statsQuery, streamsQuery],
});
const mediaScope = createScope({ integrationIds: ["audio-1"], options: { showOnlyPlaying: true } });
expect(matches("audioStats", statsQuery.path, statsQuery.input, compactAudioScope)).toBe(true);
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, compactAudioScope)).toBe(false);
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, advancedAudioScope)).toBe(true);
expect(
matches(
"audioStats",
streamsQuery.path,
{ integrationIds: ["audio-1"], showOnlyPlaying: true },
advancedAudioScope,
),
).toBe(false);
expect(
matches("mediaServer", streamsQuery.path, { integrationIds: ["audio-1"], showOnlyPlaying: true }, mediaScope),
).toBe(true);
expect(matches("mediaServer", streamsQuery.path, streamsQuery.input, mediaScope)).toBe(false);
});
test("uses each Calendar instance's registered month", () => {
const path = ["widget", "calendar", "findAllEvents"];
const januaryInput = {
integrationIds: ["calendar-1"],
month: 0,
year: 2026,
releaseType: [],
showUnmonitored: false,
};
const februaryInput = { ...januaryInput, month: 1 };
const januaryScope = createScope({ runtimeQueries: [{ path, input: januaryInput }] });
const februaryScope = createScope({ runtimeQueries: [{ path, input: februaryInput }] });
expect(matches("calendar", path, januaryInput, januaryScope)).toBe(true);
expect(matches("calendar", path, februaryInput, januaryScope)).toBe(false);
expect(matches("calendar", path, februaryInput, februaryScope)).toBe(true);
});
test("uses each Media Transcoding instance's registered page and geometry-derived page size", () => {
const path = ["widget", "mediaTranscoding", "getDataAsync"];
const firstPage = { integrationId: "transcode-1", pageSize: 8, page: 1 };
const secondPage = { integrationId: "transcode-1", pageSize: 12, page: 2 };
const firstScope = createScope({ runtimeQueries: [{ path, input: firstPage }] });
const secondScope = createScope({ runtimeQueries: [{ path, input: secondPage }] });
expect(matches("mediaTranscoding", path, firstPage, firstScope)).toBe(true);
expect(matches("mediaTranscoding", path, secondPage, firstScope)).toBe(false);
expect(matches("mediaTranscoding", path, secondPage, secondScope)).toBe(true);
});
test("matches only the Downloads query with the current integrations and limit", () => {
const scope = createScope({
integrationIds: ["sabnzbd-1", "qbittorrent-1"],
options: { limitPerIntegration: 25 },
});
const path = ["widget", "downloads", "getJobsAndStatuses"];
expect(
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 25 }, scope),
).toBe(true);
expect(matches("downloads", path, { integrationIds: ["sabnzbd-1"], limitPerIntegration: 25 }, scope)).toBe(false);
expect(
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 50 }, scope),
).toBe(false);
expect(
matches("downloads", ["widget", "downloads", "pause"], { integrationIds: ["sabnzbd-1", "qbittorrent-1"] }, scope),
).toBe(false);
});
test("matches only the active Stock Price symbol and time frame", () => {
const scope = createScope({ options: { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" } });
const path = ["widget", "stockPrice", "getPriceHistory"];
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(true);
expect(matches("stockPrice", path, { stock: "MSFT", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(false);
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "5d", timeInterval: "1d" }, scope)).toBe(false);
});
test("matches Weather by the coordinates sent to the location procedure", () => {
const scope = createScope({
options: { location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 } },
});
const path = ["widget", "weather", "atLocation"];
expect(matches("weather", path, { latitude: 48.85341, longitude: 2.3488 }, scope)).toBe(true);
expect(matches("weather", path, { latitude: 51.5072, longitude: -0.1276 }, scope)).toBe(false);
expect(
matches("weather", ["widget", "weather", "forecast"], { latitude: 48.85341, longitude: 2.3488 }, scope),
).toBe(false);
});
test("matches Clock weather only when enabled and at its configured coordinates", () => {
const path = ["widget", "weather", "atLocation"];
const location = { name: "Brisbane", latitude: -27.4698, longitude: 153.0251 };
const enabledScope = createScope({ options: { showWeather: true, weatherLocation: location } });
const disabledScope = createScope({ options: { showWeather: false, weatherLocation: location } });
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, enabledScope)).toBe(true);
expect(matches("clock", path, { latitude: 48.85341, longitude: 2.3488 }, enabledScope)).toBe(false);
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, disabledScope)).toBe(false);
});
test("matches Minecraft Server Status by the full input passed to useQuery", () => {
const scope = createScope({
options: { title: "Survival", domain: "play.example.com", isBedrockServer: false },
});
const path = ["widget", "minecraft", "getServerStatus"];
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Survival", domain: "play.example.com", isBedrockServer: false },
scope,
),
).toBe(true);
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Creative", domain: "play.example.com", isBedrockServer: false },
scope,
),
).toBe(false);
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Survival", domain: "bedrock.example.com", isBedrockServer: true },
scope,
),
).toBe(false);
});
test("matches Notifications by integrations and the full input passed to useQuery", () => {
const scope = createScope({ integrationIds: ["ntfy-1"], options: { hideLogos: true } });
const path = ["widget", "notifications", "getNotifications"];
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["ntfy-1"] }, scope)).toBe(true);
expect(matches("notifications", path, { hideLogos: false, integrationIds: ["ntfy-1"] }, scope)).toBe(false);
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["gotify-1"] }, scope)).toBe(false);
expect(
matches("notifications", ["widget", "notifications", "dismiss"], { integrationIds: ["ntfy-1"] }, scope),
).toBe(false);
});
test("keeps renamed RSS input fields item-specific", () => {
const scope = createScope({
integrationIds: [],
options: { feedUrls: ["https://feed.example/rss"], maximumAmountPosts: 100 },
});
const path = ["widget", "rssFeed", "getFeeds"];
expect(matches("rssFeed", path, { urls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(true);
expect(matches("rssFeed", path, { urls: ["https://other.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(
false,
);
});
test("generic fallback ignores transformed non-identity inputs", () => {
const scope = createScope({ integrationIds: ["integration-1"], options: { pageSize: 10 } });
const definitionKeys = [[["widget", "example"]]] as const;
expect(
matchesWidgetItemQuery(
[["widget", "example", "getData"], { input: { integrationIds: ["integration-1"], pageSize: 50 } }],
definitionKeys,
scope,
),
).toBe(true);
});
});
@@ -0,0 +1,94 @@
import type { QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { NormalizedWidgetQuery, WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
export const matchesWidgetItemQuery = (
queryKey: QueryKey,
widgetQueryKeys: readonly QueryKey[],
scope: WidgetQueryMatcherScope,
queryMatcher?: WidgetQueryMatcher,
) => {
const query = normalizeWidgetQuery(queryKey);
if (!query || !widgetQueryKeys.some((candidate) => matchesDefinitionPath(query.path, candidate))) return false;
if (queryMatcher) return queryMatcher(query, scope);
return matchesQueryInput(query.input, scope);
};
const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => {
const candidatePath = candidate[0];
return (
Array.isArray(candidatePath) &&
candidatePath.every((part, index) => typeof part === "string" && path[index] === part)
);
};
const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
if (input == null) return true;
if (Array.isArray(input)) {
return isEqual(input, scope.integrationIds) || containsValue(scope.options, input);
}
if (!isRecord(input)) {
return (
input === scope.itemId ||
input === scope.boardId ||
scope.integrationIds.includes(String(input)) ||
containsValue(scope.options, input)
);
}
for (const [key, value] of Object.entries(input)) {
if (key === "itemId") {
if (value !== scope.itemId) return false;
continue;
}
if (key === "boardId") {
if (value !== scope.boardId) return false;
continue;
}
if (key === "integrationId") {
if (!scope.integrationIds.includes(String(value))) return false;
continue;
}
if (key === "integrationIds") {
if (!isEqual(value, scope.integrationIds)) return false;
continue;
}
if (isIdentityKey(key) && !matchesAnyScopedValue(value, scope)) return false;
}
return true;
};
const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) =>
value === scope.itemId ||
value === scope.boardId ||
scope.integrationIds.includes(String(value)) ||
containsValue(scope.options, value);
const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids");
const containsValue = (container: unknown, expected: unknown, seen = new WeakSet<object>()): boolean => {
if (isEqual(container, expected)) return true;
if (container === null || typeof container !== "object" || seen.has(container)) return false;
seen.add(container);
return Object.values(container).some((value) => containsValue(value, expected, seen));
};
const isEqual = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
@@ -0,0 +1,4 @@
.suppressTouchCallout {
user-select: none;
-webkit-touch-callout: none;
}
@@ -17,6 +17,10 @@ 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;
@@ -39,18 +43,25 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
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 clearLongPress = () => {
if (longPressTimer.current === null) return;
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
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);
},
[],
);
@@ -89,12 +100,31 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
clearLongPress();
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
touchGuardElement.current = event.currentTarget;
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
longPressTimer.current = setTimeout(() => {
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 ||
@@ -134,10 +164,10 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onPointerLeave={clearLongPress}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onPointerLeave={handlePointerEnd}
>
{children}
</Box>
@@ -0,0 +1,17 @@
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);
});
});
@@ -0,0 +1,7 @@
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;