e7f0d0d1b7eb

keyboard navigation for identity suggest list — arrow keys, enter/space, escape (v2.38.15)

time
2026-05-12 16:57 UTC · 5 months ago
machine
koh2
parent
db05c533ad81
5 files manifest.firefox.json+1 −1manifest.json+1 −1src/content/inject.ts+54 −7src/popup/styles/popup.css+3 −1src/popup/views/Add.tsx+83 −8
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.38.14",4+  "version": "2.38.15",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.38.14",4+  "version": "2.38.15",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

src/content/inject.ts+54 −7
@@ -1129,7 +1129,8 @@11291129     background: transparent; cursor: pointer; text-align: left;11301130     transition: background 80ms;11311131   }1132-  .identity-suggest-row:hover { background: color-mix(in srgb, var(--peach-accent) 10%, transparent); }1132+  .identity-suggest-row:hover,1133+  .identity-suggest-row:focus { background: color-mix(in srgb, var(--peach-accent) 10%, transparent); outline: none; }11331134   .identity-suggest-val {11341135     font-family: "IBM Plex Mono", monospace; font-size: 10px; color: var(--peach-text);11351136     overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;@@ -1614,11 +1615,19 @@16141615     if (!identities.length) return;16151616     let activeFilter: "all" | "username" | "email" = "all";16161617     let collapsed = false;1618+    let suppressNextFocus = false;16171619 16181620     const list = g.appendChild(document.createElement("div"));16191621     list.className = "identity-suggest-list";16201622     list.style.display = "none";16211623 1624+    function pick(value: string) {1625+      inp.value = value;1626+      list.style.display = "none";1627+      suppressNextFocus = true;1628+      inp.focus();1629+    }1630+16221631     // Filter pill row + collapse toggle16231632     const filterRow = list.appendChild(document.createElement("div"));16241633     filterRow.className = "identity-filter-row";@@ -1674,17 +1683,55 @@16741683         badge.textContent = s.type === "email" ? "@" : "u";16751684         const meta = row.appendChild(document.createElement("span"));16761685         meta.className = "identity-suggest-meta"; meta.textContent = `${s.useCount}×`;1677-        row.addEventListener("mousedown", (e) => {1678-          e.preventDefault();1679-          inp.value = s.value;1680-          list.style.display = "none";1686+        row.addEventListener("mousedown", (e) => { e.preventDefault(); pick(s.value); });1687+        row.addEventListener("keydown", (e) => {1688+          if (e.key === "ArrowDown") {1689+            e.preventDefault();1690+            (row.nextElementSibling as HTMLElement | null)?.focus();1691+          } else if (e.key === "ArrowUp") {1692+            e.preventDefault();1693+            const prev = row.previousElementSibling as HTMLElement | null;1694+            if (prev) prev.focus();1695+            else inp.focus(); // back to input, list stays visible via focus handler1696+          } else if (e.key === "Enter" || e.key === " ") {1697+            e.preventDefault();1698+            pick(s.value);1699+          } else if (e.key === "Escape") {1700+            e.preventDefault();1701+            list.style.display = "none";1702+            suppressNextFocus = true;1703+            inp.focus();1704+          }16811705         });1706+        row.addEventListener("blur", (e) => {1707+          const rt = (e as FocusEvent).relatedTarget as Node | null;1708+          if (rt && (rt === inp || list.contains(rt))) return;1709+          setTimeout(() => { list.style.display = "none"; }, 150);1710+        });16821711       }16831712       list.style.display = "";16841713     }1685-    inp.addEventListener("focus", render);1714+1715+    inp.addEventListener("focus", () => {1716+      if (suppressNextFocus) { suppressNextFocus = false; return; }1717+      render();1718+    });16861719     inp.addEventListener("input", render);1687-    inp.addEventListener("blur", () => setTimeout(() => { list.style.display = "none"; }, 150));1720+    inp.addEventListener("keydown", (e) => {1721+      if (list.style.display === "none") return;1722+      if (e.key === "ArrowDown" && !collapsed) {1723+        e.preventDefault();1724+        (rows.firstElementChild as HTMLElement | null)?.focus();1725+      } else if (e.key === "Escape") {1726+        e.preventDefault();1727+        list.style.display = "none";1728+      }1729+    });1730+    inp.addEventListener("blur", (e) => {1731+      const rt = (e as FocusEvent).relatedTarget as Node | null;1732+      if (rt && list.contains(rt)) return; // focus moved into suggestion list, keep open1733+      setTimeout(() => { list.style.display = "none"; }, 150);1734+    });16881735   }16891736 16901737   async function showCreateForm() {

view file

src/popup/styles/popup.css+3 −1
@@ -5262,8 +5262,10 @@52625262   transition: background 80ms ease;52635263 }52645264 5265-.peach-identity-suggestion:hover {5265+.peach-identity-suggestion:hover,5266+.peach-identity-suggestion:focus {52665267   background: color-mix(in srgb, var(--peach-accent) 8%, transparent);5268+  outline: none;52675269 }52685270 52695271 .peach-identity-suggestion-value {

view file

src/popup/views/Add.tsx+83 −8
@@ -36,6 +36,19 @@3636   const [currentTabUrl, setCurrentTabUrl] = createSignal("");3737   const [identityFilter, setIdentityFilter] = createSignal<"all" | "username" | "email">("all");3838 39+  // Mutable refs for keyboard navigation between inputs and suggestion lists.40+  // Not signals — focus state doesn't need to be reactive.41+  let usernameInputEl: HTMLInputElement | undefined;42+  let emailInputEl: HTMLInputElement | undefined;43+  let usernameSuggestEl: HTMLDivElement | undefined;44+  let emailSuggestEl: HTMLDivElement | undefined;45+  let suppressIdentityOpen = false;46+47+  function openIdentityField(field: "username" | "email") {48+    if (suppressIdentityOpen) { suppressIdentityOpen = false; return; }49+    setActiveIdentityField(field);50+  }51+3952   const allIdentities = createMemo(() => {4053     const emailMap = new Map<string, number>();4154     const usernameMap = new Map<string, number>();@@ -351,21 +364,38 @@351364               <span class="peach-label">username</span>352365               <div class="peach-field-value-row">353366                 <input367+                  ref={(el) => (usernameInputEl = el)}354368                   class="peach-field-value peach-field-value--edit"355369                   placeholder="johndoe"356370                   autocomplete="off"357371                   value={username()}358372                   onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}359-                  onFocus={() => setActiveIdentityField("username")}360-                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}373+                  onFocus={() => openIdentityField("username")}374+                  onBlur={(e) => {375+                    if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;376+                    setTimeout(() => setActiveIdentityField(null), 150);377+                  }}378+                  onKeyDown={(e) => {379+                    if (activeIdentityField() !== "username") return;380+                    if (e.key === "ArrowDown") {381+                      e.preventDefault();382+                      usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();383+                    } else if (e.key === "Escape") {384+                      e.preventDefault();385+                      setActiveIdentityField(null);386+                    }387+                  }}361388                 />362389               </div>363390               <Show when={activeIdentityField() === "username" && allIdentities().length > 0}>364391                 <IdentitySuggestList392+                  listRef={(el) => (usernameSuggestEl = el)}393+                  inputRef={() => usernameInputEl}365394                   suggestions={identitySuggestions()}366395                   filter={identityFilter()}367396                   onFilter={setIdentityFilter}368-                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); }}397+                  onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}398+                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}369399                 />370400               </Show>371401             </div>@@ -375,22 +405,39 @@375405               <span class="peach-label">email</span>376406               <div class="peach-field-value-row">377407                 <input408+                  ref={(el) => (emailInputEl = el)}378409                   class="peach-field-value peach-field-value--edit"379410                   type="email"380411                   placeholder="[email protected]"381412                   autocomplete="off"382413                   value={email()}383414                   onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}384-                  onFocus={() => setActiveIdentityField("email")}385-                  onBlur={() => setTimeout(() => setActiveIdentityField(null), 150)}415+                  onFocus={() => openIdentityField("email")}416+                  onBlur={(e) => {417+                    if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;418+                    setTimeout(() => setActiveIdentityField(null), 150);419+                  }}420+                  onKeyDown={(e) => {421+                    if (activeIdentityField() !== "email") return;422+                    if (e.key === "ArrowDown") {423+                      e.preventDefault();424+                      emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();425+                    } else if (e.key === "Escape") {426+                      e.preventDefault();427+                      setActiveIdentityField(null);428+                    }429+                  }}386430                 />387431               </div>388432               <Show when={activeIdentityField() === "email" && allIdentities().length > 0}>389433                 <IdentitySuggestList434+                  listRef={(el) => (emailSuggestEl = el)}435+                  inputRef={() => emailInputEl}390436                   suggestions={identitySuggestions()}391437                   filter={identityFilter()}392438                   onFilter={setIdentityFilter}393-                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); }}439+                  onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}440+                  onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}394441                 />395442               </Show>396443             </div>@@ -654,6 +701,9 @@654701   filter: "all" | "username" | "email";655702   onFilter: (f: "all" | "username" | "email") => void;656703   onPick: (value: string) => void;704+  onClose?: () => void;705+  listRef?: (el: HTMLDivElement) => void;706+  inputRef?: () => HTMLInputElement | undefined;657707 }658708 659709 function IdentitySuggestList(props: IdentitySuggestListProps) {@@ -663,8 +713,28 @@663713       <path d="M1 1L5 5L9 1" />664714     </svg>665715   );716+717+  function handleRowKeyDown(e: KeyboardEvent, value: string) {718+    const row = e.currentTarget as HTMLElement;719+    if (e.key === "ArrowDown") {720+      e.preventDefault();721+      (row.nextElementSibling as HTMLElement | null)?.focus();722+    } else if (e.key === "ArrowUp") {723+      e.preventDefault();724+      const prev = row.previousElementSibling as HTMLElement | null;725+      if (prev) prev.focus();726+      else props.inputRef?.()?.focus(); // back to input, list stays open727+    } else if (e.key === "Enter" || e.key === " ") {728+      e.preventDefault();729+      props.onPick(value);730+    } else if (e.key === "Escape") {731+      e.preventDefault();732+      props.onClose?.();733+    }734+  }735+666736   return (667-    <div class="peach-identity-suggestions">737+    <div class="peach-identity-suggestions" ref={props.listRef}>668738       <div class="peach-identity-filter-row">669739         <button class={`peach-identity-filter-pill ${props.filter === "all" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("all"); }}>all</button>670740         <button class={`peach-identity-filter-pill ${props.filter === "username" ? "is-on" : ""}`} type="button" onMouseDown={(e) => { e.preventDefault(); props.onFilter("username"); }}>user</button>@@ -682,7 +752,12 @@682752         <div class="peach-identity-rows-inner">683753           <For each={props.suggestions}>684754             {(s) => (685-              <button class="peach-identity-suggestion" type="button" onMouseDown={() => props.onPick(s.value)}>755+              <button756+                class="peach-identity-suggestion"757+                type="button"758+                onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}759+                onKeyDown={(e) => handleRowKeyDown(e, s.value)}760+              >686761                 <span class="peach-identity-suggestion-value">{s.value}</span>687762                 <span class={`peach-identity-suggestion-type peach-identity-suggestion-type--${s.type}`}>{s.type === "email" ? "@" : "u"}</span>688763                 <span class="peach-identity-suggestion-meta">{s.count}×</span>

view file