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 AppImage from '@/components/AppImage.vue'
|
||||||
import AuthGuard from '@/components/common/AuthGuard.vue'
|
import AuthGuard from '@/components/common/AuthGuard.vue'
|
||||||
import TaxiSkeletonCard from '@/components/transporte/TaxiSkeletonCard.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 { t } = useI18n()
|
||||||
const taxiStore = useTaxiStore()
|
const taxiStore = useTaxiStore()
|
||||||
@ -141,9 +141,9 @@ function getShiftLabel(shift: string) {
|
|||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="t('taxi.allZones') + '...'"
|
placeholder="Buscar conductor..."
|
||||||
class="search-input"
|
class="search-input"
|
||||||
:aria-label="'Buscar conductor'"
|
aria-label="Buscar conductor"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="searchQuery"
|
v-if="searchQuery"
|
||||||
@ -155,28 +155,16 @@ function getShiftLabel(shift: string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone chips -->
|
<!-- Dropdowns row -->
|
||||||
<div class="filter-section">
|
<div class="dropdowns-row">
|
||||||
<span class="filter-label">{{ t('taxi.area') }}</span>
|
<TransportFilterSelect
|
||||||
<TransportFilterChips
|
|
||||||
v-model="selectedZone"
|
v-model="selectedZone"
|
||||||
:options="zoneOptions"
|
:options="zoneOptions"
|
||||||
:label="t('taxi.area')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<TransportFilterSelect
|
||||||
|
|
||||||
<!-- Shift chips -->
|
|
||||||
<div class="filter-section">
|
|
||||||
<span class="filter-label">{{ t('taxi.shift') }}</span>
|
|
||||||
<TransportFilterChips
|
|
||||||
v-model="selectedShift"
|
v-model="selectedShift"
|
||||||
:options="shiftOptions"
|
:options="shiftOptions"
|
||||||
:label="t('taxi.shift')"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- English toggle -->
|
|
||||||
<div class="filter-section">
|
|
||||||
<button
|
<button
|
||||||
class="lang-pill"
|
class="lang-pill"
|
||||||
:class="{ 'lang-pill--active': onlyEnglish }"
|
:class="{ 'lang-pill--active': onlyEnglish }"
|
||||||
@ -184,10 +172,8 @@ function getShiftLabel(shift: string) {
|
|||||||
:aria-checked="onlyEnglish"
|
:aria-checked="onlyEnglish"
|
||||||
@click="onlyEnglish = !onlyEnglish"
|
@click="onlyEnglish = !onlyEnglish"
|
||||||
>
|
>
|
||||||
<span class="material-icons notranslate" translate="no" aria-hidden="true">
|
<span class="material-icons notranslate" translate="no" aria-hidden="true">language</span>
|
||||||
{{ onlyEnglish ? 'check_circle' : 'language' }}
|
EN
|
||||||
</span>
|
|
||||||
<span>{{ t('taxi.englishSpeakers') }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -321,10 +307,10 @@ function getShiftLabel(shift: string) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/* FILTERS */
|
/* FILTERS */
|
||||||
.filters-wrap {
|
.filters-wrap {
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.875rem;
|
gap: 0.625rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar {
|
.search-bar {
|
||||||
@ -336,7 +322,7 @@ function getShiftLabel(shift: string) {
|
|||||||
border-radius: 0.875rem;
|
border-radius: 0.875rem;
|
||||||
padding: 0 0.875rem;
|
padding: 0 0.875rem;
|
||||||
transition: border-color 0.18s ease;
|
transition: border-color 0.18s ease;
|
||||||
min-height: 48px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-bar:focus-within {
|
.search-bar:focus-within {
|
||||||
@ -358,7 +344,7 @@ function getShiftLabel(shift: string) {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.75rem 0;
|
padding: 0.625rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input::placeholder {
|
.search-input::placeholder {
|
||||||
@ -384,41 +370,34 @@ function getShiftLabel(shift: string) {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-section {
|
.dropdowns-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-pill {
|
.lang-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.25rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0 0.75rem;
|
||||||
background: var(--bg-primary);
|
height: 38px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
border: 1.5px solid var(--border-color);
|
border: 1.5px solid var(--border-color);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.18s ease;
|
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
|
||||||
min-height: 40px;
|
white-space: nowrap;
|
||||||
align-self: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-pill--active {
|
.lang-pill--active {
|
||||||
background: rgba(254, 231, 21, 0.12);
|
background: rgba(254, 231, 21, 0.1);
|
||||||
border-color: var(--active-color);
|
border-color: var(--active-color);
|
||||||
color: var(--active-color);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-pill:focus-visible {
|
.lang-pill:focus-visible {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import AuthGuard from '@/components/common/AuthGuard.vue'
|
|||||||
import AppImage from '@/components/AppImage.vue'
|
import AppImage from '@/components/AppImage.vue'
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
import { analyticsService } from '@/services/analyticsService'
|
||||||
import ShuttleSkeletonCard from '@/components/transporte/ShuttleSkeletonCard.vue'
|
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 { t } = useI18n()
|
||||||
const shuttleStore = useShuttleStore()
|
const shuttleStore = useShuttleStore()
|
||||||
@ -81,22 +81,14 @@ onUnmounted(() => {
|
|||||||
<div class="viajes-turisticos">
|
<div class="viajes-turisticos">
|
||||||
<!-- FILTERS -->
|
<!-- FILTERS -->
|
||||||
<div class="filters-wrap">
|
<div class="filters-wrap">
|
||||||
<div class="filter-section">
|
<TransportFilterSelect
|
||||||
<span class="filter-label">{{ t('shuttle.category') }}</span>
|
v-model="shuttleCategoryFilter"
|
||||||
<TransportFilterChips
|
:options="categoryOptions"
|
||||||
v-model="shuttleCategoryFilter"
|
/>
|
||||||
:options="categoryOptions"
|
<TransportFilterSelect
|
||||||
:label="t('shuttle.category')"
|
v-model="shuttleTypeFilter"
|
||||||
/>
|
:options="typeOptions"
|
||||||
</div>
|
/>
|
||||||
<div class="filter-section">
|
|
||||||
<span class="filter-label">{{ t('shuttle.tripType') }}</span>
|
|
||||||
<TransportFilterChips
|
|
||||||
v-model="shuttleTypeFilter"
|
|
||||||
:options="typeOptions"
|
|
||||||
:label="t('shuttle.tripType')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LOADING SKELETONS -->
|
<!-- LOADING SKELETONS -->
|
||||||
@ -197,24 +189,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* FILTERS */
|
/* FILTERS */
|
||||||
.filters-wrap {
|
.filters-wrap {
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GRID — single column on mobile, 2-col on tablet, 3-col on desktop */
|
/* GRID — single column on mobile, 2-col on tablet, 3-col on desktop */
|
||||||
|
|||||||
Reference in New Issue
Block a user