fix(web): mobile schema page traps users above the parts list (#76)

On mobile (iOS Safari, 390px), the schema viewport's one-finger touchmove
handler called setPan() unconditionally — even at zoom 1 with nothing to
pan — which makes iOS suppress the native page scroll. Users couldn't reach
the parts list below the fold (real trial session: 16 rage clicks, 0 OEM
codes copied, no purchase).

- use-schema-interaction: gate one-finger pan on zoom > 1 so the gesture
  falls through to native page scroll when not zoomed in.
- schema-viewer: set touch-action (pan-y pinch-zoom at zoom 1, none when
  zoomed) on the viewport; shrink mobile schema height 400px -> 280px so the
  first parts rows peek below the fold and signal "more below".
- parts-panel: after a hotspot tap, scrollIntoView uses block:"start" on
  mobile (panel is below the fold) and block:"center" on desktop, so the
  selection is actually visible.

Tests: new Vitest hook test asserts no pan at zoom<=1, pans at zoom>1, and
pinch-zoom still works; new parts-panel scroll test asserts mobile vs desktop
block target. Also fixes a pre-existing typo in the empty-state test ("Geri
don" -> "Geri dön").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:34:47 +03:00
parent 00f7edbc15
commit e4b13c6abb
6 changed files with 175 additions and 4 deletions

View File

@@ -0,0 +1,77 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Part } from "@/hooks/use-parts";
// Mutable store mock so each test can pre-select a hotspot group before mount.
const storeState: {
highlightedGroup: number | null;
selectedGroup: number | null;
setHighlightedGroup: ReturnType<typeof vi.fn>;
setSelectedGroup: ReturnType<typeof vi.fn>;
} = {
highlightedGroup: null,
selectedGroup: null,
setHighlightedGroup: vi.fn(),
setSelectedGroup: vi.fn(),
};
vi.mock("@/lib/posthog", () => ({ capture: vi.fn() }));
vi.mock("@/lib/api-client", () => ({
api: { post: vi.fn().mockResolvedValue({}), get: vi.fn().mockResolvedValue({}) },
}));
vi.mock("@/stores/schema.store", () => ({ useSchemaStore: () => storeState }));
import { PartsPanel } from "../parts-panel";
const buildPart = (overrides: Partial<Part> = {}): Part => ({
id: overrides.id ?? "p1",
name: overrides.name ?? "Part 1",
oemCode: overrides.oemCode ?? "OEM-1",
quantity: overrides.quantity ?? 1,
position: overrides.position ?? "Front",
hotspotIndex: overrides.hotspotIndex ?? 1,
unavailable: overrides.unavailable,
remark: overrides.remark,
modelCodes: overrides.modelCodes,
presel: overrides.presel,
price: overrides.price,
currency: overrides.currency,
note: overrides.note,
});
const scrollIntoViewSpy = vi.fn();
function setViewportWidth(width: number) {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
}
beforeEach(() => {
scrollIntoViewSpy.mockClear();
// jsdom does not implement scrollIntoView — stub it so the effect can run.
Element.prototype.scrollIntoView = scrollIntoViewSpy;
storeState.selectedGroup = 1;
});
afterEach(() => {
cleanup();
setViewportWidth(1024);
});
describe("PartsPanel — selected-row scroll target (issue #76)", () => {
it('uses block:"start" on a mobile viewport so the panel scrolls into view', () => {
setViewportWidth(390);
render(<PartsPanel parts={[buildPart({ id: "a", hotspotIndex: 1 })]} />);
expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1);
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ behavior: "smooth", block: "start" });
});
it('uses block:"center" on a desktop viewport (side panel always visible)', () => {
setViewportWidth(1280);
render(<PartsPanel parts={[buildPart({ id: "a", hotspotIndex: 1 })]} />);
expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1);
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ behavior: "smooth", block: "center" });
});
});

View File

