Move to using web components for custom components

They're modular, which is really nice.
This commit is contained in:
Eli Ribble 2026-01-14 18:21:56 +00:00
parent b91718cd7c
commit 6fd0ed8711
No known key found for this signature in database
9 changed files with 473 additions and 280 deletions

20
htmlpage/static.go Normal file
View file

@ -0,0 +1,20 @@
package htmlpage
import (
"embed"
"net/http"
"github.com/go-chi/chi/v5"
)
//go:embed static/*
var EmbeddedStaticFS embed.FS
var localFS http.Dir
func AddStaticRoute(r chi.Router, path string) {
if localFS == "" {
localFS = http.Dir("./htmlpage/static")
}
FileServer(r, "/static", localFS, EmbeddedStaticFS, "static")
}

View file

@ -0,0 +1,127 @@
class AddressDisplay extends HTMLElement {
constructor() {
super();
// Create a shadow DOM
this.attachShadow({mode: "open" });
// Initial render
this.render();
// Element references
this._locationDisplay = this.shadowRoot.querySelector(".location-display");
this._streetAddress = this.shadowRoot.querySelector(".street-address");
this._postCode = this.shadowRoot.querySelector(".post-code");
this._district = this.shadowRoot.querySelector(".district");
this._region = this.shadowRoot.querySelector(".region");
this._country = this.shadowRoot.querySelector(".country");
}
// Initial render of component
render() {
this.shadowRoot.innerHTML = `
<style>
@import url('/static/vendor/css/bootstrap.min.css');
.detail-label {
font-size: 0.8rem;
text-transform: uppercase;
color: #6c757d;
margin-bottom: 2px;
font-weight: 600;
}
.detail-value {
font-weight: 500;
}
.main-address {
font-weight: 500;
}
.place-info {
font-size: 0.85rem;
color: #6c757d;
margin-top: 2px;
}
.suggestions-container {
position: absolute;
width: 100%;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.suggestion-item {
cursor: pointer;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
.suggestion-item:hover {
background-color: #f8f9fa;
}
</style>
<div class="location-display" class="mt-4 d-none">
<h5 class="mb-3">Location Details</h5>
<div class="location-card p-3 mb-3">
<div class="location-detail">
<div class="detail-label">Street Address</div>
<div class="street-address detail-value">-</div>
</div>
<div class="location-detail">
<div class="detail-label">Post Code</div>
<div class="post-code detail-value">-</div>
</div>
<div class="location-detail">
<div class="detail-label">District/Place</div>
<div class="district detail-value">-</div>
</div>
<div class="location-detail">
<div class="detail-label">Region/State</div>
<div class="region detail-value">-</div>
</div>
<div class="location-detail">
<div class="detail-label">Country</div>
<div class="country detail-value">-</div>
</div>
</div>
</div>
`;
}
// Public methods
show(location) {
console.log("Showing location", location);
// Extract context data from properties
const props = location.properties;
const context = props.context || {};
// Populate structured fields
// Street Address - combine address, street, housenumber if available
let addressStr = '';
if (context.address) addressStr += context.address.address_number;
if (context.street) {
if (addressStr) addressStr += ' ';
addressStr += context.street.name;
}
if (addressStr === '') {
addressStr = props.name || props.full_address || '-';
}
this._streetAddress.textContent = addressStr;
// Post Code
this._postCode.textContent = context.postcode.name || '-';
// District (could be district, locality, or place)
this._district.textContent = context.district.name || context.place.name || context.locality.name || '-';
// Region (state, province, etc.)
this._region.textContent = context.region.name || '-';
// Country
this._country.textContent = context.country.name || '-';
}
}
customElements.define('address-display', AddressDisplay);

View file

