import { css, html, nothing, type PropertyValues } from 'lit'; import { customElement, 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 { debounce } from '../utils/debounce.js'; import { DEFAULT_BASE_URL, readApiError } from '../utils/fetch-controller.js'; import { dispatchKeyRefusal, KEY_REFUSED_MESSAGE, keyIsRefused, } 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 request. Absolute, or relative to the * page, so a site that routes API traffic through its own server names that * route here. Defaults to the public location endpoint. * 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, 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); } .spinner { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; border: 2px solid var(--roxy-muted, #71717a); border-top-color: transparent; border-radius: 50%; animation: roxy-spin 700ms linear infinite; } @keyframes roxy-spin { to { transform: translateY(-50%) rotate(360deg); } } @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } .results { position: absolute; z-index: 50; top: calc(100% + 4px); left: 0; right: 0; /* Reset the UA
${this.t(this.error)}
` : nothing }`; } } declare global { interface HTMLElementTagNameMap { 'roxy-location-search': RoxyLocationSearch; } }