fa5725c92ff1

gestures: long-press context menu (copy username/password/totp/open/trash), swipe-right-to-copy on compact, vibrate haptic, copy-flash + bottom-sheet menu styles

time
2026-05-01 04:13 UTC · 5 months ago
machine
koh2
parent
cf2c58c238b2
2 files src/popup/styles/popup.css+63 −0src/popup/views/Vault.tsx+166 −1
src/popup/styles/popup.css+63 −0
@@ -7143,3 +7143,66 @@71437143 body.peach-mobile.layout-expanded .peach-search-wrap {71447144   padding-left: 360px;71457145 }7146+7147+/* ── Long-press context menu + swipe-copy flash ─────────────────────── */7148+.peach-context-menu-backdrop {7149+  position: fixed;7150+  inset: 0;7151+  background: rgba(0, 0, 0, 0.45);7152+  z-index: 100;7153+  display: flex;7154+  align-items: flex-end;7155+  justify-content: center;7156+  animation: peach-ctx-fade 140ms ease both;7157+}7158+.peach-context-menu {7159+  width: min(360px, calc(100vw - 24px));7160+  margin-bottom: max(env(safe-area-inset-bottom), 12px);7161+  background: var(--peach-bg);7162+  border: 1px solid var(--peach-t3);7163+  border-radius: 14px;7164+  padding: 6px;7165+  display: flex;7166+  flex-direction: column;7167+  gap: 2px;7168+  box-shadow: 0 24px 48px rgba(0, 0, 0, 0.55);7169+  animation: peach-ctx-rise 200ms cubic-bezier(0.16, 1, 0.3, 1) both;7170+}7171+@keyframes peach-ctx-fade { from { opacity: 0; } to { opacity: 1; } }7172+@keyframes peach-ctx-rise {7173+  from { transform: translateY(16px); opacity: 0; }7174+  to   { transform: translateY(0);    opacity: 1; }7175+}7176+.peach-context-menu-item {7177+  background: transparent;7178+  color: var(--peach-t0);7179+  border: 0;7180+  border-radius: 10px;7181+  padding: 12px 14px;7182+  text-align: left;7183+  font: inherit;7184+  cursor: pointer;7185+}7186+.peach-context-menu-item:hover,7187+.peach-context-menu-item:focus-visible {7188+  background: var(--peach-t3);7189+}7190+.peach-context-menu-item--danger {7191+  color: var(--peach-health-bad, #d96868);7192+}7193+.peach-context-menu-item--cancel {7194+  color: var(--peach-t2);7195+  margin-top: 4px;7196+  border-top: 1px solid var(--peach-t3);7197+  border-radius: 0 0 10px 10px;7198+}7199+7200+/* Swipe-to-copy amber flash */7201+.peach-entry-row.is-copy-flash {7202+  animation: peach-copy-flash 600ms ease both;7203+}7204+@keyframes peach-copy-flash {7205+  0%   { background: transparent; }7206+  20%  { background: rgba(217, 153, 51, 0.32); }7207+  100% { background: transparent; }7208+}

view file

src/popup/views/Vault.tsx+166 −1
@@ -2,7 +2,12 @@22 import type { VaultEntry } from "../../shared/types";33 import { extractDomain, getDisplayDomain } from "../../shared/domain";44 import { faviconUrl } from "../../shared/favicon";5+import { popupCopy } from "../copy";56 7+function vibrate(ms: number) {8+  try { navigator.vibrate?.(ms); } catch { /* unsupported */ }9+}10+611 function FaviconIcon(props: { domain: string; title?: string; isNote: boolean; isPasskey: boolean }) {712   const src = props.domain && !props.isNote && !props.isPasskey813     ? faviconUrl(props.domain)@@ -119,7 +124,71 @@119124   const [selecting, setSelecting] = createSignal(false);120125   const [selectedIds, setSelectedIds] = createSignal<Set<string>>(new Set());121126   const [deleting, setDeleting] = createSignal(false);127+  const [contextEntry, setContextEntry] = createSignal<VaultEntry | null>(null);128+  const [flashId, setFlashId] = createSignal<string | null>(null);129+  let longPressTimer: number | null = null;130+  let touchStart: { x: number; y: number; t: number } | null = null;131+  let suppressNextClick = false;122132 133+  function clearLongPressTimer() {134+    if (longPressTimer !== null) {135+      window.clearTimeout(longPressTimer);136+      longPressTimer = null;137+    }138+  }139+140+  function handleRowTouchStart(entry: VaultEntry, e: TouchEvent) {141+    if (selecting()) return;142+    const t = e.touches[0];143+    if (!t) return;144+    touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };145+    clearLongPressTimer();146+    longPressTimer = window.setTimeout(() => {147+      suppressNextClick = true;148+      vibrate(20);149+      setContextEntry(entry);150+      longPressTimer = null;151+    }, 500);152+  }153+154+  function handleRowTouchMove(e: TouchEvent) {155+    if (!touchStart) return;156+    const t = e.touches[0];157+    if (!t) return;158+    const dx = Math.abs(t.clientX - touchStart.x);159+    const dy = Math.abs(t.clientY - touchStart.y);160+    if (dx > 8 || dy > 8) clearLongPressTimer();161+  }162+163+  async function handleRowTouchEnd(entry: VaultEntry, e: TouchEvent) {164+    clearLongPressTimer();165+    if (!touchStart) return;166+    const t = e.changedTouches[0];167+    if (!t) { touchStart = null; return; }168+    const dx = t.clientX - touchStart.x;169+    const dy = Math.abs(t.clientY - touchStart.y);170+    const dt = Date.now() - touchStart.t;171+    touchStart = null;172+    // Right-swipe-to-copy: only on compact + login entries with a password173+    const isCompact = !document.body.classList.contains("layout-medium")174+      && !document.body.classList.contains("layout-expanded");175+    if (176+      isCompact177+      && !selecting()178+      && dx > 80179+      && dy < 30180+      && dt < 400181+      && entry.type === "login"182+      && entry.password183+    ) {184+      suppressNextClick = true;185+      vibrate(15);186+      setFlashId(entry.id);187+      window.setTimeout(() => setFlashId((id) => id === entry.id ? null : id), 600);188+      await popupCopy(entry.password, "password");189+    }190+  }191+123192   let searchRef: HTMLInputElement | undefined;124193   let listRef: HTMLDivElement | undefined;125194 @@ -487,9 +556,15 @@487556 488557               return (489558                 <button490-                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}`}559+                  class={`peach-entry-row${!selecting() && props.selectedEntryId === entry.id ? " is-active" : ""}${!selecting() && isTabMatch() ? " is-tab-match" : ""}${selecting() && isSelected() ? " is-selected" : ""}${flashId() === entry.id ? " is-copy-flash" : ""}`}491560                   style={{ "animation-delay": `${Math.min(index(), 24) * 20}ms` }}561+                  onTouchStart={(e) => handleRowTouchStart(entry, e)}562+                  onTouchMove={handleRowTouchMove}563+                  onTouchEnd={(e) => void handleRowTouchEnd(entry, e)}564+                  onTouchCancel={() => { clearLongPressTimer(); touchStart = null; }}565+                  onContextMenu={(e) => { e.preventDefault(); if (!selecting()) setContextEntry(entry); }}492566                   onClick={() => {567+                    if (suppressNextClick) { suppressNextClick = false; return; }493568                     if (selecting()) {494569                       toggleId(entry.id);495570                     } else {@@ -586,6 +661,96 @@586661             </span>587662           </Show>588663         </button>664+      </Show>665+666+      <Show when={contextEntry() !== null}>667+        <div668+          class="peach-context-menu-backdrop"669+          onClick={() => setContextEntry(null)}670+          role="presentation"671+        >672+          <div673+            class="peach-context-menu"674+            role="menu"675+            aria-label="Entry actions"676+            onClick={(e) => e.stopPropagation()}677+          >678+            <Show when={contextEntry()!.username}>679+              <button680+                role="menuitem"681+                class="peach-context-menu-item"682+                onClick={async () => {683+                  const e = contextEntry();684+                  if (!e?.username) return;685+                  await popupCopy(e.username, "username");686+                  setContextEntry(null);687+                }}688+              >689+                Copy username690+              </button>691+            </Show>692+            <Show when={contextEntry()!.password}>693+              <button694+                role="menuitem"695+                class="peach-context-menu-item"696+                onClick={async () => {697+                  const e = contextEntry();698+                  if (!e?.password) return;699+                  await popupCopy(e.password, "password");700+                  setContextEntry(null);701+                }}702+              >703+                Copy password704+              </button>705+            </Show>706+            <Show when={contextEntry()!.totp}>707+              <button708+                role="menuitem"709+                class="peach-context-menu-item"710+                onClick={async () => {711+                  const e = contextEntry();712+                  if (!e?.totp) return;713+                  await popupCopy(e.totp, "TOTP secret");714+                  setContextEntry(null);715+                }}716+              >717+                Copy TOTP secret718+              </button>719+            </Show>720+            <button721+              role="menuitem"722+              class="peach-context-menu-item"723+              onClick={() => {724+                const e = contextEntry();725+                if (e) props.onSelectEntry(e);726+                setContextEntry(null);727+              }}728+            >729+              Open730+            </button>731+            <Show when={props.onDeleteMany}>732+              <button733+                role="menuitem"734+                class="peach-context-menu-item peach-context-menu-item--danger"735+                onClick={async () => {736+                  const e = contextEntry();737+                  if (!e) return;738+                  setContextEntry(null);739+                  await props.onDeleteMany?.([e.id]);740+                }}741+              >742+                Move to trash743+              </button>744+            </Show>745+            <button746+              role="menuitem"747+              class="peach-context-menu-item peach-context-menu-item--cancel"748+              onClick={() => setContextEntry(null)}749+            >750+              Cancel751+            </button>752+          </div>753+        </div>589754       </Show>590755     </div>591756   );

view file