Merge pull request 'dev' (#80) from dev into main
Reviewed-on: #80
This commit was merged in pull request #80.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// handleSelect lazy-fetches children via queryClient.fetchQuery; a promise that
|
||||
// never resolves keeps loadingId pinned so we can assert the loading state.
|
||||
vi.mock("@/lib/api-client", () => ({ api: { get: vi.fn() } }));
|
||||
vi.mock("@/lib/category-icons", () => ({ getCategoryIcon: () => () => null }));
|
||||
vi.mock("@sase/ui", () => ({
|
||||
cn: (...a: unknown[]) => a.filter(Boolean).join(" "),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() }));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ fetchQuery: () => new Promise(() => {}) }),
|
||||
}));
|
||||
|
||||
import { CategoryColumns } from "../category-columns";
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("CategoryColumns selection loading state (#73)", () => {
|
||||
it("disables the row and shows a spinner while the child column is being fetched", () => {
|
||||
// children undefined → handleSelect takes the lazy-fetch branch.
|
||||
render(<CategoryColumns vehicleId="v1" categories={[{ id: "c1", name: "Motor" }]} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /Motor/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
expect(button.getAttribute("aria-busy")).toBe("false");
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
expect(button.getAttribute("aria-busy")).toBe("true");
|
||||
expect(button.querySelector(".animate-spin")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,35 @@ describe("CategoryGrid navigation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("paints an immediate disabled + aria-busy pending state on the clicked card (#73)", () => {
|
||||
render(
|
||||
<CategoryGrid
|
||||
vehicleId="v1"
|
||||
categories={[
|
||||
{ id: "c1", name: "Motor", parentId: null, children: [{ id: "c2", name: "Silindir" }] },
|
||||
{ id: "c3", name: "Fren", parentId: null, children: [{ id: "c4", name: "Disk" }] },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clicked = screen.getByText("Motor").closest("button");
|
||||
const other = screen.getByText("Fren").closest("button");
|
||||
if (!clicked || !other) throw new Error("category buttons not found");
|
||||
|
||||
// Before the click neither card claims to be busy.
|
||||
expect(clicked.getAttribute("aria-busy")).toBe("false");
|
||||
expect(clicked.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
fireEvent.click(clicked);
|
||||
|
||||
// The tapped card is immediately disabled + aria-busy so the user sees the
|
||||
// tap registered (and cannot re-fire it); siblings stay interactive.
|
||||
expect(clicked.hasAttribute("disabled")).toBe(true);
|
||||
expect(clicked.getAttribute("aria-busy")).toBe("true");
|
||||
expect(other.hasAttribute("disabled")).toBe(false);
|
||||
expect(other.getAttribute("aria-busy")).toBe("false");
|
||||
});
|
||||
|
||||
it("catalog mode: parent click routes to the catalog page, preserving variant + mgp, without seeding", () => {
|
||||
render(
|
||||
<CategoryGrid
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// handleExpand drives loading through queryClient.fetchQuery; hand it a promise
|
||||
// that never resolves so the node stays in its loading state for assertions.
|
||||
vi.mock("@/lib/api-client", () => ({ api: { get: vi.fn() } }));
|
||||
vi.mock("@/lib/category-icons", () => ({ getCategoryIcon: () => () => null }));
|
||||
vi.mock("@sase/ui", () => ({
|
||||
cn: (...a: unknown[]) => a.filter(Boolean).join(" "),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, ...p }: any) => <a {...p}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ fetchQuery: () => new Promise(() => {}) }),
|
||||
}));
|
||||
|
||||
import { CategoryTree } from "../category-tree";
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("CategoryTree node loading state (#73)", () => {
|
||||
it("disables the node and shows a spinner while its children are being fetched", () => {
|
||||
// children undefined → not yet fetched → renders an expandable button.
|
||||
render(<CategoryTree vehicleId="v1" categories={[{ id: "c1", name: "Motor" }]} />);
|
||||
|
||||
const button = screen.getByRole("button", { name: /Motor/ });
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
expect(button.getAttribute("aria-busy")).toBe("false");
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
// Lazy fetch is in flight (never resolves here) → node is locked + busy and
|
||||
// an animated spinner is rendered in place of the chevron.
|
||||
expect(button.hasAttribute("disabled")).toBe(true);
|
||||
expect(button.getAttribute("aria-busy")).toBe("true");
|
||||
expect(button.querySelector(".animate-spin")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,8 @@ function ColumnPanel({
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
@@ -244,7 +246,9 @@ function ColumnPanel({
|
||||
)}
|
||||
<span className="flex-1 truncate">{category.name}</span>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
// 20px primary spinner (was 14px muted) — clear feedback that the
|
||||
// lazy child fetch is in flight; button is disabled meanwhile (#73).
|
||||
<Loader2 className="h-5 w-5 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
@@ -45,6 +45,11 @@ export function CategoryGrid({
|
||||
|
||||
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
|
||||
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
||||
// The card the user just clicked. Drilling is a route navigation, so there is
|
||||
// a 0–300 ms gap before the next page renders during which the card gave no
|
||||
// feedback → users re-tapped (rage clicks, issue #73). Track it to show an
|
||||
// immediate disabled + spinner-overlay state until this component unmounts.
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Filter the current level by name — deep levels (e.g. Elektrik has 62
|
||||
@@ -128,6 +133,9 @@ export function CategoryGrid({
|
||||
// ancestors) refetches in the background.
|
||||
const navigateToCategory = useCallback(
|
||||
(category: Category) => {
|
||||
// Paint the pending state on the same tick as the click so feedback is
|
||||
// instant; it clears naturally when the route swap unmounts the grid.
|
||||
setPendingId(category.id);
|
||||
if (!catalogMode && category.children && category.children.length > 0) {
|
||||
queryClient.setQueryData(["category-parts", vehicleId, category.id], {
|
||||
id: category.id,
|
||||
@@ -195,12 +203,15 @@ export function CategoryGrid({
|
||||
const isLeaf = category.children !== undefined && category.children.length === 0;
|
||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||
|
||||
const isPending = pendingId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => navigateToCategory(category)}
|
||||
disabled={category.unavailable}
|
||||
disabled={category.unavailable || isPending}
|
||||
aria-busy={isPending}
|
||||
className={`w-full rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${category.unavailable ? "opacity-40" : ""}`}
|
||||
>
|
||||
<CategoryCard
|
||||
@@ -210,6 +221,7 @@ export function CategoryGrid({
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id)}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -227,6 +239,7 @@ function CategoryCard({
|
||||
schemaImageUrl,
|
||||
isLeaf,
|
||||
isLoading,
|
||||
isPending,
|
||||
}: {
|
||||
name: string;
|
||||
partCount?: number;
|
||||
@@ -234,12 +247,23 @@ function CategoryCard({
|
||||
schemaImageUrl?: string | null;
|
||||
isLeaf?: boolean;
|
||||
isLoading?: boolean;
|
||||
isPending?: boolean;
|
||||
}) {
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
|
||||
// Click-pending overlay: a large (28px) centred spinner over a dimmed card,
|
||||
// shown on either card variant. This is the primary "your tap registered"
|
||||
// signal the previous design lacked (#73).
|
||||
const pendingOverlay = isPending ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-background/60 backdrop-blur-[1px]">
|
||||
<Loader2 className="h-7 w-7 animate-spin text-primary" />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (schemaImageUrl || (isLeaf && isLoading)) {
|
||||
return (
|
||||
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
|
||||
<Card className="relative border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full overflow-hidden">
|
||||
{pendingOverlay}
|
||||
<CardContent className="p-0">
|
||||
<div className="relative aspect-[4/3] bg-muted overflow-hidden">
|
||||
{(!schemaImageUrl || !imgLoaded) && (
|
||||
@@ -268,7 +292,8 @@ function CategoryCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full">
|
||||
<Card className="relative border hover:border-foreground/20 hover:shadow-md transition-all cursor-pointer group h-full">
|
||||
{pendingOverlay}
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0 group-hover:bg-primary/20 transition-colors">
|
||||
|
||||
@@ -173,7 +173,10 @@ function CategoryNode({
|
||||
<>
|
||||
<span aria-hidden className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
// Bigger (20px), primary-tinted spinner so the click feedback reads
|
||||
// clearly under a thumb on mobile — the old 14px muted one was missed
|
||||
// and drove re-taps (#73).
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
) : isLeaf ? null : (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
@@ -222,6 +225,8 @@ function CategoryNode({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
disabled={loading}
|
||||
aria-busy={loading}
|
||||
aria-expanded={expanded}
|
||||
className={rowClass}
|
||||
style={indentStyle}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
@@ -164,7 +169,13 @@ export function SchemaViewer({
|
||||
</div>
|
||||
|
||||
{/* Right side: Parts panel (40%) */}
|
||||
<div className="max-h-[500px] w-full md:max-h-none md:w-[40%]">
|
||||
{/* No mobile height cap: a `max-h` parent does not give the panel's
|
||||
`h-full` a definite height, so the table resolved to its full ~2500px
|
||||
and spilled out below the page footer (broken mobile layout). Letting
|
||||
it flow lets the page scroll naturally through the parts — the
|
||||
intended mobile pattern (#76). Desktop keeps the 40% column inside the
|
||||
md:h-[700px] row with its own inner scroll. */}
|
||||
<div className="w-full md:w-[40%]">
|
||||
<PartsPanel
|
||||
parts={parts}
|
||||
vehicleId={vehicleId}
|
||||
|
||||
80
apps/web/src/hooks/__tests__/use-schema-interaction.test.tsx
Normal file
80
apps/web/src/hooks/__tests__/use-schema-interaction.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user