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`
${
/*
* 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`