diff --git a/src/lib/typeahead.test.ts b/src/lib/typeahead.test.ts index 4f71472..335c359 100644 --- a/src/lib/typeahead.test.ts +++ b/src/lib/typeahead.test.ts @@ -2,12 +2,25 @@ import { describe, expect, it } from 'vitest' import { wireTypeaheadSubmit } from './typeahead' function setup() { - const input = new EventTarget() + const input = Object.assign(new EventTarget(), { value: '' }) + const host = new EventTarget() let submits = 0 - wireTypeaheadSubmit(input, { requestSubmit: () => submits++ }) + wireTypeaheadSubmit(input, { requestSubmit: () => submits++ }, host) const press = (key: string) => input.dispatchEvent(Object.assign(new Event('keydown'), { key })) - const type = () => input.dispatchEvent(new Event('input')) - return { press, type, submits: () => submits } + const type = (value = 'x') => { + input.value = value + input.dispatchEvent(new Event('input')) + } + // A pointer pick: the component fills the input from its shadow-root + // pointerup listener (no 'input' event), then the event reaches the host. + const clickSuggestion = (handle: string) => { + input.value = handle + host.dispatchEvent(new Event('pointerup')) + } + // A pointerup that reaches the host without picking anything, e.g. on the + // dropdown's padding. + const clickHostChrome = () => host.dispatchEvent(new Event('pointerup')) + return { press, type, clickSuggestion, clickHostChrome, submits: () => submits } } describe('wireTypeaheadSubmit', () => { @@ -43,4 +56,26 @@ describe('wireTypeaheadSubmit', () => { t.press('Enter') expect(t.submits()).toBe(1) }) + + it('submits when clicking a suggestion fills in an account', () => { + const t = setup() + t.type('ali') + t.clickSuggestion('alice.example.com') + expect(t.submits()).toBe(1) + }) + + it('does not submit a pointerup that picked nothing', () => { + const t = setup() + t.type('ali') + t.clickHostChrome() + expect(t.submits()).toBe(0) + }) + + it('does not resubmit the same pick twice', () => { + const t = setup() + t.type('ali') + t.clickSuggestion('alice.example.com') + t.clickHostChrome() + expect(t.submits()).toBe(1) + }) }) diff --git a/src/lib/typeahead.ts b/src/lib/typeahead.ts index a3eec44..9fb3ab7 100644 --- a/src/lib/typeahead.ts +++ b/src/lib/typeahead.ts @@ -1,20 +1,32 @@ -// Companion to the actor-typeahead web component, which swallows every Enter -// keydown (preventDefault) so that Enter can pick the highlighted suggestion. -// With no highlight that leaves Enter doing nothing, breaking implicit form -// submission. The component keeps its state in a closed shadow root, so this -// mirrors the relevant part: whether the user has moved the highlight since -// last typing. +// Companion to the actor-typeahead web component, which keeps its state in a +// closed shadow root. Two gaps are bridged here: +// +// - Enter: the component swallows every Enter keydown (preventDefault) so +// that Enter can pick the highlighted suggestion. With no highlight that +// leaves Enter doing nothing, breaking implicit form submission. This +// mirrors the relevant part of the component's state: whether the user has +// moved the highlight since last typing. +// - Click: picking a suggestion with the pointer only copies the handle into +// the input, so signing in would always need a second click on the button. +// Clicking an account should just sign in as it; the button stays for the +// typed-by-hand path. // // Kept free of DOM globals so it can be unit-tested in node; the entry points // import 'actor-typeahead' themselves to register the element. const NAV_KEYS = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp'] -export function wireTypeaheadSubmit(input: EventTarget, form: { requestSubmit(): void }): void { +export function wireTypeaheadSubmit( + input: EventTarget & { value: string }, + form: { requestSubmit(): void }, + host?: EventTarget, +): void { let navigating = false + let typed = input.value input.addEventListener('input', () => { navigating = false + typed = input.value }) input.addEventListener('keydown', (evt) => { @@ -33,4 +45,19 @@ export function wireTypeaheadSubmit(input: EventTarget, form: { requestSubmit(): } } }) + + // A pointer pick is visible out here as a pointerup retargeted to the host + // element (clicks on the slotted input keep their own target), and the + // component's shadow-root listener has already run by then, copying the + // handle into the input without an 'input' event. A retargeted pointerup + // that changed the value is therefore a picked account. Enter picks stay + // two-step: their synthetic pointerup is not composed and never gets here. + host?.addEventListener('pointerup', (evt) => { + if (evt.target !== host) return + if (!input.value || input.value === typed) return + typed = input.value + navigating = false + console.debug('[substandard] typeahead click: sign in as', input.value) + form.requestSubmit() + }) } diff --git a/src/popup/popup.ts b/src/popup/popup.ts index f45b4d7..208d2b2 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -451,7 +451,9 @@ $('signin-form').addEventListener('submit', async (e) => { note.textContent = `Sign-in failed: ${err instanceof Error ? err.message : err}` } }) -wireTypeaheadSubmit($('handle-input'), $('signin-form')) +// The host element makes clicking a suggested account sign in as it. +const handleInput = $('handle-input') +wireTypeaheadSubmit(handleInput, $('signin-form'), handleInput.closest('actor-typeahead') ?? undefined) // Just a shortcut into the sign-in form: the current login stays usable, and // the worker revokes it only once the replacement sign-in completes