Redesign search UI with unified input, expandable filters, and integrated map controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

- Consolidate HeroSearch into single responsive form with inline geolocation button and submit actions
- Transform SearchBar into pill-based filter bar with visual state indicators (active filters highlighted)
- Move map toggle from separate component into SearchBar with open/close state management
- Redesign StationList sort controls as pills with icons, move brand filter inline, add result count
- Expand LeafletMap to full-width panel (96 viewport height) controlled by parent open state
- Remove nested mobile/desktop layouts in HeroSearch in favor of single adaptive form
- Add "Refine" and "Sort" labels to filter groups, implement clear-all filters button
- Show verdict card only before first search on mobile, hide after results load
- Position StatsRow within hero gradient, move results section into same gradient container
- Update map initialization to only occur when panel is open, destroy on close
- Add accessibility labels (aria-expanded, aria-controls) to map toggle button
This commit is contained in:
Ovidiu U
2026-04-22 09:38:23 +01:00
parent afe459f248
commit dd9bd95657
6 changed files with 352 additions and 291 deletions

View File

@@ -1,91 +1,114 @@
<template>
<div class="flex flex-col gap-3 max-w-md w-full">
<!-- Row 1: postcode + button -->
<div class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<label for="postcode-input" class="sr-only">Postcode or city</label>
<button
:disabled="locating"
aria-label="Use my location"
class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 px-3 py-1.5
bg-primary/85
text-white rounded-sm text-sm font-semibold transition-opacity hover:opacity-80"
type="button"
@click="useMyLocation"
>
<iconify-icon icon="lucide:locate-fixed" style="font-size:16px;"></iconify-icon>
<span class="hidden sr-only">Near me</span>
</button>
<input
id="postcode-input"
v-model="postcode"
type="text"
:placeholder="coords ? 'Using your current location' : 'Enter postcode, e.g. SW1A 1AA'"
class="w-full h-14 pr-28 pl-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base"
@keyup.enter="onSearch"
/>
</div>
<button
@click="onSearch"
:disabled="!postcode.trim() && !coords"
class="h-14 px-8 bg-primary text-white rounded-xl font-bold text-base shadow-xl hover:bg-primary-dark transition-all disabled:cursor-not-allowed"
>
Find Prices
</button>
</div>
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
<!-- Leading label -->
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
Refine
</span>
<!-- Row 2: fuel type + radius + sort -->
<div class="grid grid-cols-3 gap-2">
<!-- Fuel type -->
<label
:class="[
'relative group inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
fuelType !== DEFAULTS.fuelType
? 'bg-primary/10 border-primary text-primary'
: 'bg-white border-zinc-200 hover:border-zinc-300',
]"
>
<iconify-icon class="text-sm opacity-70" icon="lucide:fuel"></iconify-icon>
<span class="text-sm font-medium">{{ fuelLabel }}</span>
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
<select
v-model="fuelType"
aria-label="Fuel type"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent truncate"
class="absolute inset-0 opacity-0 cursor-pointer"
>
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">
{{ fuel.label }}
</option>
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">{{ fuel.label }}</option>
</select>
<select
v-model="radius"
aria-label="Search radius"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
>
<!-- <option :value="2">2 miles</option> -->
</label>
<!-- Radius -->
<label
:class="[
'relative group inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
radius !== DEFAULTS.radius
? 'bg-primary/10 border-primary text-primary'
: 'bg-white border-zinc-200 hover:border-zinc-300',
]"
>
<iconify-icon class="text-sm opacity-70" icon="lucide:circle-dot"></iconify-icon>
<span class="text-sm font-medium">{{ radius }} miles</span>
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
<select
v-model.number="radius"
aria-label="Search radius"
class="absolute inset-0 opacity-0 cursor-pointer"
>
<option :value="5">5 miles</option>
<option :value="10">10 miles</option>
<option :value="20">20 miles</option>
</select>
<select
v-model="sort"
aria-label="Sort by"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
</label>
<!-- Show / hide map -->
<button
:aria-expanded="mapOpen"
:class="[
'inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
mapOpen
? 'bg-primary/10 border-primary text-primary'
: 'bg-white border-zinc-200 text-zinc-700 hover:border-zinc-300',
]"
aria-controls="leaflet-map-panel"
type="button"
@click="emit('toggle-map')"
>
<iconify-icon :icon="mapOpen ? 'lucide:map' : 'lucide:map-off'" class="text-sm opacity-70"></iconify-icon>
<span class="text-sm font-medium">{{ mapOpen ? 'Hide map' : 'Show map' }}</span>
<iconify-icon
:class="mapOpen ? 'rotate-180' : ''"
class="text-sm opacity-60 transition-transform duration-200"
icon="lucide:chevron-down"
></iconify-icon>
</button>
<!-- Divider + clear (only when any active) -->
<template v-if="hasActive">
<span class="hidden md:inline h-5 w-px bg-zinc-200 mx-1"></span>
<button
class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors"
type="button"
@click="resetFilters"
>
<option value="reliable">Reliable</option>
<option value="price">Price</option>
<option value="distance">Distance</option>
<option value="updated">Updated</option>
</select>
</div>
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
Clear
</button>
</template>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { FUEL_TYPES } from '../constants/fuelTypes.js'
const DEFAULTS = Object.freeze({
fuelType: 'e10',
radius: 10,
sort: 'reliable',
})
const props = defineProps({
initial: { type: Object, default: () => ({}) },
resultCount: { type: Number, default: null },
mapOpen: { type: Boolean, default: true },
})
const emit = defineEmits(['search'])
const emit = defineEmits(['search', 'toggle-map'])
const postcode = ref('')
const coords = ref(null)
const fuelType = ref('e10')
const radius = ref(10)
const sort = ref('reliable')
const locating = ref(false)
const fuelType = ref(DEFAULTS.fuelType)
const radius = ref(DEFAULTS.radius)
const sort = ref(DEFAULTS.sort)
let hydrating = false
@@ -100,29 +123,28 @@ watch(() => props.initial, (v) => {
nextTick(() => { hydrating = false })
}, { immediate: true, deep: true })
watch(postcode, () => { coords.value = null })
watch([fuelType, radius, sort], () => {
if (hydrating) return
if (postcode.value.trim() || coords.value) onSearch()
if (postcode.value.trim() || coords.value) emitSearch()
})
function useMyLocation() {
if (!navigator.geolocation) return
locating.value = true
navigator.geolocation.getCurrentPosition(
({ coords: c }) => {
coords.value = { lat: c.latitude, lng: c.longitude }
postcode.value = ''
locating.value = false
onSearch()
},
() => { locating.value = false },
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
)
const fuelLabel = computed(() => {
return FUEL_TYPES.find(f => f.value === fuelType.value)?.label ?? 'Fuel'
})
const hasActive = computed(() => (
fuelType.value !== DEFAULTS.fuelType
|| radius.value !== DEFAULTS.radius
|| sort.value !== DEFAULTS.sort
))
function resetFilters() {
fuelType.value = DEFAULTS.fuelType
radius.value = DEFAULTS.radius
sort.value = DEFAULTS.sort
}
function onSearch() {
function emitSearch() {
const hasPostcode = postcode.value.trim().length > 0
const hasCoords = coords.value !== null
if (!hasPostcode && !hasCoords) return