Mobile fixes
This commit is contained in:
fraxle
2026-06-02 19:36:54 +01:00
parent 51e5381de6
commit bf4c1a0136
4 changed files with 258 additions and 68 deletions
+103 -42
View File
@@ -21,6 +21,7 @@
// ════════════════════════════════════════════════════════════════════════
import { h, render, Fragment } from '../vendor/preact.js';
import { useState, useRef, useEffect } from '../vendor/preact-hooks.js';
import htm from '../vendor/htm.js';
import {
utciCategory, UTCI_BANDS, bandGradient,
@@ -45,7 +46,7 @@ export function UTCIForecast() {
const {
location, setLocationAndSave,
forecast, airQuality, loading, error, now, fetchedAt,
searchQuery, setSearchQuery, searchResults, searching,
searchQuery, setSearchQuery, searchResults, setSearchResults, searching,
selectedDay, setSelectedDay,
proPromptDay, setProPromptDay,
proPromptSource, setProPromptSource,
@@ -84,7 +85,38 @@ export function UTCIForecast() {
bannerVisible, bannerStageRef,
bannerSlideTo, dismissBanner,
} = useAppState();
// Search is hidden by default and revealed via the magnifier next to the
// location. The input then overlays the location line to save space.
const [searchOpen, setSearchOpen] = useState(false);
const searchWrapRef = useRef(null);
const searchInputRef = useRef(null);
// Focus the input the moment the search opens.
useEffect(() => {
if (searchOpen) searchInputRef.current?.focus();
}, [searchOpen]);
// Close the search on outside-click or Escape, clearing any stray query.
useEffect(() => {
if (!searchOpen) return;
const onDown = (e) => {
if (searchWrapRef.current && !searchWrapRef.current.contains(e.target)) {
setSearchOpen(false);
setSearchQuery('');
}
};
const onKey = (e) => {
if (e.key === 'Escape') { setSearchOpen(false); setSearchQuery(''); }
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [searchOpen, setSearchQuery]);
// Columns that mark the start of a logical group - used to draw a faint
// vertical border separating groups in the forecast table.
const GROUP_ORDER = {
@@ -317,48 +349,77 @@ export function UTCIForecast() {
<span class="title-sun">SUN</span><span class="title-scope">Scope</span>
</h1>
<div class="utci-tagline">See the sun the way your skin does.</div>
<div class="utci-search-wrap">
<input
class="utci-search"
type="text"
placeholder="Search any town or city…"
value=${searchQuery}
onInput=${(e) => setSearchQuery(e.currentTarget.value)}
/>
${searchResults.length > 0 && html`
<div class="utci-results">
${searchResults.map((r) => html`
<div
key=${`${r.id}-${r.latitude}`}
class="utci-result"
onClick=${() => {
setLocationAndSave({
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
lat: r.latitude,
lon: r.longitude,
country: r.country_code,
});
setSearchQuery('');
setSearchResults([]);
setSelectedDay(0);
}}
>
<div>${r.name}${r.admin1 ? `, ${r.admin1}` : ''}</div>
<div class="utci-result-meta">
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
</div>
</div>`)}
<div class="utci-loc-search" ref=${searchWrapRef}>
<div class="utci-current-loc">
<button
type="button"
class="utci-loc-name"
aria-label="Search for a town or city"
aria-expanded=${searchOpen}
onClick=${() => setSearchOpen((v) => !v)}
>
<svg class="utci-loc-pin" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
<path fill="currentColor" d="M12 2c-3.87 0-7 3.13-7 7 0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z" />
<circle cx="12" cy="9" r="2.5" fill="#fffcf2" />
</svg>
${location.name}
</button>
<button
type="button"
class="utci-search-toggle"
aria-label="Search for a town or city"
aria-expanded=${searchOpen}
onClick=${() => setSearchOpen((v) => !v)}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
${fetchedAt && !loading && html`
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
${searchOpen && html`
<div class="utci-search-wrap utci-search-overlay">
<input
ref=${searchInputRef}
class="utci-search"
type="text"
placeholder="Search any town or city…"
value=${searchQuery}
onInput=${(e) => setSearchQuery(e.currentTarget.value)}
/>
${searchResults.length > 0 && html`
<div class="utci-results">
${searchResults.map((r) => html`
<div
key=${`${r.id}-${r.latitude}`}
class="utci-result"
onClick=${() => {
setLocationAndSave({
name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`,
lat: r.latitude,
lon: r.longitude,
country: r.country_code,
});
setSearchQuery('');
setSearchResults([]);
setSelectedDay(0);
setSearchOpen(false);
}}
>
<div>${r.name}${r.admin1 ? `, ${r.admin1}` : ''}</div>
<div class="utci-result-meta">
${r.country} · ${r.latitude.toFixed(2)}°, ${r.longitude.toFixed(2)}°
</div>
</div>`)}
</div>`}
${searching && html`<div class="utci-searching">Searching…</div>`}
</div>`}
${searching && html`<div class="utci-searching">Searching…</div>`}
</div>
<div class="utci-current-loc">
${location.name}
<span class="utci-loc-coords">
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}°
</span>
</div>
${fetchedAt && !loading && html`
<div class="utci-fetch-time">Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>`}
</div>
<div class="scope-col">