1import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";2import type { VaultEntry } from "../../shared/types";3import { sendMessage } from "../App";4import { parseOtpauthURI } from "../../crypto/totp";5import { GeneratorDrawer } from "../components/GeneratorDrawer";67interface AddProps {8 entry?: VaultEntry;9 entries?: VaultEntry[];10 autoOpenGenerator?: boolean;11 onBack: () => void;12 onSave: (entry?: VaultEntry) => void | Promise<void>;13}1415export function AddView(props: AddProps) {16 const [title, setTitle] = createSignal(props.entry?.title ?? "");17 const [username, setUsername] = createSignal(props.entry?.username ?? "");18 const [password, setPassword] = createSignal(props.entry?.password ?? "");19 const [url, setUrl] = createSignal(props.entry?.url ?? "");20 const [totp, setTotp] = createSignal(props.entry?.totp ?? "");21 const [notes, setNotes] = createSignal(props.entry?.notes ?? "");22 const [saving, setSaving] = createSignal(false);23 const [error, setError] = createSignal("");24 const [passwordStrength, setPasswordStrength] = createSignal<number | null>(null);25 const [zxcvbnFn, setZxcvbnFn] = createSignal<((pwd: string) => { score: number }) | null>(null);26 const [tags, setTags] = createSignal<string[]>(props.entry?.tags ?? []);27 const [tagInput, setTagInput] = createSignal("");28 const [showTagSuggestions, setShowTagSuggestions] = createSignal(false);29 const [showGenerator, setShowGenerator] = createSignal(props.autoOpenGenerator ?? false);30 const [showPassword, setShowPassword] = createSignal(false);31 const [showTotp, setShowTotp] = createSignal(!!(props.entry?.totp));32 const [showNotes, setShowNotes] = createSignal(!!(props.entry?.notes));33 const [email, setEmail] = createSignal((props.entry as VaultEntry & { email?: string })?.email ?? "");34 const [activeIdentityField, setActiveIdentityField] = createSignal<"username" | "email" | null>(null);35 const [currentTabUrl, setCurrentTabUrl] = createSignal("");3637 38 39 let usernameInputEl: HTMLInputElement | undefined;40 let emailInputEl: HTMLInputElement | undefined;41 let usernameSuggestEl: HTMLDivElement | undefined;42 let emailSuggestEl: HTMLDivElement | undefined;43 let suppressIdentityOpen = false;4445 function openIdentityField(field: "username" | "email") {46 if (suppressIdentityOpen) { suppressIdentityOpen = false; return; }47 setActiveIdentityField(field);48 }4950 const allIdentities = createMemo(() => {51 const emailMap = new Map<string, number>();52 const usernameMap = new Map<string, number>();53 for (const entry of props.entries ?? []) {54 const uname = entry.username?.trim() ?? "";55 const em = entry.email?.trim() ?? "";56 if (uname) {57 if (uname.includes("@")) emailMap.set(uname, (emailMap.get(uname) ?? 0) + 1);58 else usernameMap.set(uname, (usernameMap.get(uname) ?? 0) + 1);59 }60 if (em) emailMap.set(em, (emailMap.get(em) ?? 0) + 1);61 }62 const result: Array<{ value: string; count: number; type: "email" | "username" }> = [];63 const seen = new Set<string>();64 for (const v of new Set([...emailMap.keys(), ...usernameMap.keys()])) {65 if (seen.has(v)) continue;66 seen.add(v);67 const ec = emailMap.get(v) ?? 0;68 const uc = usernameMap.get(v) ?? 0;69 result.push({ value: v, count: Math.max(ec, uc), type: ec >= uc ? "email" : "username" });70 }71 return result.sort((a, b) => b.count - a.count);72 });7374 const identitySuggestions = createMemo(() => {75 const field = activeIdentityField();76 if (!field) return [];77 const input = (field === "username" ? username() : email()).toLowerCase();78 return allIdentities()79 .filter(s => s.type === field && (!input || s.value.toLowerCase().includes(input)))80 .slice(0, 8);81 });8283 const isCardEntry = () => props.entry?.type === "card";84 const [selectedType, setSelectedType] = createSignal<"login" | "card">(85 isCardEntry() ? "card" : "login"86 );8788 89 const [cardNickname, setCardNickname] = createSignal(props.entry?.cardNickname ?? "");90 const [cardholderName, setCardholderName] = createSignal(props.entry?.cardholderName ?? "");91 const [cardNumber, setCardNumber] = createSignal(props.entry?.cardNumber ?? "");92 const [expMonth, setExpMonth] = createSignal(props.entry?.expMonth ?? "");93 const [expYear, setExpYear] = createSignal(props.entry?.expYear ?? "");94 const [cvc, setCvc] = createSignal(props.entry?.cvc ?? "");95 const [showCvc, setShowCvc] = createSignal(false);96 const [cardNumberFocused, setCardNumberFocused] = createSignal(false);97 function getCardNetwork(num: string): string {98 const n = num.replace(/\s/g, '');99 if (/^4/.test(n)) return 'Visa';100 if (/^5[1-5]|^2[2-7]/.test(n)) return 'Mastercard';101 if (/^3[47]/.test(n)) return 'Amex';102 if (/^6(?:011|5)/.test(n)) return 'Discover';103 return '';104 }105106 function formatCardNumberDisplay(raw: string, focused: boolean): string {107 const n = raw.replace(/\s/g, '');108 if (!focused && n.length >= 4) return '•••• •••• •••• ' + n.slice(-4);109 if (/^3[47]/.test(n)) return n.replace(/(\d{4})(\d{1,6})?(\d{1,5})?/, (_, a, b, c) => [a, b, c].filter(Boolean).join(' '));110 return n.replace(/(\d{4})(?=\d)/g, '$1 ').trim();111 }112113 const canSave = () => {114 if (!title().trim()) return false;115 if (selectedType() === "card" && (!cardholderName().trim() || !cardNumber().trim())) return false;116 return true;117 };118119 const allTags = createMemo(() => {120 const tagSet = new Set<string>();121 for (const entry of props.entries ?? []) {122 for (const tag of entry.tags ?? []) tagSet.add(tag);123 }124 return Array.from(tagSet).sort();125 });126127 const tagSuggestions = createMemo(() => {128 const input = tagInput().toLowerCase().trim();129 if (!input) return [];130 return allTags().filter(t =>131 t.toLowerCase().includes(input) && !tags().includes(t)132 ).slice(0, 5);133 });134135 function addTag(tag: string) {136 const n = tag.toLowerCase().trim();137 if (!n || n.length > 32 || tags().includes(n) || tags().length >= 10) return;138 setTags([...tags(), n]);139 }140141 function removeTag(tag: string) {142 setTags(tags().filter(t => t !== tag));143 }144145 function handleTagKeyDown(e: KeyboardEvent) {146 if (e.key === "Enter" || e.key === ",") {147 e.preventDefault();148 if (tagInput()) { addTag(tagInput()); setTagInput(""); setShowTagSuggestions(false); }149 } else if (e.key === "Backspace" && !tagInput() && tags().length > 0) {150 removeTag(tags()[tags().length - 1]);151 }152 }153154 155 createEffect(async () => {156 const pwd = password();157 if (pwd.length < 4) { setPasswordStrength(null); return; }158 159 160 161 162 if (pwd.length >= 50) { setPasswordStrength(4); return; }163 try {164 if (!zxcvbnFn()) {165 166 const [{ zxcvbn, zxcvbnOptions }, common, en] = await Promise.all([167 import("@zxcvbn-ts/core"),168 import("@zxcvbn-ts/language-common"),169 import("@zxcvbn-ts/language-en"),170 ]);171 zxcvbnOptions.setOptions({172 translations: en.translations,173 graphs: common.adjacencyGraphs,174 dictionary: { ...common.dictionary, ...en.dictionary },175 });176 setZxcvbnFn(() => (pwd: string) => zxcvbn(pwd));177 }178 const fn = zxcvbnFn();179 if (fn) setPasswordStrength(fn(pwd).score);180 } catch {181 setPasswordStrength(pwd.length >= 16 ? 3 : pwd.length >= 12 ? 2 : 1);182 }183 });184185 onMount(() => {186 const el = document.querySelector(".peach-new-title-input") as HTMLInputElement | null;187 if (el) el.focus();188 chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {189 const tabUrl = tabs[0]?.url;190 if (tabUrl && tabUrl.startsWith("http")) setCurrentTabUrl(tabUrl);191 });192 193 194 const keepAlive = setInterval(() => {195 chrome.runtime.sendMessage({ type: "TOUCH_AUTO_LOCK" }).catch(() => {});196 }, 30_000);197 onCleanup(() => clearInterval(keepAlive));198 });199200 function getNormalizedTotp(): string | undefined {201 const value = totp().trim();202 if (!value) return undefined;203 if (value.startsWith("otpauth://")) {204 const parsed = parseOtpauthURI(value);205 return parsed?.secret ?? value;206 }207 return value;208 }209210 async function handleSave() {211 if (!canSave()) {212 setError(213 selectedType() === "card"214 ? "cardholder name and card number are required"215 : "title is required"216 );217 return;218 }219220 setSaving(true);221 setError("");222223 const payload: Record<string, unknown> = {224 type: selectedType(),225 title: title().trim(),226 notes: notes().trim() || undefined,227 tags: tags().length > 0 ? tags() : undefined,228 };229230 if (selectedType() === "login") {231 payload.username = username().trim();232 payload.email = email().trim() || undefined;233 payload.password = password();234 payload.url = url().trim();235 payload.totp = getNormalizedTotp();236 }237238 if (selectedType() === "card") {239 const raw = cardNumber().replace(/\s/g, '');240 payload.cardholderName = cardholderName().trim();241 payload.cardNumber = raw;242 payload.expMonth = expMonth().trim();243 payload.expYear = expYear().trim();244 payload.cvc = cvc().trim();245 payload.cardNickname = cardNickname().trim() || undefined;246 const n = raw;247 payload.network = /^4/.test(n) ? 'visa' : /^5[1-5]|^2[2-7]/.test(n) ? 'mastercard' : /^3[47]/.test(n) ? 'amex' : /^6(?:011|5)/.test(n) ? 'discover' : 'unknown';248 payload.last4 = raw.slice(-4);249 }250251 if (props.entry) {252 const res = await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: payload } });253 const result = res as { ok: boolean; error?: string };254 if (!result.ok) { setError(result.error ?? "could not update entry"); setSaving(false); return; }255 await props.onSave({ ...props.entry, ...payload } as VaultEntry);256 return;257 }258259 const res = await sendMessage({ type: "ADD_ENTRY", payload });260 const result = res as { ok: boolean; data?: VaultEntry; error?: string };261 if (!result.ok) {262 263 if (result.error === "vault_locked") {264 setError("vault is locked — tap the lock icon to re-enter your master password");265 } else {266 267 const SAFE_ERRORS = new Set([268 "title is required",269 "cardholder name and card number are required",270 "invalid passkey credential",271 "pro_required",272 ]);273 setError(274 result.error !== undefined && SAFE_ERRORS.has(result.error)275 ? result.error276 : "could not save entry",277 );278 }279 setSaving(false);280 return;281 }282 await props.onSave(result.data);283 }284285 const strengthLabel = () => {286 const s = passwordStrength();287 if (s === null) return null;288 return ["weak", "weak", "fair", "good", "strong"][s] as string;289 };290291 return (292 <div class="peach-overlay-view ov-enter-right" style="display:flex">293 <div class="peach-overlay-header">294 <button295 class="peach-btn peach-btn--ghost peach-btn--sm peach-overlay-action"296 onClick={props.onBack}297 >298 cancel299 </button>300 <span class="peach-overlay-title">301 {props.entry ? "edit entry" : "new entry"}302 </span>303 <button304 class="peach-btn peach-btn--accent peach-btn--sm peach-overlay-action"305 onClick={() => void handleSave()}306 disabled={saving() || !canSave()}307 >308 {saving() ? "saving…" : props.entry ? "save" : "create"}309 </button>310 </div>311312 {}313 <div class="peach-entry-body peach-entry-body--new">314315 {}316 <Show when={!props.entry}>317 <div class="peach-new-type-row">318 <button319 class={`peach-new-type-btn ${selectedType() === "login" ? "is-active" : ""}`}320 onClick={() => setSelectedType("login")}321 >login</button>322 <button323 class={`peach-new-type-btn ${selectedType() === "card" ? "is-active" : ""}`}324 onClick={() => setSelectedType("card")}325 >card</button>326 </div>327 </Show>328329 {}330 <div class="peach-new-title-wrap">331 <input332 class="peach-new-title-input"333 placeholder="entry name"334 autocomplete="off"335 value={title()}336 onInput={(e) => setTitle((e.currentTarget as HTMLInputElement).value)}337 onKeyDown={(e) => { if (e.key === "Enter") void handleSave(); }}338 />339 </div>340341 {}342 <Show when={selectedType() === "login"}>343 <div class="peach-domain">344 <div class="peach-domain-edit-row">345 <input346 class="peach-domain-value peach-domain-value--edit"347 placeholder="domain.com"348 autocomplete="off"349 value={url()}350 onInput={(e) => setUrl((e.currentTarget as HTMLInputElement).value)}351 />352 <Show when={currentTabUrl()}>353 <button354 class="peach-domain-url-btn"355 type="button"356 onClick={() => setUrl(currentTabUrl())}357 title="Insert current page URL"358 >359 <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">360 <path d="M4.5 7.5a3 3 0 0 0 4.243 0L10.5 5.75a3 3 0 0 0-4.243-4.243L5.5 2.26" />361 <path d="M7.5 4.5a3 3 0 0 0-4.243 0L1.5 6.25a3 3 0 0 0 4.243 4.243L6.5 9.74" />362 </svg>363 </button>364 </Show>365 </div>366 </div>367368 <section class="peach-fields">369 {}370 <div class="peach-field-card">371 <span class="peach-label">username</span>372 <div class="peach-field-value-row">373 <input374 ref={(el) => (usernameInputEl = el)}375 class="peach-field-value peach-field-value--edit"376 placeholder="johndoe"377 autocomplete="off"378 value={username()}379 onInput={(e) => setUsername((e.currentTarget as HTMLInputElement).value)}380 onFocus={() => openIdentityField("username")}381 onBlur={(e) => {382 if (usernameSuggestEl?.contains(e.relatedTarget as Node)) return;383 setTimeout(() => { if (activeIdentityField() === "username") setActiveIdentityField(null); }, 150);384 }}385 onKeyDown={(e) => {386 if (activeIdentityField() !== "username") return;387 if (e.key === "ArrowDown") {388 e.preventDefault();389 usernameSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();390 } else if (e.key === "Escape") {391 e.preventDefault();392 setActiveIdentityField(null);393 }394 }}395 />396 </div>397 <Show when={activeIdentityField() === "username" && identitySuggestions().length > 0}>398 <IdentitySuggestList399 listRef={(el) => (usernameSuggestEl = el)}400 inputRef={() => usernameInputEl}401 suggestions={identitySuggestions()}402 onPick={(v) => { setUsername(v); setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}403 onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; usernameInputEl?.focus(); }}404 />405 </Show>406 </div>407408 {}409 <div class="peach-field-card">410 <span class="peach-label">email</span>411 <div class="peach-field-value-row">412 <input413 ref={(el) => (emailInputEl = el)}414 class="peach-field-value peach-field-value--edit"415 type="email"416 placeholder="[email protected]"417 autocomplete="off"418 value={email()}419 onInput={(e) => setEmail((e.currentTarget as HTMLInputElement).value)}420 onFocus={() => openIdentityField("email")}421 onBlur={(e) => {422 if (emailSuggestEl?.contains(e.relatedTarget as Node)) return;423 setTimeout(() => { if (activeIdentityField() === "email") setActiveIdentityField(null); }, 150);424 }}425 onKeyDown={(e) => {426 if (activeIdentityField() !== "email") return;427 if (e.key === "ArrowDown") {428 e.preventDefault();429 emailSuggestEl?.querySelector<HTMLElement>(".peach-identity-suggestion")?.focus();430 } else if (e.key === "Escape") {431 e.preventDefault();432 setActiveIdentityField(null);433 }434 }}435 />436 </div>437 <Show when={activeIdentityField() === "email" && identitySuggestions().length > 0}>438 <IdentitySuggestList439 listRef={(el) => (emailSuggestEl = el)}440 inputRef={() => emailInputEl}441 suggestions={identitySuggestions()}442 onPick={(v) => { setEmail(v); setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}443 onClose={() => { setActiveIdentityField(null); suppressIdentityOpen = true; emailInputEl?.focus(); }}444 />445 </Show>446 </div>447448 {}449 <div class="peach-field-card">450 <span class="peach-label">password</span>451 <Show when={!showGenerator()}>452 <div class="peach-password-edit-row">453 <input454 class="peach-field-value peach-field-value--edit"455 type={showPassword() ? "text" : "password"}456 placeholder="password"457 autocomplete="new-password"458 value={password()}459 onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}460 />461 <button462 class="peach-inline-action peach-password-toggle-btn"463 type="button"464 onClick={() => setShowPassword(!showPassword())}465 aria-label={showPassword() ? "Hide password" : "Show password"}466 >467 <Show468 when={showPassword()}469 fallback={470 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">471 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />472 <circle cx="7" cy="5" r="1.75" />473 </svg>474 }475 >476 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">477 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />478 <path d="M3.5 5.5L2.5 7.5" />479 <path d="M7 6.5V8.5" />480 <path d="M10.5 5.5L11.5 7.5" />481 </svg>482 </Show>483 </button>484 </div>485 </Show>486487 {}488 <Show when={strengthLabel() !== null}>489 <div class="peach-strength-hairline">490 <div class="peach-strength-hairline-track">491 <div492 class="peach-strength-hairline-fill"493 style={{494 width: ["25%","40%","60%","80%","100%"][passwordStrength()!],495 background: ["rgba(220,80,60,0.65)","rgba(220,80,60,0.65)","rgba(200,140,60,0.6)","rgba(180,162,100,0.55)","rgba(150,175,110,0.6)"][passwordStrength()!],496 }}497 />498 </div>499 <span class="peach-strength-label">{strengthLabel()}</span>500 </div>501 </Show>502503 {}504 <button505 class="peach-generate-toggle-link"506 type="button"507 onClick={() => { setShowGenerator(!showGenerator()); }}508 >509 {showGenerator() ? "☰ hide generator" : "⚙ generate"}510 </button>511 <GeneratorDrawer512 compact513 open={showGenerator()}514 onChange={(pw) => setPassword(pw)}515 onUse={(pw) => { setPassword(pw); setShowGenerator(false); }}516 />517 </div>518519 {}520 <Show when={showTotp() || totp()}>521 <div class="peach-field-card">522 <span class="peach-label">2fa secret</span>523 <div class="peach-field-value-row">524 <input525 class="peach-field-value peach-field-value--edit"526 placeholder="BASE32 or otpauth:// URI"527 autocomplete="off"528 value={totp()}529 onInput={(e) => setTotp((e.currentTarget as HTMLInputElement).value)}530 />531 </div>532 </div>533 </Show>534 </section>535 </Show>536537 {}538 <Show when={selectedType() === "card"}>539 <section class="peach-fields">540 {}541 <div class="peach-field-card">542 <span class="peach-label">nickname</span>543 <div class="peach-field-value-row">544 <input class="peach-field-value peach-field-value--edit" placeholder="Personal Visa"545 autocomplete="off" value={cardNickname()}546 onInput={(e) => setCardNickname((e.currentTarget as HTMLInputElement).value)} />547 </div>548 </div>549 {}550 <div class="peach-field-card">551 <span class="peach-label">cardholder name</span>552 <div class="peach-field-value-row">553 <input class="peach-field-value peach-field-value--edit" placeholder="Jane Smith"554 autocomplete="cc-name" value={cardholderName()}555 onInput={(e) => setCardholderName((e.currentTarget as HTMLInputElement).value)} />556 </div>557 </div>558 {}559 <div class="peach-field-card">560 <span class="peach-label">561 card number562 <Show when={getCardNetwork(cardNumber())}>563 <span class="peach-card-network-badge">{getCardNetwork(cardNumber())}</span>564 </Show>565 </span>566 <div class="peach-field-value-row">567 <input class="peach-field-value peach-field-value--edit peach-card-number"568 placeholder="•••• •••• •••• ••••"569 autocomplete="cc-number" inputmode="numeric" maxLength={19}570 value={formatCardNumberDisplay(cardNumber(), cardNumberFocused())}571 onFocus={() => setCardNumberFocused(true)}572 onBlur={() => setCardNumberFocused(false)}573 onInput={(e) => {574 const raw = (e.currentTarget as HTMLInputElement).value.replace(/\s/g, '');575 setCardNumber(raw);576 }} />577 </div>578 </div>579 {}580 <div class="peach-field-card">581 <span class="peach-label">expiry</span>582 <div class="peach-card-expiry-row">583 <input class="peach-field-value peach-field-value--edit" placeholder="MM"584 autocomplete="cc-exp-month" inputmode="numeric" maxLength={2} style="width:56px"585 value={expMonth()}586 onInput={(e) => setExpMonth((e.currentTarget as HTMLInputElement).value)} />587 <span class="peach-card-expiry-sep">/</span>588 <input class="peach-field-value peach-field-value--edit" placeholder="YYYY"589 autocomplete="cc-exp-year" inputmode="numeric" maxLength={4} style="width:72px"590 value={expYear()}591 onInput={(e) => setExpYear((e.currentTarget as HTMLInputElement).value)} />592 </div>593 </div>594 {}595 <div class="peach-field-card">596 <span class="peach-label">CVC</span>597 <div class="peach-field-value-row">598 <input class="peach-field-value peach-field-value--edit"599 type={showCvc() ? "text" : "password"} placeholder="•••"600 autocomplete="cc-csc" inputmode="numeric" maxLength={4}601 value={cvc()}602 onInput={(e) => setCvc((e.currentTarget as HTMLInputElement).value)} />603 <button class="peach-inline-action peach-password-toggle-btn" type="button"604 onClick={() => setShowCvc(!showCvc())} aria-label={showCvc() ? "Hide CVC" : "Show CVC"}>605 <Show when={showCvc()} fallback={606 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">607 <path d="M1 5C1 5 2.8 1 7 1C11.2 1 13 5 13 5C13 5 11.2 9 7 9C2.8 9 1 5 1 5Z" />608 <circle cx="7" cy="5" r="1.75" />609 </svg>610 }>611 <svg width="14" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">612 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />613 </svg>614 </Show>615 </button>616 </div>617 </div>618 </section>619 </Show>620621 {}622 <Show when={selectedType() === "login" && (showNotes() || notes())}>623 <div class="peach-field-card" style="margin:0 12px">624 <span class="peach-label">notes</span>625 <textarea626 class="peach-new-textarea"627 placeholder="anything worth remembering"628 value={notes()}629 onInput={(e) => setNotes((e.currentTarget as HTMLTextAreaElement).value)}630 />631 </div>632 </Show>633634 {}635 <div class="peach-entry-add-links">636 <Show when={selectedType() === "login" && !showTotp() && !totp()}>637 <button class="peach-add-field-link" type="button" onClick={() => setShowTotp(true)}>638 + add 2FA639 </button>640 </Show>641 <Show when={selectedType() === "login" && !showNotes() && !notes()}>642 <button class="peach-add-field-link" type="button" onClick={() => setShowNotes(true)}>643 + add notes644 </button>645 </Show>646 </div>647648 {}649 <div class="peach-field-card" style="margin:4px 12px 12px">650 <span class="peach-label">tags</span>651 <div class="peach-tag-input-wrap">652 <For each={tags()}>653 {(tag) => (654 <span class="peach-tag-chip">655 {tag}656 <button class="peach-tag-chip-remove" onClick={() => removeTag(tag)} aria-label={`Remove ${tag}`}>×</button>657 </span>658 )}659 </For>660 <input661 class="peach-tag-input"662 placeholder={tags().length === 0 ? "add tags…" : ""}663 value={tagInput()}664 onInput={(e) => { setTagInput((e.currentTarget as HTMLInputElement).value); setShowTagSuggestions(true); }}665 onKeyDown={handleTagKeyDown}666 onFocus={() => setShowTagSuggestions(true)}667 onBlur={() => setTimeout(() => setShowTagSuggestions(false), 150)}668 style="flex:1;min-width:80px;background:transparent;border:none;outline:none;color:var(--t1);font-size:12px;padding:2px;"669 />670 </div>671 <Show when={showTagSuggestions() && tagSuggestions().length > 0}>672 <div class="peach-tag-suggestions" style="margin-top:4px;">673 <For each={tagSuggestions()}>674 {(s) => (675 <button676 class="peach-tag-suggestion"677 onMouseDown={() => { addTag(s); setTagInput(""); setShowTagSuggestions(false); }}678 >{s}</button>679 )}680 </For>681 </div>682 </Show>683 </div>684685 <Show when={error()}>686 <p class="peach-hint peach-hint-error" style="margin:0 16px 12px">{error()}</p>687 </Show>688689 </div>690 </div>691 );692}693694interface IdentitySuggestListProps {695 suggestions: Array<{ value: string; count: number; type: "email" | "username" }>;696 onPick: (value: string) => void;697 onClose?: () => void;698 listRef?: (el: HTMLDivElement) => void;699 inputRef?: () => HTMLInputElement | undefined;700}701702export function IdentitySuggestList(props: IdentitySuggestListProps) {703 const [collapsed, setCollapsed] = createSignal(false);704 const ChevronIcon = () => (705 <svg width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">706 <path d="M1 1L5 5L9 1" />707 </svg>708 );709710 function handleRowKeyDown(e: KeyboardEvent, value: string) {711 const row = e.currentTarget as HTMLElement;712 if (e.key === "ArrowDown") {713 e.preventDefault();714 (row.nextElementSibling as HTMLElement | null)?.focus();715 } else if (e.key === "ArrowUp") {716 e.preventDefault();717 const prev = row.previousElementSibling as HTMLElement | null;718 if (prev) prev.focus();719 else props.inputRef?.()?.focus();720 } else if (e.key === "Enter" || e.key === " ") {721 e.preventDefault();722 props.onPick(value);723 } else if (e.key === "Escape") {724 e.preventDefault();725 props.onClose?.();726 }727 }728729 return (730 <div class="peach-identity-suggestions" ref={props.listRef}>731 <div class="peach-identity-filter-row" style="justify-content:flex-end">732 <button733 class={`peach-identity-collapse-btn${collapsed() ? " is-collapsed" : ""}`}734 type="button"735 onMouseDown={(e) => { e.preventDefault(); setCollapsed(c => !c); }}736 aria-label={collapsed() ? "Show suggestions" : "Hide suggestions"}737 >738 <ChevronIcon />739 </button>740 </div>741 <div class={`peach-identity-rows${collapsed() ? " is-collapsed" : ""}`}>742 <div class="peach-identity-rows-inner">743 <For each={props.suggestions}>744 {(s) => (745 <button746 class="peach-identity-suggestion"747 type="button"748 onMouseDown={(e) => { e.preventDefault(); props.onPick(s.value); }}749 onKeyDown={(e) => handleRowKeyDown(e, s.value)}750 >751 <span class="peach-identity-suggestion-value">{s.value}</span>752 <span class="peach-identity-suggestion-meta">{s.count}×</span>753 </button>754 )}755 </For>756 </div>757 </div>758 </div>759 );760}