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:
2026-04-04 17:59:40 -05:00
parent 5c1b62f55a
commit dc007b24ce
3 changed files with 244 additions and 78 deletions

View 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>

View File

@ -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 {

View File

@ -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 */