Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218<script lang="ts"> // The identity field a person signs in with, once — the account popover and the space picker both // draw this rather than each keeping their own copy of it. // // It used to be an `<input list>` and a `<datalist>` on each surface, and a datalist is the // BROWSER's dropdown rather than the app's: the popup opens in response to a keystroke and offers // the options that existed at the moment of that keystroke. Remembered identities are in the list // before anybody types, so on a profile that has signed in here before it behaved like a typeahead. // On a fully logged out profile there is nothing remembered, the directory's handles are the only // suggestions there are, and they land a debounce and a round trip after the keystroke that asked // for them — by which time the browser has already decided there was nothing to show. Somebody // types their handle, stops, and no suggestion ever appears. The one surface where the feature is // the whole point is the one surface it did not reach. // // So the list is drawn here: an ARIA combobox over the same `HandleTypeahead`, which paints the // moment an answer arrives rather than at the next keypress, matches on text this app normalizes // (a leading `@` reached the directory but could never match a plain handle option — see // `visibleSuggestions`), and is one implementation instead of one per surface. // // `name`/`autocomplete="username"` stay on the input. They are the standard hints for a password // manager that does hold something for this origin, they cost nothing when it does not, and they // are about a different mechanism from the list below — see `Account.svelte` for why this origin // is unlikely to have anything stored under them. // // Each row is drawn with the face the app draws every other identity with (`Disc.svelte`): the // picture the directory named for that handle, or — when nobody has one to offer — the coloured // disc derived from the DID, which is what an identity looks like here anyway. A person picking // themselves out of a list of near-identical handles recognises their own picture before they have // read a domain, and the picture they pick is the one the account chip shows them afterwards. import { rememberedIdentities } from '$lib/auth.svelte.js' import { unknownActor } from '$lib/directory.js' import { HandleTypeahead, mergeSuggestions, visibleSuggestions, type Suggestion, } from '$lib/handle-suggest.svelte.js' import { cachedAvatars } from '$lib/identity.js' import Disc from './Disc.svelte'
interface Props { /** What has been typed. The surface owns it — it is what gets signed in. */ value: string /** The element, for a surface that puts the caret here (the picker's first screen). */ field?: HTMLInputElement | null /** Scopes this field's listbox and option ids: both surfaces can be on screen at once. */ id: string /** The words above the field. */ label: string /** What a screen reader is told the field takes, which is more than the label has room for. */ hint: string disabled?: boolean } let { value = $bindable(''), field = $bindable(null), id, label, hint, disabled = false, }: Props = $props()
// One instance per field rather than a shared store: both surfaces can be mounted at once holding // different text, and a shared list would put one field's answer under the other. const typeahead = new HandleTypeahead() // The pictures this browser resolved while it was signed in, read once and never refreshed: a // remembered identity is one that has been a member somewhere, so its face is usually already // cached, and a logged-out screen has no business resolving DIDs at a directory to decorate a // dropdown. One that is not cached falls back to its derived disc, exactly like an agent's. const faces = cachedAvatars() const remembered = $derived( rememberedIdentities.map((identity) => ({ handle: identity.handle ?? identity.did, did: identity.did, ...(faces[identity.did] ? { avatar: faces[identity.did] as string } : {}), })), ) const suggestions = $derived( visibleSuggestions(mergeSuggestions(remembered, typeahead.suggestions), value), ) $effect(() => { void typeahead.suggest(value) }) // A field that goes away takes its pending ask with it. $effect(() => () => typeahead.dispose())
let focused = $state(false) /** Escape, or an accepted suggestion: this list has been answered, until the next keystroke. */ let dismissed = $state(false) let active = $state(-1)
const open = $derived(focused && !dismissed && suggestions.length > 0) // Clamped rather than stored: the answers change under the caret, and an index kept across that // would point at a handle the reader can no longer see — or at nothing at all. const current = $derived(open && active >= 0 && active < suggestions.length ? active : -1)
// A suggestion with no DID (nothing offers one today, but the shape allows it) takes its colour and // initials from the handle instead — `unknownActor` derives both from whatever it is given. const faceFor = (suggestion: Suggestion) => unknownActor(suggestion.did ?? suggestion.handle, suggestion.handle, suggestion.avatar)
function accept(suggestion: string): void { value = suggestion dismissed = true active = -1 // Chosen, not submitted: pressing Continue is still a separate decision, exactly as it was when // the browser filled this field from a datalist. field?.focus() }
function move(step: number): void { dismissed = false if (suggestions.length === 0) return const count = suggestions.length active = current < 0 ? (step > 0 ? 0 : count - 1) : (current + step + count) % count }
function onkeydown(event: KeyboardEvent): void { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { if (suggestions.length === 0) return // The caret would otherwise jump to one end of the field, which is the browser's answer to this // key in a text input and not the one a list under it wants. event.preventDefault() move(event.key === 'ArrowDown' ? 1 : -1) return } if (event.key === 'Enter') { const picked = current >= 0 ? suggestions[current] : undefined // With nothing highlighted this is the form's Enter and stays the form's Enter. if (!picked) return event.preventDefault() accept(picked.handle) return } if (event.key === 'Escape' && open) { // One layer per press (`keys.ts`): an open list is a layer, so the window handler that would // otherwise blur the field never sees this one. The next Escape leaves the field as always. event.stopPropagation() dismissed = true active = -1 } }</script>
<div class="signin-combo"> <label class="field"> <span class="field-label">{label}</span> <input bind:this={field} bind:value type="text" name="username" spellcheck="false" autocapitalize="off" autocomplete="username" role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls="{id}-suggestions" aria-activedescendant={current >= 0 ? `${id}-suggestion-${current}` : undefined} aria-label={hint} {disabled} placeholder="you.example.com" oninput={() => { dismissed = false active = -1 }} onfocus={() => (focused = true)} onblur={() => (focused = false)} {onkeydown} /> </label> <!-- Rendered whether or not it is showing, because `aria-controls` above names it either way. Options are buttons, and are the listbox's own children rather than list items: the keys that drive this list are handled on the input above, so a row that could only be clicked would be a row a keyboard could not reach if it ever came to that. `tabindex="-1"` keeps them out of the tab order all the same — Tab leaves the field, it does not walk the suggestions. --> <div class="signin-list" id="{id}-suggestions" role="listbox" aria-label="{label} suggestions" hidden={!open} > {#each suggestions as suggestion, index (suggestion.handle)} <!-- `pointerdown` is defaulted away so the field never loses focus to the row being pressed: a blur here would close the list out from under the click that was choosing from it. The face is hidden from assistive technology: a disc carries its initials as text, and the accessible name of this option is the handle, not the handle read out twice. --> <button class="signin-option" class:on={index === current} id="{id}-suggestion-{index}" type="button" role="option" tabindex="-1" aria-selected={index === current} onpointerdown={(event) => event.preventDefault()} onclick={() => accept(suggestion.handle)} > <span class="signin-face" aria-hidden="true"> <Disc actor={faceFor(suggestion)} size={19} /> </span> <span class="signin-handle">{suggestion.handle}</span> </button> {/each} </div></div>{#if typeahead.enabled} <!-- The second sentence is here because a picture is a request: the row's face is fetched from wherever that identity publishes it, which is a host this app never chose and is not the one named above. Said plainly rather than left to be discovered in a network tab. --> <p class="signin-source"> Suggestions beyond the identities this browser remembers come from {typeahead.host}, which sees what you type here. Profile pictures load from wherever each identity publishes them. </p>{/if}