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
↓ peach-browser-e7f0d0d1.tar.gzatlas at this save
files changed5 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 −8manifest.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": {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",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() {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 {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>