c465530a0e11

fix: Preserve NoteRow mouse input on touch devices (v4.10.40)

Route touch taps through the native click event so hybrid devices keep working with mouse and trackpad input. Suppress the synthetic click after swipes and cover tap, hybrid-click, and swipe behavior with focused jsdom tests.

time
2026-08-29 18:56 UTC · 1 month ago
machine
koh2
parent
aff447a19549
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/NoteRow.test.tsx+82 −0src/popup/components/NoteRow.tsx+17 −5
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.39",4+  "version": "4.10.40",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "4.10.39",5+  "version": "4.10.40",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.10.39",3+  "version": "4.10.40",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/components/NoteRow.test.tsx+82 −0
@@ -0,0 +1,82 @@1+// @vitest-environment jsdom2+3+import type { JSX } from "solid-js";4+import { afterEach, describe, expect, it, vi } from "vitest";5+import type { VaultEntry } from "../../shared/types";6+import { renderView } from "../views/view-test-utils";7+import { NoteRow } from "./NoteRow";8+9+const note = {10+  id: "note-1",11+  type: "note",12+  title: "Hybrid input",13+  modifiedAt: Date.now(),14+} as VaultEntry;15+16+function touchEvent(type: string, x: number, y: number): TouchEvent {17+  const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent;18+  const touch = { clientX: x, clientY: y } as Touch;19+  Object.defineProperties(event, {20+    touches: { value: type === "touchend" ? [] : [touch] },21+    changedTouches: { value: [touch] },22+  });23+  return event;24+}25+26+describe("NoteRow input", () => {27+  const cleanups: Array<() => void> = [];28+29+  afterEach(() => {30+    while (cleanups.length) cleanups.pop()!();31+  });32+33+  function mounted(props: { onTap: () => void; onArchive?: () => void }) {34+    const view = renderView(() => (35+      <NoteRow36+        note={note}37+        index={0}38+        onTap={props.onTap}39+        onDelete={() => {}}40+        onArchive={props.onArchive ?? (() => {})}41+      />42+    ) as JSX.Element);43+    cleanups.push(view.dispose);44+    return view.container.querySelector(".peach-note-row") as HTMLDivElement;45+  }46+47+  it("opens once when a touch tap produces its click", () => {48+    const onTap = vi.fn();49+    const row = mounted({ onTap });50+51+    row.dispatchEvent(touchEvent("touchstart", 20, 20));52+    row.dispatchEvent(touchEvent("touchend", 20, 20));53+    row.click();54+55+    expect(onTap).toHaveBeenCalledTimes(1);56+  });57+58+  it("accepts a mouse click even when the browser supports touch", () => {59+    const onTap = vi.fn();60+    const row = mounted({ onTap });61+    Object.defineProperty(window, "ontouchstart", { configurable: true, value: null });62+63+    row.click();64+65+    expect(onTap).toHaveBeenCalledTimes(1);66+  });67+68+  it("does not open after a swipe click", () => {69+    const onTap = vi.fn();70+    const onArchive = vi.fn();71+    const row = mounted({ onTap, onArchive });72+73+    row.dispatchEvent(touchEvent("touchstart", 10, 20));74+    row.dispatchEvent(touchEvent("touchmove", 140, 20));75+    row.dispatchEvent(touchEvent("touchmove", 140, 20));76+    row.dispatchEvent(touchEvent("touchend", 140, 20));77+    row.click();78+79+    expect(onArchive).toHaveBeenCalledTimes(1);80+    expect(onTap).not.toHaveBeenCalled();81+  });82+});

view file

src/popup/components/NoteRow.tsx+17 −5
@@ -49,6 +49,8 @@4949   let touchStartX = 0;5050   let touchStartY = 0;5151   let captured = false;52+  let suppressNextClick = false;53+  let touchTapPending = false;5254 5355   function handleTouchStart(e: TouchEvent) {5456     const t = e.touches[0];@@ -56,6 +58,8 @@5658     touchStartX = t.clientX;5759     touchStartY = t.clientY;5860     captured = false;61+    suppressNextClick = false;62+    touchTapPending = false;5963   }6064 6165   function handleTouchMove(e: TouchEvent) {@@ -81,13 +85,13 @@8185 8286   function handleTouchEnd(_e: TouchEvent) {8387     if (!captured) {84-      // Tap85-      props.onTap(props.note, rowEl);86-      vibrate(12);88+      // Let the browser's click event handle taps and mouse input alike.89+      touchTapPending = true;8790       setSwping(false);8891       return;8992     }9093 94+    suppressNextClick = true;9195     const dx = swipeX();9296     setSwping(false);9397 @@ -113,6 +117,8 @@113117     setSwipeX(0);114118     setSwping(false);115119     captured = false;120+    suppressNextClick = false;121+    touchTapPending = false;116122   }117123 118124   // Highlight search match in title@@ -145,8 +151,14 @@145151       onTouchEnd={handleTouchEnd}146152       onTouchCancel={handleTouchCancel}147153       onClick={() => {148-        if (!("ontouchstart" in window)) {149-          props.onTap(props.note, rowEl);154+        if (suppressNextClick) {155+          suppressNextClick = false;156+          return;157+        }158+        props.onTap(props.note, rowEl);159+        if (touchTapPending) {160+          touchTapPending = false;161+          vibrate(12);150162         }151163       }}152164     >

view file