refactor: replace chip filters with compact dropdown menus in transport section
Filters now occupy a single compact row instead of multiple chip rows, giving more vertical space to taxi and shuttle cards. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
208
frontend/src/components/transporte/TransportFilterSelect.vue
Normal file
208
frontend/src/components/transporte/TransportFilterSelect.vue
Normal file
@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
options: { value: string; label: string; icon?: string }[]
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const selected = computed(() => props.options.find(o => o.value === props.modelValue))
|
||||
const isFiltered = computed(() => props.modelValue !== props.options[0]?.value)
|
||||
|
||||
function select(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onOutsideClick(e: MouseEvent) {
|
||||
if (root.value && !root.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', onOutsideClick))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-select" ref="root">
|
||||
<button
|
||||
class="filter-trigger"
|
||||
:class="{ 'filter-trigger--active': isFiltered, 'filter-trigger--open': open }"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="listbox"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span
|
||||
v-if="selected?.icon"
|
||||
class="material-icons notranslate trigger-icon"
|
||||
translate="no"
|
||||
aria-hidden="true"
|
||||
>{{ selected.icon }}</span>
|
||||
<span class="trigger-label">{{ selected?.label ?? placeholder }}</span>
|
||||
<span class="material-icons notranslate chevron" translate="no" aria-hidden="true">expand_more</span>
|
||||
</button>
|
||||
|
||||
<Transition name="drop">
|
||||
<ul v-if="open" class="dropdown" role="listbox">
|
||||
<li
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
class="dropdown-item"
|
||||
:class="{ 'dropdown-item--selected': modelValue === opt.value }"
|
||||
role="option"
|
||||
:aria-selected="modelValue === opt.value"
|
||||
@mousedown.prevent="select(opt.value)"
|
||||
>
|
||||
<span
|
||||
v-if="opt.icon"
|
||||
class="material-icons notranslate item-icon"
|
||||
translate="no"
|
||||
aria-hidden="true"
|
||||
>{{ opt.icon }}</span>
|
||||
{{ opt.label }}
|
||||
<span
|
||||
v-if="modelValue === opt.value"
|
||||
class="material-icons notranslate check-icon"
|
||||
translate="no"
|
||||
aria-hidden="true"
|
||||
>check</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-select {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 38px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-trigger--active {
|
||||
border-color: var(--active-color);
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-trigger--open {
|
||||
border-color: var(--active-color);
|
||||
}
|
||||
|
||||
.filter-trigger:focus-visible {
|
||||
outline: 2px solid var(--active-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.trigger-icon {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--active-color);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 1rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-trigger--open .chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.375rem;
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-item--selected {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--active-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--active-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.drop-enter-active,
|
||||
.drop-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.drop-enter-from,
|
||||
.drop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter-trigger,
|
||||
.chevron,
|
||||
.dropdown-item {
|
||||
transition: none;
|
||||
}
|
||||
.drop-enter-active,
|
||||
.drop-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -8,7 +8,7 @@ import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||
import AppImage from '@/components/AppImage.vue'
|
||||
import AuthGuard from '@/components/common/AuthGuard.vue'
|
||||
import TaxiSkeletonCard from '@/components/transporte/TaxiSkeletonCard.vue'
|
||||
import TransportFilterChips from '@/components/transporte/TransportFilterChips.vue'
|
||||
import TransportFilterSelect from '@/components/transporte/TransportFilterSelect.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const taxiStore = useTaxiStore()
|
||||
@ -141,9 +141,9 @@ function getShiftLabel(shift: string) {
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="t('taxi.allZones') + '...'"
|
||||
placeholder="Buscar conductor..."
|
||||
class="search-input"
|
||||
:aria-label="'Buscar conductor'"
|
||||
aria-label="Buscar conductor"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
@ -155,28 +155,16 @@ function getShiftLabel(shift: string) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Zone chips -->
|
||||
<div class="filter-section">
|
||||
<span class="filter-label">{{ t('taxi.area') }}</span>
|
||||
<TransportFilterChips
|
||||
<!-- Dropdowns row -->
|
||||
<div class="dropdowns-row">
|
||||
<TransportFilterSelect
|
||||
v-model="selectedZone"
|
||||
:options="zoneOptions"
|
||||
:label="t('taxi.area')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Shift chips -->
|
||||
<div class="filter-section">
|
||||
<span class="filter-label">{{ t('taxi.shift') }}</span>
|
||||
<TransportFilterChips
|
||||
<TransportFilterSelect
|
||||
v-model="selectedShift"
|
||||
:options="shiftOptions"
|
||||
:label="t('taxi.shift')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- English toggle -->
|
||||
<div class="filter-section">
|
||||
<button
|
||||
class="lang-pill"
|
||||
:class="{ 'lang-pill--active': onlyEnglish }"
|
||||
@ -184,10 +172,8 @@ function getShiftLabel(shift: string) {
|
||||
:aria-checked="onlyEnglish"
|
||||
@click="onlyEnglish = !onlyEnglish"
|
||||
>
|
||||
<span class="material-icons notranslate" translate="no" aria-hidden="true">
|
||||
{{ onlyEnglish ? 'check_circle' : 'language' }}
|
||||
</span>
|
||||
<span>{{ t('taxi.englishSpeakers') }}</span>
|
||||
<span class="material-icons notranslate" translate="no" aria-hidden="true">language</span>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -321,10 +307,10 @@ function getShiftLabel(shift: string) {
|
||||
<style scoped>
|
||||
/* FILTERS */
|
||||
.filters-wrap {
|
||||
padding: 0 1rem 1rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
@ -336,7 +322,7 @@ function getShiftLabel(shift: string) {
|
||||
border-radius: 0.875rem;
|
||||
padding: 0 0.875rem;
|
||||
transition: border-color 0.18s ease;
|
||||
min-height: 48px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.search-bar:focus-within {
|
||||
@ -358,7 +344,7 @@ function getShiftLabel(shift: string) {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 0;
|
||||
padding: 0.625rem 0;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
@ -384,41 +370,34 @@ function getShiftLabel(shift: string) {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
.dropdowns-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.lang-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-primary);
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 38px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: 99px;
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
min-height: 40px;
|
||||
align-self: flex-start;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lang-pill--active {
|
||||
background: rgba(254, 231, 21, 0.12);
|
||||
background: rgba(254, 231, 21, 0.1);
|
||||
border-color: var(--active-color);
|
||||
color: var(--active-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.lang-pill:focus-visible {
|
||||
|
||||
@ -7,7 +7,7 @@ import AuthGuard from '@/components/common/AuthGuard.vue'
|
||||
import AppImage from '@/components/AppImage.vue'
|
||||
import { analyticsService } from '@/services/analyticsService'
|
||||
import ShuttleSkeletonCard from '@/components/transporte/ShuttleSkeletonCard.vue'
|
||||
import TransportFilterChips from '@/components/transporte/TransportFilterChips.vue'
|
||||
import TransportFilterSelect from '@/components/transporte/TransportFilterSelect.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const shuttleStore = useShuttleStore()
|
||||
@ -81,22 +81,14 @@ onUnmounted(() => {
|
||||
<div class="viajes-turisticos">
|
||||
<!-- FILTERS -->
|
||||
<div class="filters-wrap">
|
||||
<div class="filter-section">
|
||||
<span class="filter-label">{{ t('shuttle.category') }}</span>
|
||||
<TransportFilterChips
|
||||
v-model="shuttleCategoryFilter"
|
||||
:options="categoryOptions"
|
||||
:label="t('shuttle.category')"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<span class="filter-label">{{ t('shuttle.tripType') }}</span>
|
||||
<TransportFilterChips
|
||||
v-model="shuttleTypeFilter"
|
||||
:options="typeOptions"
|
||||
:label="t('shuttle.tripType')"
|
||||
/>
|
||||
</div>
|
||||
<TransportFilterSelect
|
||||
v-model="shuttleCategoryFilter"
|
||||
:options="categoryOptions"
|
||||
/>
|
||||
<TransportFilterSelect
|
||||
v-model="shuttleTypeFilter"
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- LOADING SKELETONS -->
|
||||
@ -197,24 +189,11 @@ onUnmounted(() => {
|
||||
|
||||
/* FILTERS */
|
||||
.filters-wrap {
|
||||
padding: 0 1rem 1rem;
|
||||
padding: 0 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* GRID — single column on mobile, 2-col on tablet, 3-col on desktop */
|
||||
|
||||
Reference in New Issue
Block a user