import { css, html, nothing, type PropertyValues } from 'lit'; import { property, state } from 'lit/decorators.js'; import { RoxyLocalizedElement } from '../i18n/localized-element.js'; import type { SearchCitiesResponse } from '../types/index.js'; import { baseStyles } from '../utils/base-styles.js'; import { comboboxStyles } from '../utils/combobox-styles.js'; import { debounce } from '../utils/debounce.js'; import { customElement } from '../utils/define.js'; import { apiFetch, DEFAULT_BASE_URL, readApiError, submitContextConverter, } from '../utils/fetch-controller.js'; import { dispatchKeyRefusal, type KeyRefusal, keyRefusal, } from '../utils/key-guard.js'; type CityResult = SearchCitiesResponse['cities'][number]; /** * Stateful location search input. Calls /location/search and emits * `roxy-location-select` CustomEvent with the chosen city. Required for any * chart endpoint. * * Behavior: 300ms input debounce, click-outside dismiss, keyboard navigation * with arrow keys / Enter / Escape, AbortController on stale requests. A search * that fails prints the reason under the field and keeps it there until the * query changes, so a key or quota fault is legible where it happens instead of * surfacing later as a missing field on submit. * * Attributes: * api-key optional. Direct call to roxyapi.com when set. * publishable-key optional. Browser-safe pk_* key with allowed_origins. * endpoint optional. Where the search sends its GET. Absolute, or relative to the * page. Set, it wins over submit-url; unset, the public location endpoint. * submit-url optional. A proxy route that holds the key. With no endpoint set, the search * is POSTed there as one request, the way every other request of a widget is. * submit-context optional. A JSON object sent to submit-url as `context` beside the request. * placeholder optional. Input placeholder. Translated when it is the default (see below). * default-value optional. Pre-filled query. * lang optional. Display language. Resolves from the page when absent, EXCEPT * inside another component shadow root, which is why the form forwards it. * * The words this box writes (its placeholder, its empty state, its busy label, its refusal * message) read the page language. The CITY NAMES do not: they are the values the location API * returned and are printed as they came back, so the box and the response can never disagree. */ @customElement('roxy-location-search') export class RoxyLocationSearch extends RoxyLocalizedElement { static styles = [ baseStyles, comboboxStyles, css` :host { display: block; position: relative; } .field { position: relative; } input { width: 100%; padding: var(--roxy-space-sm, 0.5rem) var(--roxy-space-md, 1rem); font-size: var(--roxy-text-base, 1rem); font-family: inherit; color: var(--roxy-fg, #0a0a0a); background: var(--roxy-bg, #fff); border: 1px solid var(--roxy-border, #e4e4e7); border-radius: var(--roxy-radius-md, 8px); transition: border-color var(--roxy-motion-duration, 200ms) var(--roxy-motion-easing, cubic-bezier(0.4, 0, 0.2, 1)); box-sizing: border-box; } input:focus { outline: 2px solid var(--roxy-ring, rgba(245, 158, 11, 0.4)); outline-offset: 2px; border-color: var(--roxy-accent-ink, #b45309); } .option .city { font-weight: var(--roxy-weight-bold, 600); } .option .where { color: var(--roxy-muted, #71717a); flex-grow: 1; } .option .tz { color: var(--roxy-muted, #71717a); font-size: var(--roxy-text-xs, 0.75rem); font-variant-numeric: tabular-nums; } .search-error { margin: var(--roxy-space-xs, 0.25rem) 0 0; color: var(--_danger-fg); font-size: var(--roxy-text-sm, 0.875rem); } `, ]; @property({ type: String, attribute: 'api-key' }) apiKey?: string; @property({ type: String, attribute: 'publishable-key' }) publishableKey?: string; /** Where the search sends its GET; unset, the search goes through {@link submitUrl} when one is set, else to the public location endpoint. */ @property({ type: String }) endpoint?: string; /** Proxy route that holds the key; the search is POSTed there when no {@link endpoint} is set. */ @property({ type: String, attribute: 'submit-url' }) submitUrl?: string; /** Object sent to {@link submitUrl} as `context` beside the search. */ @property({ attribute: 'submit-context', converter: submitContextConverter }) submitContext?: Record; /** Input placeholder. The default is a catalogue entry and renders in the page language; a caller-supplied one is printed as given, because a miss returns the source string unchanged. */ @property({ type: String }) placeholder = 'Search city'; @property({ type: String, attribute: 'default-value' }) defaultValue = ''; @state() private query = ''; @state() private results: CityResult[] = []; @state() private isOpen = false; @state() private isLoading = false; @state() private highlight = -1; /** Why the key is refused, when it is, so the search fail-closes: no network call, a visible error, matching {@link FetchController}. */ @state() private refusal?: KeyRefusal; /** Message from the last failed search, or null. Rendered under the field and outliving the dropdown, so a failed request reads as a failure rather than as a city that does not exist. */ @state() private error: string | null = null; private clickOutsideHandler?: (e: MouseEvent) => void; private abortController?: AbortController; private debouncedFetch = debounce((q: string) => { void this.fetchResults(q); }, 300); connectedCallback(): void { super.connectedCallback(); this.query = this.defaultValue; this.clickOutsideHandler = (e: MouseEvent) => { const path = e.composedPath(); if (!path.includes(this)) this.isOpen = false; }; document.addEventListener('mousedown', this.clickOutsideHandler); } protected willUpdate(changed: PropertyValues): void { if (changed.has('publishableKey') || changed.has('apiKey')) { // Effective key mirrors fetchResults: publishable-key wins, api-key is the // legacy fallback. A refused key fail-closes here, warning once. const refusal = keyRefusal(this.publishableKey ?? this.apiKey); if (refusal && !this.refusal) dispatchKeyRefusal(this, refusal, { warn: true }); this.refusal = refusal; } } disconnectedCallback(): void { super.disconnectedCallback(); if (this.clickOutsideHandler) { document.removeEventListener('mousedown', this.clickOutsideHandler); } this.debouncedFetch.cancel(); if (this.abortController) { this.abortController.abort(); this.abortController = undefined; } } private async fetchResults(q: string) { // Fail closed: never send a refused key. willUpdate has already set // refusal and surfaced the error; this guards the direct-call paths. if (this.refusal) return; // Abort any in-flight request so a stale response cannot overwrite a // fresher one (debounced typing) or land after disconnect. if (this.abortController) this.abortController.abort(); const controller = new AbortController(); this.abortController = controller; this.isLoading = true; try { const json = await this.search(q, controller.signal); if (controller.signal.aborted) return; this.error = null; this.results = json.cities ?? []; // Open even on zero matches, or the empty state can never render: gating this // on `length > 0` closed the box on an empty result and said nothing at all. this.isOpen = true; this.highlight = this.results.length > 0 ? 0 : -1; } catch (err) { if ((err as { name?: string })?.name === 'AbortError') return; this.results = []; this.isOpen = false; this.highlight = -1; this.error = err instanceof Error ? err.message : String(err); } finally { if (this.abortController === controller) { this.abortController = undefined; } if (!controller.signal.aborted) this.isLoading = false; } } /** One search: through the proxy route when it is the only route set, else a GET to the endpoint with the key. */ private async search( q: string, signal: AbortSignal, ): Promise { if (!this.endpoint && this.submitUrl) return apiFetch( { submitUrl: this.submitUrl, submitContext: this.submitContext }, { path: '/location/search', method: 'GET', query: { q, limit: 8 } }, signal, ); // Resolved against the page, so a same-origin route ("/api/roxy/location/search") // is as valid an endpoint as an absolute URL. const url = new URL( this.endpoint || `${DEFAULT_BASE_URL}/location/search`, document.baseURI, ); url.searchParams.set('q', q); url.searchParams.set('limit', '8'); const headers: Record = { Accept: 'application/json', }; // publishable-key wins when both are set. Surfacing the conflict at the // console (not as a thrown error) avoids breaking widgets that legitimately // have a stale secret key around while migrating to publishable. if (this.apiKey && this.publishableKey) { console.warn( '[roxy-location-search] both api-key and publishable-key set; using publishable-key. Remove api-key from your widget markup.', ); } const key = this.publishableKey ?? this.apiKey; if (key) headers['X-API-Key'] = key; const res = await fetch(url, { headers, signal }); if (!res.ok) throw new Error(await readApiError(res)); return (await res.json()) as SearchCitiesResponse; } private onInput = (e: Event) => { const value = (e.target as HTMLInputElement).value; this.query = value; if (value.length < 2) { this.results = []; this.isOpen = false; this.highlight = -1; this.error = null; return; } this.debouncedFetch(value); }; private select(city: CityResult) { this.query = `${city.city}${city.province ? `, ${city.province}` : ''}, ${city.country}`; this.isOpen = false; this.results = []; this.dispatchEvent( new CustomEvent('roxy-location-select', { detail: city, bubbles: true, composed: true, }), ); } private onKeyDown = (e: KeyboardEvent) => { if (!this.isOpen || this.results.length === 0) { if (e.key === 'ArrowDown' && this.query.length >= 2) { void this.fetchResults(this.query); e.preventDefault(); } return; } if (e.key === 'ArrowDown') { e.preventDefault(); this.highlight = (this.highlight + 1) % this.results.length; } else if (e.key === 'ArrowUp') { e.preventDefault(); this.highlight = (this.highlight - 1 + this.results.length) % this.results.length; } else if (e.key === 'Enter') { e.preventDefault(); const target = this.results[this.highlight] ?? this.results[0]; if (target) this.select(target); } else if (e.key === 'Escape') { this.isOpen = false; } }; render() { if (this.refusal) { return html``; } return html`
= 0 ? `roxy-location-option-${this.highlight}` : '' } aria-autocomplete="list" autocomplete="off" placeholder=${this.t(this.placeholder)} .value=${this.query} @input=${this.onInput} @keydown=${this.onKeyDown} @focus=${() => { if (this.results.length > 0) this.isOpen = true; }} /> ${ this.isLoading ? html`` : nothing } ${ this.isOpen ? html`
    ${ this.results.length === 0 ? html`
  • ${this.t('No cities found')}
  • ` : this.results.map( (city, idx) => html`
  • `, ) }
` : nothing }
${ /* * Outside `.field` on purpose, so it is not part of the dropdown and does not * close with it. The click that submits the surrounding form also dismisses * the dropdown through the click-outside handler, and that is the same moment * the form reports the field as unfilled, so a reason rendered inside the * popup would disappear exactly when it becomes worth reading. * * Through `t()` for the same reason {@link RoxyDataElement.renderError} does * it: most of what lands here is a wire fact that misses the catalogue and * renders unchanged, but one of them is ours, and translating a message on * one surface and not the other puts two languages on one page. */ this.error ? html`` : nothing }`; } } declare global { interface HTMLElementTagNameMap { 'roxy-location-search': RoxyLocationSearch; } }