c465530a0e11
fix: Preserve NoteRow mouse input on touch devices (v4.10.40)
- time
- 2026-08-29 18:56 UTC · 1 month ago
- machine
- koh2
- parent
- aff447a19549
↓ peach-browser-c465530a.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/NoteRow.test.tsx+82 −0src/popup/components/NoteRow.tsx+17 −5manifest.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": {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",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",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+});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 >