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
↓ peach-browser-264e3339.tar.gzatlas at this save
files changed1 file
src/popup/App.tsx+108 −36src/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}