264e3339f4cd

implement progressive bottom-sheet drag for vault list on native: always-mounted .peach-vault-sheet wrapper with rest position translateY(calc(100% - 60px)) (peek bar visible) and is-open class for translateY(0). Touch handlers in App.tsx track 1:1 finger position via sheetTranslate signal, disable transition during drag, snap to open/closed at touchend by velocity (>0.4 px/ms) or position (50% threshold). Horizontal swipes >16px without matching vertical motion abort so entry-nav stays usable. Also instrument settings gear click with console.log to diagnose unreachable setting nav

time
2026-05-07 04:09 UTC · 5 months ago
machine
koh2
parent
9a83c9b80541
1 file src/popup/App.tsx+108 −36
src/popup/App.tsx+108 −36
@@ -115,6 +115,9 @@115115 export default function App() {116116   const [bootState, setBootState] = createSignal<BootState>("loading");117117   const [view, setView] = createSignal<View>("lock");118+  // Mobile bottom-sheet live drag offset in px (null = not actively dragging,119+  // CSS transition takes over for snap animation).120+  const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);118121   const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(119122     "enter"120123   );@@ -506,69 +509,109 @@506509     window.addEventListener("peach:back", handlePeachBack);507510     onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));508511 509-    // Mobile bottom-sheet gesture: swipe up from the bottom 40% of the screen510-    // reveals the vault list; swipe down from the top of the list dismisses it.511-    // Tracked at document level so the search bar input doesn't have to be the512-    // touch target — anywhere in the lower band counts.512+    // Mobile bottom-sheet drag tracking. The vault list sits as a sheet that513+    // peeks from the bottom (search bar handle visible) and drags up to fill514+    // the screen. dragOffset is the LIVE finger offset during a drag, in px,515+    // 0 when no drag in progress. The wrapper element computes transform =516+    // (rest position) + dragOffset, so the user sees 1:1 finger tracking.513517     if (isNativePlatform()) {514518       let touchStartY = 0;515519       let touchStartX = 0;520+      let lastY = 0;521+      let lastT = 0;516522       let dragging = false;517-      let startedInBand = false;518-      const SWIPE_THRESHOLD = 60;519-      const HORIZONTAL_TOLERANCE = 30;523+      let captured = false;524+      const HORIZONTAL_TOLERANCE = 16;525+      const ACTIVATION_DY = 8;520526 521527       const onTouchStart = (e: TouchEvent) => {522528         const t = e.touches[0];523529         if (!t) return;524-        touchStartY = t.clientY;530+        const v = view();531+        if (v !== "entry" && v !== "list") return;532+        // Entry view: only intercept drags starting in the bottom 40%.533+        // List view: intercept drags starting in the top 35% (the search/handle row).534+        const inBand = v === "entry"535+          ? t.clientY > window.innerHeight * 0.6536+          : t.clientY < window.innerHeight * 0.35;537+        if (!inBand) return;538+        touchStartY = lastY = t.clientY;525539         touchStartX = t.clientX;540+        lastT = e.timeStamp;526541         dragging = true;527-        const v = view();528-        if (v === "entry") {529-          // Only count starts inside the bottom 40% band (top 60% reserved530-          // for entry interactions, taps, swipes between entries).531-          startedInBand = t.clientY > window.innerHeight * 0.6;532-        } else if (v === "list") {533-          // From list: any downward drag near the top of the list dismisses it.534-          startedInBand = t.clientY < window.innerHeight * 0.35;535-        } else {536-          startedInBand = false;537-        }542+        captured = false;538543       };539544 540545       const onTouchMove = (e: TouchEvent) => {541-        if (!dragging || !startedInBand) return;546+        if (!dragging) return;542547         const t = e.touches[0];543548         if (!t) return;544549         const dy = t.clientY - touchStartY;545550         const dx = Math.abs(t.clientX - touchStartX);546-        if (dx > HORIZONTAL_TOLERANCE) {547-          // Treat as a horizontal swipe — give it back to the entry view.548-          dragging = false;549-          return;551+552+        if (!captured) {553+          if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {554+            // Looks like a horizontal swipe — release.555+            dragging = false;556+            return;557+          }558+          if (Math.abs(dy) < ACTIVATION_DY) return;559+          captured = true;550560         }561+562+        // Translate the sheet by dy from its rest position. Clamp so the563+        // sheet can't be dragged above its open position or below its peek.551564         const v = view();552-        if (v === "entry" && dy < -SWIPE_THRESHOLD) {553-          dragging = false;554-          setView("list");555-        } else if (v === "list" && dy > SWIPE_THRESHOLD) {556-          dragging = false;557-          setView("entry");558-        }565+        const restClosed = window.innerHeight - 60; // peek height566+        const baseTranslate = v === "list" ? 0 : restClosed;567+        const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));568+        setSheetTranslate(next);569+        lastY = t.clientY;570+        lastT = e.timeStamp;559571       };560572 561-      const onTouchEnd = () => { dragging = false; startedInBand = false; };573+      const onTouchEnd = (e: TouchEvent) => {574+        if (!dragging) { return; }575+        const t = e.changedTouches[0];576+        const elapsed = Math.max(1, e.timeStamp - lastT);577+        const velocity = t ? (t.clientY - lastY) / elapsed : 0; // px/ms578+        const wasCaptured = captured;579+        dragging = false;580+        captured = false;562581 582+        if (!wasCaptured) {583+          // It was a tap, not a drag. Let the underlying click handler run.584+          setSheetTranslate(null);585+          return;586+        }587+588+        const restClosed = window.innerHeight - 60;589+        const current = sheetTranslate() ?? restClosed;590+        const v = view();591+592+        // Snap rules:593+        //   - Strong upward velocity → open594+        //   - Strong downward velocity → close595+        //   - Otherwise snap to whichever rest is closer596+        let nextOpen: boolean;597+        if (velocity < -0.4) nextOpen = true;598+        else if (velocity > 0.4) nextOpen = false;599+        else nextOpen = current < restClosed * 0.5;600+601+        if (nextOpen && v !== "list") setView("list");602+        if (!nextOpen && v !== "entry") setView("entry");603+        // Release the live offset so CSS transition handles the snap.604+        setSheetTranslate(null);605+      };606+563607       document.addEventListener("touchstart", onTouchStart, { passive: true });564608       document.addEventListener("touchmove",  onTouchMove,  { passive: true });565609       document.addEventListener("touchend",   onTouchEnd,   { passive: true });566-      document.addEventListener("touchcancel", onTouchEnd,  { passive: true });610+      document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });567611       onCleanup(() => {568612         document.removeEventListener("touchstart", onTouchStart);569613         document.removeEventListener("touchmove",  onTouchMove);570614         document.removeEventListener("touchend",   onTouchEnd);571-        document.removeEventListener("touchcancel", onTouchEnd);572615       });573616     }574617 @@ -1112,7 +1155,7 @@11121155               <div class="peach-chrome-actions">11131156                 <button11141157                   class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"1115-                  onClick={() => setView("settings")}1158+                  onClick={() => { console.log("[peach] settings tap"); setView("settings"); }}11161159                   aria-label="Settings"11171160                 >11181161                   <svg width="14" height="14" viewBox="0 0 14 14" fill="none">@@ -1266,7 +1309,36 @@12661309               </section>12671310             </div>12681311 1269-            <Show when={view() === "list"}>1312+            {/* Native: vault list lives in an always-mounted bottom sheet1313+                that peeks at the bottom (search-bar handle) and drags up to1314+                fill the screen. Desktop: shown on demand via Show. */}1315+            <Show when={isNativePlatform()}>1316+              <div1317+                class="peach-vault-sheet"1318+                classList={{ "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1319+                style={(() => {1320+                  const live = sheetTranslate();1321+                  if (live === null) return undefined;1322+                  return { transform: `translateY(${live}px)` };1323+                })()}1324+              >1325+                <VaultView1326+                  entries={entries()}1327+                  selectedEntryId={selectedEntry()?.id}1328+                  currentTabDomain={currentTabDomain()}1329+                  sortOrder={sortOrder()}1330+                  searchSeed={listSearchSeed()}1331+                  searchSeedNonce={listSearchSeedNonce()}1332+                  onSelectEntry={handleSelectEntry}1333+                  onSortOrderChange={setSortOrder}1334+                  onAdd={handleAdd}1335+                  onClose={() => setView("entry")}1336+                  onOpenTrash={() => setView("trash")}1337+                  onDeleteMany={handleDeleteMany}1338+                />1339+              </div>1340+            </Show>1341+            <Show when={!isNativePlatform() && view() === "list"}>12701342               <VaultView12711343                 entries={entries()}12721344                 selectedEntryId={selectedEntry()?.id}

view file