Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,7 @@
2025-11-08 move wiki data loading into a built workspace dependency and add provider tests wiki-data-reader item-optimizer/src/itemDataProvider.ts item-optimizer/src/__tests__
2025-11-08 add wiki snapshot refresh + parser/update scripts with hero ingestion wiki-data-reader/scripts wiki-data-reader/snapshots
2025-11-09 fetch remote item icon URLs from wiki snapshots and remove local PNGs wiki-data-reader/scripts item-optimizer/src

2025-12-18 add mobile tap-to-toggle detail previews in Item Gallery item-optimizer/src/components/ItemGallery.tsx
2025-12-19 clamp tooltip position and anchor mobile taps to item bounds item-optimizer/src/components/ItemGallery.tsx item-optimizer/src/utils/tooltipUtils.ts
2025-12-20 keep Item Gallery detail sticky on mobile and disable tooltip taps item-optimizer/src/components/ItemGallery.tsx
45 changes: 39 additions & 6 deletions item-optimizer/src/components/ItemGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { useAppDispatch, useAppSelector } from "../hooks";
import { clearTooltip, setTooltip } from "../slices/tooltipSlice";
import type { Item, ItemOverride } from "../types";
Expand Down Expand Up @@ -36,6 +36,24 @@ export default function ItemGallery({ items, heroes, attrTypes }: Props) {
const dispatch = useAppDispatch();
const overrideVersion = useAppSelector((s) => s.input.present.overrideVersion);
const overrides = useMemo(loadLocalOverrides, [overrideVersion]);
const isMobile = useMemo(() => {
if (typeof window === "undefined") return false;
if (window.matchMedia?.("(pointer: coarse)")?.matches) return true;
return window.innerWidth < 640;
}, []);

useEffect(() => {
return () => {
dispatch(clearTooltip());
};
}, [dispatch]);

function handleItemClick(it: Item) {
setSelected(it);
if (isMobile) {
dispatch(clearTooltip());
}
}

const filtered = items.filter(
(it) =>
Expand All @@ -47,7 +65,12 @@ export default function ItemGallery({ items, heroes, attrTypes }: Props) {
<div className="glass-card space-y-6 rounded-xl shadow-lg p-4 sm:p-6 bg-white dark:bg-gray-800 dark:border-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">Configuration</h2>
<div className="relative">
<div>
<div
className={`space-y-2 ${
isMobile ? "sticky top-0 z-10 bg-white dark:bg-gray-800 pb-4" : ""
}`}
data-testid="item-detail-panel"
>
{selected && (
<div className="space-y-2">
<div className="space-y-1">
Expand Down Expand Up @@ -210,10 +233,20 @@ export default function ItemGallery({ items, heroes, attrTypes }: Props) {
<button
key={idx}
type="button"
onClick={() => setSelected(it)}
onMouseEnter={(e) => dispatch(setTooltip({ item: it, x: e.clientX, y: e.clientY }))}
onMouseMove={(e) => dispatch(setTooltip({ item: it, x: e.clientX, y: e.clientY }))}
onMouseLeave={() => dispatch(clearTooltip())}
onClick={() => handleItemClick(it)}
onMouseEnter={
isMobile
? undefined
: (e: MouseEvent<HTMLButtonElement>) =>
dispatch(setTooltip({ item: it, x: e.clientX, y: e.clientY }))
}
onMouseMove={
isMobile
? undefined
: (e: MouseEvent<HTMLButtonElement>) =>
dispatch(setTooltip({ item: it, x: e.clientX, y: e.clientY }))
}
onMouseLeave={isMobile ? undefined : () => dispatch(clearTooltip())}
className="relative flex flex-col items-center gap-1 p-2 rounded border dark:border-gray-700 bg-white dark:bg-gray-900 hover:bg-gray-100 dark:hover:bg-gray-800"
>
{overrides[it.name] && (
Expand Down
31 changes: 31 additions & 0 deletions item-optimizer/src/components/__tests__/ItemGallery.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { Provider } from "react-redux";
import ItemGallery from "../ItemGallery";
import store from "../../store";
Expand Down Expand Up @@ -124,4 +125,34 @@ describe("ItemGallery", () => {
fireEvent.click(getByText("Save"));
expect(localStorage.getItem("localOverrides")).toBe("{}");
});

it("keeps the detail panel sticky on mobile and avoids tooltip taps", () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = vi.fn().mockImplementation((query) => ({
matches: query === "(pointer: coarse)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));

const { getByText, getByTestId } = render(
<Provider store={store}>
<ItemGallery items={items} heroes={heroes} attrTypes={attrTypes} />
</Provider>,
);

expect(getByTestId("item-detail-panel")).toHaveClass("sticky");

const secondItem = getByText("Two").closest("button")!;
fireEvent.click(secondItem);

expect(store.getState().tooltip).toBeNull();
expect(getByText("ability")).toBeInTheDocument();

window.matchMedia = originalMatchMedia;
});
});
34 changes: 34 additions & 0 deletions item-optimizer/src/utils/__tests__/tooltipUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* @vitest-environment jsdom */
import { getTooltipStyle } from "../tooltipUtils";

describe("getTooltipStyle", () => {
const originalWidth = Object.getOwnPropertyDescriptor(window, "innerWidth");
const originalHeight = Object.getOwnPropertyDescriptor(window, "innerHeight");

afterEach(() => {
if (originalWidth?.get) {
Object.defineProperty(window, "innerWidth", originalWidth);
}
if (originalHeight?.get) {
Object.defineProperty(window, "innerHeight", originalHeight);
}
});

it("keeps tooltip within viewport when space is limited", () => {
Object.defineProperty(window, "innerWidth", { value: 280, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 200, configurable: true });

const style = getTooltipStyle(10, 10, { width: 260, height: 180, offset: 12 });
expect(style.left).toBe(12);
expect(style.top).toBe(12);
});

it("flips position and clamps to max viewport space", () => {
Object.defineProperty(window, "innerWidth", { value: 360, configurable: true });
Object.defineProperty(window, "innerHeight", { value: 300, configurable: true });

const style = getTooltipStyle(350, 280, { width: 200, height: 180, offset: 12 });
expect(style.left).toBeLessThanOrEqual(148);
expect(style.top).toBeLessThanOrEqual(108);
});
});
6 changes: 5 additions & 1 deletion item-optimizer/src/utils/tooltipUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface TooltipStyleOptions {
export function getTooltipStyle(
x: number,
y: number,
options: TooltipStyleOptions = {}
options: TooltipStyleOptions = {},
): React.CSSProperties {
const winW = window.innerWidth;
const winH = window.innerHeight;
Expand All @@ -28,6 +28,10 @@ export function getTooltipStyle(
if (top + TOOLTIP_HEIGHT > winH) {
top = y - TOOLTIP_HEIGHT - OFFSET;
}
const maxLeft = Math.max(OFFSET, winW - TOOLTIP_WIDTH - OFFSET);
const maxTop = Math.max(OFFSET, winH - TOOLTIP_HEIGHT - OFFSET);
left = Math.min(Math.max(left, OFFSET), maxLeft);
top = Math.min(Math.max(top, OFFSET), maxTop);
return {
left,
top,
Expand Down