- 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
162 lines
5.9 KiB
Vue
162 lines
5.9 KiB
Vue
<template>
|
|
<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>
|
|
|
|
<!-- 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="absolute inset-0 opacity-0 cursor-pointer"
|
|
>
|
|
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">{{ fuel.label }}</option>
|
|
</select>
|
|
</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>
|
|
</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"
|
|
>
|
|
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
|
|
Clear
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
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', 'toggle-map'])
|
|
|
|
const postcode = ref('')
|
|
const coords = ref(null)
|
|
const fuelType = ref(DEFAULTS.fuelType)
|
|
const radius = ref(DEFAULTS.radius)
|
|
const sort = ref(DEFAULTS.sort)
|
|
|
|
let hydrating = false
|
|
|
|
watch(() => props.initial, (v) => {
|
|
if (!v) return
|
|
hydrating = true
|
|
if (typeof v.postcode === 'string') postcode.value = v.postcode
|
|
if (v.lat != null && v.lng != null) coords.value = { lat: v.lat, lng: v.lng }
|
|
if (v.fuelType) fuelType.value = v.fuelType
|
|
if (v.radius != null) radius.value = Number(v.radius)
|
|
if (v.sort) sort.value = v.sort
|
|
nextTick(() => { hydrating = false })
|
|
}, { immediate: true, deep: true })
|
|
|
|
watch([fuelType, radius, sort], () => {
|
|
if (hydrating) return
|
|
if (postcode.value.trim() || coords.value) emitSearch()
|
|
})
|
|
|
|
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 emitSearch() {
|
|
const hasPostcode = postcode.value.trim().length > 0
|
|
const hasCoords = coords.value !== null
|
|
if (!hasPostcode && !hasCoords) return
|
|
|
|
emit('search', {
|
|
postcode: hasPostcode ? postcode.value.trim() : null,
|
|
lat: hasCoords ? coords.value.lat : null,
|
|
lng: hasCoords ? coords.value.lng : null,
|
|
fuelType: fuelType.value,
|
|
radius: radius.value,
|
|
sort: sort.value,
|
|
})
|
|
}
|
|
</script>
|