class AddressOrReportInput extends HTMLElement { // make element form-associated static formAssociated = true; constructor() { super(); this.attachShadow({ mode: "open" }); this.internals = this.attachInternals(); this.render(); // Element references this._addresses = []; this._input = this.shadowRoot.querySelector("input"); this._reports = []; this._suggestionsContainer = this.shadowRoot.querySelector( ".suggestions-container", ); // Bind methods this._handleInput = this._handleInput.bind(this); // Debounce timer this._debounceTimer = null; // The suggestion data this._suggestionData = null; } // Lifecycle: when element is added to the DOM connectedCallback() { this._input.addEventListener("input", this._handleInput); } // Lifecycle: when element is removed from the DOM disconnectedCallback() { this._input.removeEventListener("input", this._handleInput); } // Lifecycle: watch these attributes for changes static get observedAttributes() { return ["placeholder", "api-key"]; } // Lifecycle: respond to attribute changes attributeChangedCallback(name, oldValue, newValue) { if (name === "placeholder" && this._input) { this._input.placeholder = newValue; } if (name === "api-key") { this._apiKey = newValue; } } // Properties API get value() { return this._input ? this._input.value : ""; } set value(val) { if (this._input) { this._input.value = val; const entries = new FormData(); entries.append("address", val); this.internals.setFormValue(entries); } } // Private methods _handleInput(event) { const searchText = event.target.value.trim(); // Clear previous timer clearTimeout(this._debounceTimer); // Clear suggestions if input is less than 3 characters if (searchText.length < 3) { this._suggestionsContainer.innerHTML = ""; return; } // Debounce API calls (wait 300ms after typing stops) this._debounceTimer = setTimeout(() => { this._handleSuggestions(searchText); }, 300); } async _fetchAddressSuggestions(text) { try { const url = `https://api.mapbox.com/search/geocode/v6/forward?q=${encodeURIComponent(text)}&access_token=${this._apiKey}`; const response = await fetch(url); const data = await response.json(); return data.features || []; } catch (error) { console.error("Error fetching geocoding suggestions:", error); return []; } } async _fetchReportSuggestions(text) { try { const url = `/report/suggest?r=${text}`; const response = await fetch(url); const data = await response.json(); return data.reports || []; } catch (error) { console.error("Error fetching report suggestions:", error); return []; } } async _handleSuggestions(text) { await Promise.all([ (async () => { this._addresses = await this._fetchAddressSuggestions(text); })(), (async () => { this._reports = await this._fetchReportSuggestions(text); })(), ]); this._renderSuggestions(this._addresses, this._reports); } _renderSuggestions(addresses, reports) { console.log("Rendering suggestions", addresses, reports); const reportElements = reports .map((item, index) => { const formatted_id = _formatReportID(item.id); const type_display = _formatReportType(item.type); return `
${formatted_id}
${type_display}
`; }) .join(""); const addressElements = addresses .map((item, index) => { if (item.properties.place_formatted != "") { return `
${item.properties.name || item.properties.full_address}
${item.properties.place_formatted}
`; } else { return `
${item.properties.name || item.properties.full_address}
${item.properties.place_formatted}
`; } }) .join(""); this._suggestionsContainer.innerHTML = reportElements + addressElements; // Add click listeners to suggestions this.shadowRoot.querySelectorAll(".suggestion-item").forEach((el) => { el.addEventListener("click", (e) => { const type = el.dataset.type; let detail = null; if (type == "report") { const index = parseInt(el.dataset.index); detail = this._reports[index]; this.value = _formatReportID(detail.id); this._suggestionsContainer.innerHTML = ""; } else if (type == "address") { const index = parseInt(el.dataset.index); detail = this._addresses[index]; this.SetValue(detail); // Dispatch custom event } this.dispatchEvent( new CustomEvent("suggestion-selected", { bubbles: true, composed: true, // Allows event to cross shadow DOM boundary detail: detail, }), ); }); }); } // Initial render of component render() { const placeholder = this.getAttribute("placeholder") || "Enter address"; this.shadowRoot.innerHTML = `
`; } // Public methods clear() { if (this._input) { this._input.value = ""; this._suggestionsContainer.innerHTML = ""; } } SetValue(suggestion) { this.value = suggestion.properties.full_address; this._suggestionsContainer.innerHTML = ""; } } function _formatReportID(id) { if (id.length === 12) { return `${id.substring(0, 4)}-${id.substring(4, 8)}-${id.substring(8)}`; } return id; } function _formatReportType(type) { if (type == "nuisance") { return "Mosquito Nuisance Report"; } else if (type == "pool") { return "Standing Water Report"; } else { return "Unknown Report Type"; } } customElements.define("address-or-report-input", AddressOrReportInput);