@@ -113,11 +113,11 @@ describe("PartsPanel", () => {
expect(availableRow.getAttribute("aria-disabled")).toBeNull();
});
it("renders a Geri don button in the empty state that invokes window.history.back", () => {
it("renders a Geri dön button in the empty state that invokes window.history.back", () => {
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
render(<PartsPanel parts={[]} />);
const button = screen.getByRole("button", { name: "Geri don" });
const button = screen.getByRole("button", { name: "Geri dön" });
expect(button).toBeInTheDocument();
fireEvent.click(button);

View File

@@ -83,7 +83,11 @@ export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPan
if (selectedGroup != null) {
const row = rowRefs.current.get(selectedGroup);
if (row) {
row.scrollIntoView({ behavior: "smooth", block: "center" });
// On mobile the parts panel sits below the fold, so a hotspot tap must
// pull the panel into the viewport (block:"start") for the selection to
// be visible at all; desktop centres the row in the side panel (#76).
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
row.scrollIntoView({ behavior: "smooth", block: isMobile ? "start" : "center" });
}
}
}, [selectedGroup]);

View File

@@ -116,7 +116,9 @@ export function SchemaViewer({
)}
>
{/* Left side: Schema image + hotspot overlay (60%) */}
<div className="relative flex h-[400px] flex-col border-b border-border md:h-auto md:w-[60%] md:border-b-0 md:border-r">
{/* Mobile height kept short (280px) so the first parts rows peek below the
fold, signalling "more below"; desktop uses the 60/40 side-by-side split. */}
<div className="relative flex h-[280px] flex-col border-b border-border md:h-auto md:w-[60%] md:border-b-0 md:border-r">
{/* Toolbar */}
<div className="absolute left-3 top-3 z-20">
<SchemaToolbar />
@@ -125,6 +127,9 @@ export function SchemaViewer({
{/* Schema viewport */}
<div
ref={viewportRef}
// At zoom 1 allow vertical page scroll + pinch (so mobile users can
// reach the parts list); once zoomed in, lock the surface to pan.
style={{ touchAction: zoom > 1 ? "none" : "pan-y pinch-zoom" }}
className="relative flex-1 cursor-grab overflow-hidden bg-muted/30 active:cursor-grabbing"
onMouseDown={interaction.onMouseDown}
onMouseMove={interaction.onMouseMove}

View File

@@ -0,0 +1,80 @@
import { act, renderHook } from "@testing-library/react";
import type { TouchEvent as ReactTouchEvent } from "react";
import { beforeEach, describe, expect, it } from "vitest";
import { useSchemaStore } from "@/stores/schema.store";
import { useSchemaInteraction } from "../use-schema-interaction";
function touchEvent(points: { x: number; y: number }[]): ReactTouchEvent {
return {
touches: points.map((p) => ({ clientX: p.x, clientY: p.y })),
preventDefault: () => {},
} as unknown as ReactTouchEvent;
}
beforeEach(() => {
useSchemaStore.setState({
zoom: 1,
panX: 0,
panY: 0,
selectedGroup: null,
highlightedGroup: null,
isFullscreen: false,
});
});
describe("useSchemaInteraction — one-finger pan gate (issue #76)", () => {
it("does NOT pan on a one-finger drag at zoom <= 1 so the page can scroll", () => {
const { result } = renderHook(() => useSchemaInteraction());
act(() => {
result.current.onTouchStart(touchEvent([{ x: 100, y: 200 }]));
// Swipe up 80px — the user trying to reach the parts list below the fold.
result.current.onTouchMove(touchEvent([{ x: 100, y: 120 }]));
});
const { panX, panY } = useSchemaStore.getState();
expect(panX).toBe(0);
expect(panY).toBe(0);
});
it("pans on a one-finger drag once zoomed in (> 1)", () => {
const { result, rerender } = renderHook(() => useSchemaInteraction());
act(() => {
useSchemaStore.setState({ zoom: 2 });
});
rerender();
act(() => {
result.current.onTouchStart(touchEvent([{ x: 100, y: 200 }]));
result.current.onTouchMove(touchEvent([{ x: 140, y: 230 }]));
});
const { panX, panY } = useSchemaStore.getState();
expect(panX).toBe(40);
expect(panY).toBe(30);
});
it("still pinch-zooms on a two-finger gesture regardless of the gate", () => {
const { result } = renderHook(() => useSchemaInteraction());
act(() => {
result.current.onTouchStart(
touchEvent([
{ x: 100, y: 100 },
{ x: 200, y: 100 },
]),
);
// Fingers move apart from 100px to 200px → 2x zoom.
result.current.onTouchMove(
touchEvent([
{ x: 50, y: 100 },
{ x: 250, y: 100 },
]),
);
});
expect(useSchemaStore.getState().zoom).toBeGreaterThan(1);
});
});

View File

@@ -80,6 +80,11 @@ export function useSchemaInteraction() {
}
lastTouchCenter.current = { x: centerX, y: centerY };
} else if (e.touches.length === 1 && isDragging.current) {
// Only consume a one-finger drag when actually zoomed in. At zoom <= 1
// there is nothing to pan, and stealing the gesture makes iOS Safari
// suppress the page scroll — which traps mobile users above the parts
// list (issue #76). Let the touch fall through to native scrolling.
if (zoom <= 1) return;
const dx = e.touches[0].clientX - lastMousePos.current.x;
const dy = e.touches[0].clientY - lastMousePos.current.y;
lastMousePos.current = {