search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: handle typeahead at login (typeahead.waow.tech)

- debounced search against app.bsky.actor.searchActorsTypeahead
- arrow keys + enter + click + escape
- 16px font on the input (belt-and-suspenders — iOS won't auto-zoom)
- autocorrect=off, spellcheck=false so handles don't get mangled

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+118 -2
+118 -2
site/subscriptions.html
··· 92 92 width: 100%; 93 93 padding: 0.5rem; 94 94 font-family: monospace; 95 - font-size: 16px; 95 + font-size: 16px; /* 16px prevents iOS auto-zoom on focus */ 96 96 background: var(--bg-subtle); 97 97 border: 1px solid var(--border-focus); 98 98 color: var(--text); 99 99 } 100 100 input[type="text"]:focus { outline: 1px solid #1B7340; } 101 101 102 + /* typeahead dropdown */ 103 + .handle-wrap { position: relative; flex: 1; } 104 + .typeahead { 105 + position: absolute; 106 + top: calc(100% + 2px); 107 + left: 0; 108 + right: 0; 109 + background: var(--bg-subtle); 110 + border: 1px solid var(--border-focus); 111 + max-height: 240px; 112 + overflow-y: auto; 113 + z-index: 10; 114 + } 115 + .typeahead:empty, .typeahead.hidden { display: none; } 116 + .typeahead-row { 117 + display: flex; 118 + align-items: center; 119 + gap: 0.5rem; 120 + padding: 0.4rem 0.5rem; 121 + cursor: pointer; 122 + border-bottom: 1px solid var(--border); 123 + font-size: 12px; 124 + } 125 + .typeahead-row:last-child { border-bottom: none; } 126 + .typeahead-row:hover, .typeahead-row.active { background: var(--bg-hover); } 127 + .typeahead-avatar { 128 + width: 20px; height: 20px; 129 + border-radius: 50%; 130 + background: var(--border); 131 + flex-shrink: 0; 132 + object-fit: cover; 133 + } 134 + .typeahead-handle { color: var(--text-bright); } 135 + .typeahead-name { color: var(--text-dim); font-size: 11px; } 136 + 102 137 button { 103 138 padding: 0.5rem 1rem; 104 139 font-family: monospace; ··· 231 266 <section id="login" class="card hidden"> 232 267 <p class="hint">sign in with your atproto handle:</p> 233 268 <form id="login-form" style="display:flex;gap:0.5rem"> 234 - <input id="handle" type="text" placeholder="you.bsky.social" autocomplete="off" autocapitalize="off" required style="flex:1"> 269 + <div class="handle-wrap"> 270 + <input id="handle" type="text" placeholder="you.bsky.social" 271 + autocomplete="off" autocapitalize="off" autocorrect="off" 272 + spellcheck="false" required> 273 + <div id="typeahead" class="typeahead hidden" role="listbox"></div> 274 + </div> 235 275 <button type="submit">sign in</button> 236 276 </form> 237 277 <div id="login-status" class="status"></div> ··· 413 453 if (!h) return; 414 454 window.location.href = API_URL + '/oauth/login?handle=' + encodeURIComponent(h); 415 455 }); 456 + 457 + // handle typeahead — hits typeahead.waow.tech (drop-in for app.bsky.actor.searchActorsTypeahead) 458 + (function wireTypeahead() { 459 + const input = $('handle'); 460 + if (!input) return; // login form may not be present 461 + const box = $('typeahead'); 462 + let debounce = 0; 463 + let activeIdx = -1; 464 + let items = []; 465 + 466 + const renderRows = (actors) => { 467 + items = actors || []; 468 + activeIdx = -1; 469 + if (!items.length) { box.classList.add('hidden'); box.innerHTML = ''; return; } 470 + box.innerHTML = items.map((a, i) => ` 471 + <div class="typeahead-row" role="option" data-i="${i}"> 472 + ${a.avatar ? `<img class="typeahead-avatar" src="${escapeHtml(a.avatar)}" alt="">` : '<div class="typeahead-avatar"></div>'} 473 + <span class="typeahead-handle">@${escapeHtml(a.handle)}</span> 474 + ${a.displayName ? `<span class="typeahead-name">${escapeHtml(a.displayName)}</span>` : ''} 475 + </div> 476 + `).join(''); 477 + box.classList.remove('hidden'); 478 + box.querySelectorAll('.typeahead-row').forEach(row => { 479 + row.addEventListener('mousedown', (e) => { 480 + e.preventDefault(); // keep focus on input 481 + select(parseInt(row.dataset.i, 10)); 482 + }); 483 + }); 484 + }; 485 + 486 + const select = (i) => { 487 + if (i < 0 || i >= items.length) return; 488 + input.value = items[i].handle; 489 + box.classList.add('hidden'); 490 + items = []; 491 + // auto-submit — user picked, they're ready 492 + $('login-form').requestSubmit(); 493 + }; 494 + 495 + const setActive = (i) => { 496 + activeIdx = i; 497 + box.querySelectorAll('.typeahead-row').forEach((row, idx) => { 498 + row.classList.toggle('active', idx === i); 499 + }); 500 + }; 501 + 502 + input.addEventListener('input', () => { 503 + clearTimeout(debounce); 504 + const q = input.value.trim(); 505 + if (q.length < 2) { renderRows([]); return; } 506 + debounce = setTimeout(async () => { 507 + try { 508 + const r = await fetch(`https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(q)}&limit=6`); 509 + if (!r.ok) return; 510 + const j = await r.json(); 511 + renderRows(j.actors || []); 512 + } catch {} 513 + }, 150); 514 + }); 515 + 516 + input.addEventListener('keydown', (e) => { 517 + if (box.classList.contains('hidden') || !items.length) return; 518 + if (e.key === 'ArrowDown') { e.preventDefault(); setActive(Math.min(activeIdx + 1, items.length - 1)); } 519 + else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(Math.max(activeIdx - 1, 0)); } 520 + else if (e.key === 'Enter' && activeIdx >= 0) { e.preventDefault(); select(activeIdx); } 521 + else if (e.key === 'Escape') { box.classList.add('hidden'); } 522 + }); 523 + 524 + input.addEventListener('blur', () => { 525 + // slight delay so mousedown on a row can fire first 526 + setTimeout(() => box.classList.add('hidden'), 120); 527 + }); 528 + input.addEventListener('focus', () => { 529 + if (items.length) box.classList.remove('hidden'); 530 + }); 531 + })(); 416 532 417 533 render(); 418 534 </script>