@ -0,0 +1,226 @@
class AddressInput extends HTMLElement {
constructor() {
super();
// Create a shadow DOM
this.attachShadow({mode: "open" });
// Initial render
this.render();
// Element references
this._input = this.shadowRoot.querySelector('input');
this._suggestions = 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;
}
}
// 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._suggestions.innerHTML = '';
return;
}
// Debounce API calls (wait 300ms after typing stops)
this._debounceTimer = setTimeout(() => {
this._fetchAddressSuggestions(searchText)
.then(response => {
this._renderSuggestions(response.features);
});
}, 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;
} catch (error) {
console.error('Error fetching geocoding suggestions:', error);
}
}
_renderSuggestions(suggestions) {
console.log("Rendering suggestions", suggestions);
this._suggestions.innerHTML = suggestions.map((item, index) => {
if (item.properties.place_formatted != "") {
return `
<div class="suggestion-item list-group-item"
data-index="${index}"
data-lat="${item.geometry.coordinates[1]}"
data-lng="${item.geometry.coordinates[0]}">
<div class="main-address">${item.properties.name || item.properties.full_address}</div>
<div class="place-info">${item.properties.place_formatted}</div>
</div>`
} else {
return `
<div class="suggestion-item list-group-item"
data-index="${index}"
data-lat="${item.coordinates.lat}"
data-lng="${item.coordinates.lng}">
<div class="main-address">${item.properties.name || item.properties.full_address}</div>
<div class="place-info">${item.properties.place_formatted}</div>
</div>`
}
}).join('');
// Add click listeners to suggestions
this.shadowRoot.querySelectorAll('.suggestion-item').forEach(el => {
el.addEventListener('click', e => {
const index = parseInt(el.dataset.index);
const suggestion = suggestions[index];
this.value = suggestion.properties.full_address;
this._suggestions.innerHTML = '';
// Dispatch custom event
this.dispatchEvent(new CustomEvent('address-selected', {
bubbles: true,
composed: true, // Allows event to cross shadow DOM boundary
detail: {
location: suggestion
}
}));
});
});
}
// Initial render of component
render() {
const placeholder = this.getAttribute('placeholder') || 'Enter address';
this.shadowRoot.innerHTML = `
<style>
@import url('/static/vendor/css/bootstrap.min.css');
.detail-label {
font-size: 0.8rem;
text-transform: uppercase;
color: #6c757d;
margin-bottom: 2px;
font-weight: 600;
}
.detail-value {
font-weight: 500;
}
.main-address {
font-weight: 500;
}
.place-info {
font-size: 0.85rem;
color: #6c757d;
margin-top: 2px;
}
.suggestions-container {
position: absolute;
width: 100%;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.suggestion-item {
cursor: pointer;
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
.suggestion-item:hover {
background-color: #f8f9fa;
}
</style>
<label for="addressInput" class="form-label">Enter address</label>
<input type="text" class="form-control" id="address" name="address" placeholder="${placeholder}">
<div id="suggestions" class="suggestions-container list-group"></div>
`;
}
// Public methods
clear() {
if (this._input) {
this._input.value = '';
this._suggestions.innerHTML = '';
}
}
}
customElements.define('address-input', AddressInput);
function setLocationInputs(suggestion) {
let address = document.getElementById('address');
let country = document.getElementById('address-country');
let latitude = document.getElementById('latitude');
let longitude = document.getElementById('longitude');
let latlngAccuracyType = document.getElementById('latlng-accuracy-type');
let postcode = document.getElementById('address-postcode');
let place = document.getElementById('address-place');
let region = document.getElementById('address-region');
let street = document.getElementById('address-street');
// Extract context data from properties
const props = suggestion.properties;
const context = props.context || {};
// Populate structured fields
address.value = props.full_address;
country.value = context.country.name;
latitude.value = props.coordinates.latitude;
longitude.value = props.coordinates.longitude;
latlngAccuracyType.value = props.coordinates.accuracy;
postcode.value = context.postcode.name;
place.value = context.place.name;
region.value = context.region.name;
street.value = context.country.name;
}

View file

@ -8,6 +8,4 @@ async function geocodeReverse(MAPBOX_ACCESS_TOKEN, lngLat) {
return;
}
const match = data.features[0];
displaySelectedLocation(match);
setLocationInputs(match);
}