Files
kashilo/js/components/search-box.js

1194 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { t, i18n } from '../i18n.js'
import { escapeHTML } from '../utils/helpers.js'
import { categoriesService } from '../services/categories.js'
const COUNTRIES = ['ch', 'de', 'at', 'fr', 'it', 'li']
const RADIUS_OPTIONS = [5, 10, 20, 50, 100, 200]
class SearchBox extends HTMLElement {
static get observedAttributes() {
return ['category', 'subcategory', 'country', 'query']
}
constructor() {
super()
this.loadFiltersFromStorage()
}
loadFiltersFromStorage() {
const saved = localStorage.getItem('searchFilters')
if (saved) {
try {
const filters = JSON.parse(saved)
this.selectedCategory = filters.category || ''
this.selectedSubcategory = filters.subcategory || ''
this.selectedCountry = filters.country || 'ch'
this.selectedRadius = filters.radius || 50
this.useCurrentLocation = filters.useCurrentLocation || false
this.searchQuery = filters.query || ''
} catch (e) {
this.resetFilters()
}
} else {
this.resetFilters()
}
}
resetFilters() {
this.selectedCategory = ''
this.selectedSubcategory = ''
this.selectedCountry = 'ch'
this.selectedRadius = 50
this.useCurrentLocation = false
this.searchQuery = ''
this.categoryTree = []
this.geoLoading = false
this.currentLat = null
this.currentLng = null
}
saveFiltersToStorage() {
const filters = {
category: this.selectedCategory,
subcategory: this.selectedSubcategory,
country: this.selectedCountry,
radius: this.selectedRadius,
useCurrentLocation: this.useCurrentLocation,
query: this.searchQuery
}
localStorage.setItem('searchFilters', JSON.stringify(filters))
}
connectedCallback() {
// Override from attributes if provided
if (this.hasAttribute('category')) {
this.selectedCategory = this.getAttribute('category')
}
if (this.hasAttribute('subcategory')) {
this.selectedSubcategory = this.getAttribute('subcategory')
}
if (this.hasAttribute('country')) {
this.selectedCountry = this.getAttribute('country')
}
if (this.hasAttribute('query')) {
this.searchQuery = this.getAttribute('query')
}
this.loadAndRender()
this.unsubscribe = i18n.subscribe(() => this.loadAndRender())
}
async loadAndRender() {
try {
this.categoryTree = await categoriesService.getTree()
} catch (e) {
this.categoryTree = []
}
this.render()
this.setupEventListeners()
}
disconnectedCallback() {
if (this.unsubscribe) this.unsubscribe()
if (this._closeDropdown) {
document.removeEventListener('click', this._closeDropdown)
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return
switch (name) {
case 'category':
this.selectedCategory = newValue || ''
break
case 'subcategory':
this.selectedSubcategory = newValue || ''
break
case 'country':
this.selectedCountry = newValue || 'ch'
break
case 'query':
this.searchQuery = newValue || ''
break
}
if (this.isConnected) {
this.render()
this.setupEventListeners()
}
}
render() {
this.innerHTML = /* html */`
<form class="search-box" id="search-form">
<div class="search-row search-row-query">
<div class="search-field search-field-query">
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="text" id="search-query" placeholder="${t('header.searchPlaceholder')}" value="${this.searchQuery}" />
</div>
</div>
${this.renderFilters()}
<div class="search-row search-row-submit">
<button type="submit" class="btn btn-primary btn-search">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<span class="btn-search-text">${t('search.searchButton')}</span>
</button>
</div>
</form>
${this.renderFilterBadges()}
`
}
findCategoryBySlug(tree, slug) {
for (const cat of tree) {
if (cat.slug === slug) return cat
if (cat.children?.length) {
const found = this.findCategoryBySlug(cat.children, slug)
if (found) return found
}
}
return null
}
getCategoryLabel() {
const cat = this.findCategoryBySlug(this.categoryTree, this.selectedCategory)
if (!cat) return this.selectedCategory
const catName = categoriesService.getTranslatedName(cat)
if (this.selectedSubcategory) {
const sub = this.findCategoryBySlug(cat.children || [], this.selectedSubcategory)
if (sub) return `${catName} ${categoriesService.getTranslatedName(sub)}`
}
return catName
}
hasActiveFilters() {
return this.searchQuery || this.selectedCategory || this.useCurrentLocation
}
getActiveFilterCount() {
let count = 0
if (this.searchQuery) count++
if (this.selectedCategory) count++
if (this.useCurrentLocation) count++
return count
}
renderFilterBadges() {
if (!this.hasActiveFilters()) return ''
const badges = []
// Query badge
if (this.searchQuery) {
badges.push(/* html */`
<button type="button" class="filter-badge" data-filter="query">
<span class="filter-badge-text">"${escapeHTML(this.searchQuery)}"</span>
<svg class="filter-badge-close" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`)
}
// Category badge
if (this.selectedCategory) {
const categoryLabel = this.getCategoryLabel()
badges.push(/* html */`
<button type="button" class="filter-badge" data-filter="category">
<span class="filter-badge-text">${escapeHTML(categoryLabel)}</span>
<svg class="filter-badge-close" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`)
}
// Location badge (only when using current location with radius)
if (this.useCurrentLocation) {
const radiusText = t('search.radiusAround', { radius: this.selectedRadius })
badges.push(/* html */`
<button type="button" class="filter-badge" data-filter="location">
<span class="filter-badge-text">📍 ${radiusText}</span>
<svg class="filter-badge-close" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`)
}
// Only show "clear all" if more than one filter is active
const showClearAll = this.getActiveFilterCount() > 1
return /* html */`
<div class="filter-badges">
${badges.join('')}
${showClearAll ? `
<button type="button" class="filter-badge filter-badge-clear" data-filter="all">
<span class="filter-badge-text">${t('search.clearAll')}</span>
<svg class="filter-badge-close" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
` : ''}
</div>
`
}
renderFilters() {
// Track which category accordion is expanded
this._expandedCategory = this._expandedCategory || ''
return /* html */`
<!-- Accordion Category Dropdown -->
<div class="search-row search-row-filters">
<div class="category-dropdown">
<button type="button" class="category-dropdown-trigger" id="category-trigger">
<span class="category-dropdown-label">
${this.selectedCategory
? this.getCategoryLabel()
: t('search.allCategories')}
</span>
<svg class="category-dropdown-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class="category-menu" id="category-menu">
<div class="category-menu-inner">
<button type="button" class="category-item category-item--all ${!this.selectedCategory ? 'active' : ''}" data-category="" data-subcategory="">
${t('search.allCategories')}
</button>
${(this.categoryTree || []).map(cat => `
<div class="category-accordion ${this._expandedCategory === cat.slug ? 'expanded' : ''}">
<button type="button" class="category-item ${this.selectedCategory === cat.slug ? 'active' : ''}" data-category="${cat.slug}">
<span>${categoriesService.getTranslatedName(cat)}</span>
<svg class="category-item-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div class="subcategory-list">
<button type="button" class="subcategory-item ${this.selectedCategory === cat.slug && !this.selectedSubcategory ? 'active' : ''}" data-category="${cat.slug}" data-subcategory="">
${t('search.allIn')} ${categoriesService.getTranslatedName(cat)}
</button>
${(cat.children || []).map(sub => `
<button type="button" class="subcategory-item ${this.selectedCategory === cat.slug && this.selectedSubcategory === sub.slug ? 'active' : ''}" data-category="${cat.slug}" data-subcategory="${sub.slug}">
${categoriesService.getTranslatedName(sub)}
</button>
`).join('')}
</div>
</div>
`).join('')}
</div>
</div>
</div>
<!-- Location (inline on desktop) -->
<div class="filter-location">
<div class="search-field search-field-country">
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<select id="country-select">
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
📍 ${t('search.currentLocation')}
</option>
${COUNTRIES.map(c => `
<option value="${c}" ${!this.useCurrentLocation && this.selectedCountry === c ? 'selected' : ''}>
${t(`countries.${c}`)}
</option>
`).join('')}
</select>
</div>
</div>
<!-- Radius (inline on desktop) -->
<div class="filter-radius ${!this.useCurrentLocation ? 'hidden' : ''}">
<div class="search-field search-field-radius">
<select id="radius-select">
${RADIUS_OPTIONS.map(r => `
<option value="${r}" ${this.selectedRadius === r ? 'selected' : ''}>
${r} km
</option>
`).join('')}
</select>
</div>
</div>
</div>
<!-- Mobile only: Location row -->
<div class="search-row search-row-location mobile-only">
<div class="search-field search-field-country">
<svg class="field-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<label for="country-select-mobile" class="sr-only">${t('search.currentLocation')}</label>
<select id="country-select-mobile">
<option value="current" ${this.useCurrentLocation ? 'selected' : ''}>
📍 ${t('search.currentLocation')}
</option>
${COUNTRIES.map(c => `
<option value="${c}" ${!this.useCurrentLocation && this.selectedCountry === c ? 'selected' : ''}>
${t(`countries.${c}`)}
</option>
`).join('')}
</select>
</div>
</div>
<!-- Mobile only: Radius row -->
<div class="search-row search-row-radius mobile-only ${!this.useCurrentLocation ? 'hidden' : ''}">
<div class="search-field search-field-radius">
<select id="radius-select-mobile">
${RADIUS_OPTIONS.map(r => `
<option value="${r}" ${this.selectedRadius === r ? 'selected' : ''}>
${r} km
</option>
`).join('')}
</select>
</div>
</div>
`
}
setupEventListeners() {
const form = this.querySelector('#search-form')
const queryInput = this.querySelector('#search-query')
// Desktop selects
const countrySelect = this.querySelector('#country-select')
const radiusSelect = this.querySelector('#radius-select')
// Mobile selects
const countrySelectMobile = this.querySelector('#country-select-mobile')
const radiusSelectMobile = this.querySelector('#radius-select-mobile')
// Accordion dropdown
const categoryTrigger = this.querySelector('#category-trigger')
const categoryMenu = this.querySelector('#category-menu')
form?.addEventListener('submit', (e) => {
e.preventDefault()
this.handleSearch()
})
queryInput?.addEventListener('input', (e) => {
this.searchQuery = e.target.value
})
// Toggle dropdown
if (categoryTrigger && categoryMenu) {
categoryTrigger.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
categoryMenu.classList.toggle('open')
})
}
// Close dropdown on outside click
this._closeDropdown = (e) => {
if (!this.contains(e.target)) {
categoryMenu?.classList.remove('open')
}
}
document.addEventListener('click', this._closeDropdown)
// Category accordion headers - toggle expand
this.querySelectorAll('.category-accordion > .category-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
const cat = item.dataset.category
const accordion = item.closest('.category-accordion')
// Toggle this accordion
if (this._expandedCategory === cat) {
this._expandedCategory = ''
accordion?.classList.remove('expanded')
} else {
// Close other accordions
this.querySelectorAll('.category-accordion.expanded').forEach(el => {
el.classList.remove('expanded')
})
this._expandedCategory = cat
accordion?.classList.add('expanded')
}
})
})
// "All categories" button
this.querySelector('.category-item--all')?.addEventListener('click', (e) => {
e.stopPropagation()
this.selectedCategory = ''
this.selectedSubcategory = ''
this._expandedCategory = ''
categoryMenu?.classList.remove('open')
this.emitFilterChange()
this.render()
this.setupEventListeners()
})
// Subcategory items - select category + subcategory
this.querySelectorAll('.subcategory-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation()
this.selectedCategory = item.dataset.category
this.selectedSubcategory = item.dataset.subcategory
this._expandedCategory = ''
categoryMenu?.classList.remove('open')
this.emitFilterChange()
this.render()
this.setupEventListeners()
})
})
// Country select handler (both desktop and mobile)
const handleCountryChange = (e) => {
if (e.target.value === 'current') {
this.useCurrentLocation = true
this.requestGeolocation()
} else {
this.useCurrentLocation = false
this.selectedCountry = e.target.value
}
this.emitFilterChange()
this.render()
this.setupEventListeners()
}
countrySelect?.addEventListener('change', handleCountryChange)
countrySelectMobile?.addEventListener('change', handleCountryChange)
// Radius select handler (both desktop and mobile)
const handleRadiusChange = (e) => {
this.selectedRadius = parseInt(e.target.value)
this.emitFilterChange()
this.updateFilterBadges()
}
radiusSelect?.addEventListener('change', handleRadiusChange)
radiusSelectMobile?.addEventListener('change', handleRadiusChange)
// Filter badge click handlers
this.querySelectorAll('.filter-badge').forEach(badge => {
badge.addEventListener('click', (e) => {
e.preventDefault()
const filterType = badge.dataset.filter
this.removeFilter(filterType)
})
})
// Adjust select width to selected option (desktop only)
this.adjustSelectWidth(countrySelect)
this.adjustSelectWidth(radiusSelect)
}
updateFilterBadges() {
const badgesContainer = this.querySelector('.filter-badges')
if (badgesContainer) {
badgesContainer.outerHTML = this.renderFilterBadges()
// Re-attach event listeners for new badges
this.querySelectorAll('.filter-badge').forEach(badge => {
badge.addEventListener('click', (e) => {
e.preventDefault()
const filterType = badge.dataset.filter
this.removeFilter(filterType)
})
})
}
}
removeFilter(filterType) {
switch (filterType) {
case 'query':
this.searchQuery = ''
break
case 'category':
this.selectedCategory = ''
this.selectedSubcategory = ''
break
case 'location':
this.useCurrentLocation = false
this.currentLat = null
this.currentLng = null
break
case 'all':
this.searchQuery = ''
this.selectedCategory = ''
this.selectedSubcategory = ''
this.useCurrentLocation = false
this.currentLat = null
this.currentLng = null
break
}
this.saveFiltersToStorage()
this.emitFilterChange()
this.render()
this.setupEventListeners()
}
adjustSelectWidth(select) {
if (!select) return
// Only apply fixed width on desktop (768px+)
if (window.innerWidth < 768) {
select.style.width = ''
return
}
// Create hidden span to measure text width
const measurer = document.createElement('span')
measurer.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;'
select.parentElement.appendChild(measurer)
const selectedOption = select.options[select.selectedIndex]
measurer.textContent = selectedOption ? selectedOption.textContent : ''
// Add padding for arrow, icon and buffer
select.style.width = (measurer.offsetWidth + 90) + 'px'
measurer.remove()
}
getFilterDetails() {
return {
query: this.searchQuery,
category: this.selectedCategory,
subcategory: this.selectedSubcategory,
country: this.selectedCountry,
useCurrentLocation: this.useCurrentLocation,
lat: this.currentLat,
lng: this.currentLng,
radius: this.selectedRadius
}
}
emitFilterChange() {
this.saveFiltersToStorage()
this.dispatchEvent(new CustomEvent('filter-change', {
bubbles: true,
detail: this.getFilterDetails()
}))
}
handleSearch() {
const params = new URLSearchParams()
if (this.searchQuery) params.set('q', this.searchQuery)
if (this.selectedCategory) params.set('category', this.selectedCategory)
if (this.selectedSubcategory) params.set('sub', this.selectedSubcategory)
if (this.useCurrentLocation && this.currentLat && this.currentLng) {
params.set('lat', this.currentLat)
params.set('lng', this.currentLng)
params.set('radius', this.selectedRadius)
} else if (!this.useCurrentLocation) {
params.set('country', this.selectedCountry)
}
this.saveFiltersToStorage()
// Emit custom event
const event = new CustomEvent('search', {
bubbles: true,
cancelable: true,
detail: {
...this.getFilterDetails(),
params: params.toString()
}
})
const cancelled = !this.dispatchEvent(event)
// Navigate to home page with params unless event was cancelled
if (!cancelled && !this.hasAttribute('no-navigate')) {
const url = '#/' + (params.toString() ? '?' + params.toString() : '')
window.location.hash = url
}
}
requestGeolocation() {
if (!('geolocation' in navigator)) {
this.handleGeoError()
return
}
this.geoLoading = true
this.updateGeoButton()
navigator.geolocation.getCurrentPosition(
(position) => {
this.currentLat = position.coords.latitude
this.currentLng = position.coords.longitude
this.geoLoading = false
this.updateGeoButton()
this.emitFilterChange()
},
(error) => {
console.warn('Geolocation error:', error)
this.handleGeoError()
},
{ timeout: 10000, enableHighAccuracy: false }
)
}
handleGeoError() {
// Try IP-based geolocation as fallback
this.fetchIpLocation()
}
async fetchIpLocation() {
try {
const response = await fetch('https://ipapi.co/json/')
if (response.ok) {
const data = await response.json()
if (data.latitude && data.longitude) {
this.currentLat = data.latitude
this.currentLng = data.longitude
console.log('IP-based location:', data.city, data.country_code)
}
}
} catch (e) {
console.warn('IP geolocation failed:', e)
}
this.geoLoading = false
this.updateGeoButton()
this.emitFilterChange()
}
updateGeoButton() {
const countrySelect = this.querySelector('#country-select')
if (!countrySelect) return
if (this.geoLoading) {
countrySelect.disabled = true
const currentOption = countrySelect.querySelector('option[value="current"]')
if (currentOption) {
currentOption.textContent = `${t('search.locating')}`
}
} else {
countrySelect.disabled = false
const currentOption = countrySelect.querySelector('option[value="current"]')
if (currentOption) {
currentOption.textContent = `📍 ${t('search.currentLocation')}`
}
}
}
// Public API
getFilters() {
return {
query: this.searchQuery,
category: this.selectedCategory,
subcategory: this.selectedSubcategory,
country: this.selectedCountry,
useCurrentLocation: this.useCurrentLocation,
lat: this.currentLat,
lng: this.currentLng,
radius: this.selectedRadius
}
}
setFilters(filters) {
if (filters.query !== undefined) this.searchQuery = filters.query
if (filters.category !== undefined) this.selectedCategory = filters.category
if (filters.subcategory !== undefined) this.selectedSubcategory = filters.subcategory
if (filters.country !== undefined) this.selectedCountry = filters.country
if (filters.radius !== undefined) this.selectedRadius = filters.radius
if (filters.useCurrentLocation !== undefined) this.useCurrentLocation = filters.useCurrentLocation
this.saveFiltersToStorage()
if (this.categoryTree && this.categoryTree.length > 0) {
this.render()
this.setupEventListeners()
}
}
clearFilters() {
this.resetFilters()
localStorage.removeItem('searchFilters')
this.loadAndRender()
}
}
customElements.define('search-box', SearchBox)
const style = document.createElement('style')
style.textContent = /* css */`
search-box {
display: block;
}
search-box .search-box {
width: 100%;
background: var(--color-bg);
border: 2px solid var(--color-border);
border-radius: var(--radius-lg);
box-sizing: border-box;
}
search-box .search-row {
display: flex;
border-bottom: 1px solid var(--color-border);
}
search-box .search-row:last-child {
border-bottom: none;
}
search-box .search-field {
flex: 1;
display: flex;
align-items: center;
position: relative;
}
search-box .search-field + .search-field {
border-left: 1px solid var(--color-border);
}
search-box .search-field.hidden,
search-box .search-row.hidden {
display: none;
}
search-box .field-icon {
position: absolute;
left: var(--space-md);
color: var(--color-text-muted);
pointer-events: none;
}
search-box .search-field input,
search-box .search-field select {
width: 100%;
padding: var(--space-md);
padding-left: 2.75rem;
border: none;
background: transparent;
font-size: var(--font-size-base);
color: var(--color-text);
}
search-box .search-field select {
padding-left: var(--space-md);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B5B95' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--space-md) center;
padding-right: 2.5rem;
}
search-box .search-field-country select {
padding-left: 2.75rem;
}
search-box .search-field input:focus,
search-box .search-field select:focus {
background: var(--color-bg-secondary);
}
search-box .search-field input:focus-visible,
search-box .search-field select:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
/* Mobile: Search query field top corners */
search-box .search-field-query input {
border-radius: calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px) 0 0;
}
search-box .search-field-query input:focus {
border-radius: calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px) 0 0;
}
search-box .search-field input::placeholder {
color: var(--color-text-muted);
}
search-box .search-row-submit {
padding: var(--space-sm);
background: var(--color-bg-secondary);
border-radius: 0 0 calc(var(--radius-lg) - 2px) calc(var(--radius-lg) - 2px);
}
search-box .btn-search {
width: 100%;
padding: var(--space-md);
gap: var(--space-sm);
}
/* Mobile: full width for location/radius */
search-box .search-row-location .search-field,
search-box .search-row-radius .search-field {
flex: 1;
}
search-box .search-row-location .search-field select,
search-box .search-row-radius .search-field select {
width: 100%;
}
/* Mobile: hide desktop-only filter elements */
search-box .filter-location,
search-box .filter-radius {
display: none;
}
/* Desktop: two-row layout */
@media (min-width: 768px) {
/* Hide mobile-only rows on desktop */
search-box .mobile-only {
display: none !important;
}
/* Show inline filters on desktop */
search-box .filter-location,
search-box .filter-radius {
display: flex;
border-left: 1px solid var(--color-border);
}
search-box .filter-radius.hidden {
display: none;
}
search-box .filter-location .search-field,
search-box .filter-radius .search-field {
flex: 0 0 auto;
}
search-box .filter-location select,
search-box .filter-radius select {
width: auto;
}
search-box .search-box {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
border-radius: var(--radius-xl);
}
search-box .search-box .search-row {
border-bottom: none;
}
/* Row 1: Search field + Button */
search-box .search-box .search-row-query {
grid-column: 1;
grid-row: 1;
border-right: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
}
search-box .search-box .search-row-submit {
grid-column: 2;
grid-row: 1;
padding: var(--space-xs);
background: transparent;
border-bottom: 1px solid var(--color-border);
}
/* Row 2: Filters */
search-box .search-box .search-row-filters {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
border-right: none;
}
search-box .search-box .btn-search {
width: auto;
padding: var(--space-md);
border-radius: var(--radius-md);
}
/* Desktop: Submit row - top-right corner inherits from parent */
search-box .search-box .search-row-submit {
border-radius: 0 calc(var(--radius-xl) - 2px) 0 0;
}
search-box .search-box .btn-search-text {
display: none;
}
/* Category truncation */
search-box .search-box .category-dropdown-label {
max-width: 250px;
}
/* Desktop: Search query field - only top-left rounded */
search-box .search-field-query input {
border-radius: calc(var(--radius-xl) - 2px) 0 0 0;
}
/* Desktop: Category dropdown trigger - bottom-left rounded on hover/focus */
search-box .category-dropdown-trigger:hover,
search-box .category-dropdown-trigger:focus {
border-radius: 0 0 0 calc(var(--radius-xl) - 2px);
}
/* Desktop: Last filter (location/radius) - bottom-right rounded */
search-box .filter-location:last-child select,
search-box .filter-radius:not(.hidden):last-child select {
border-radius: 0 0 calc(var(--radius-xl) - 2px) 0;
}
search-box .filter-location:last-child select:hover,
search-box .filter-location:last-child select:focus,
search-box .filter-radius:not(.hidden):last-child select:hover,
search-box .filter-radius:not(.hidden):last-child select:focus {
background: var(--color-bg-secondary);
border-radius: 0 0 calc(var(--radius-xl) - 2px) 0;
}
}
/* Category Dropdown (Desktop) */
search-box .category-dropdown {
position: relative;
flex: 1;
min-width: 0;
}
search-box .category-dropdown-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
background: transparent;
border: none;
font-size: var(--font-size-base);
color: var(--color-text);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
search-box .category-dropdown-trigger:hover {
background: var(--color-bg-secondary);
}
search-box .category-dropdown-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
search-box .category-dropdown-arrow {
flex-shrink: 0;
margin-left: var(--space-sm);
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
search-box .category-menu.open + .category-dropdown-arrow,
search-box .category-dropdown-trigger:focus .category-dropdown-arrow {
transform: rotate(180deg);
}
search-box .category-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all var(--transition-fast);
z-index: var(--z-dropdown);
max-width: calc(100vw - 2 * var(--space-md));
}
search-box .category-menu.open {
opacity: 1;
visibility: visible;
transform: translateY(4px);
}
search-box .category-menu-inner {
padding: var(--space-xs);
max-height: 400px;
overflow-y: auto;
}
search-box .category-item,
search-box .category-item--all {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
color: var(--color-text);
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
search-box .category-item span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
search-box .category-item:hover,
search-box .category-item--all:hover {
background: var(--color-bg-secondary);
}
search-box .category-item.active,
search-box .category-item--all.active {
background: var(--color-primary-light);
color: var(--color-primary);
}
search-box .category-item-arrow {
flex-shrink: 0;
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
/* Accordion */
search-box .category-accordion {
border-top: 1px solid var(--color-border);
}
search-box .category-accordion:first-of-type {
border-top: none;
}
search-box .category-accordion .category-item-arrow {
transform: rotate(0deg);
}
search-box .category-accordion.expanded .category-item-arrow {
transform: rotate(180deg);
}
search-box .subcategory-list {
display: none;
padding: 0 var(--space-xs) var(--space-xs);
}
search-box .category-accordion.expanded .subcategory-list {
display: block;
}
search-box .subcategory-item {
width: 100%;
display: block;
padding: var(--space-sm) var(--space-md);
padding-left: var(--space-xl);
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: background var(--transition-fast), color var(--transition-fast);
}
search-box .subcategory-item:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
search-box .subcategory-item.active {
background: var(--color-primary-light);
color: var(--color-primary);
}
/* Filter Badges */
search-box .filter-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-sm);
justify-content: center;
}
search-box .filter-badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
color: var(--color-text);
cursor: pointer;
transition: all var(--transition-fast);
}
search-box .filter-badge:hover {
background: var(--color-bg-tertiary);
border-color: var(--color-text-muted);
}
search-box .filter-badge-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
search-box .filter-badge-close {
flex-shrink: 0;
color: var(--color-text-muted);
transition: color var(--transition-fast);
}
search-box .filter-badge:hover .filter-badge-close {
color: var(--color-text);
}
search-box .filter-badge-clear {
background: transparent;
border-style: dashed;
color: var(--color-text-muted);
}
search-box .filter-badge-clear:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
`
document.head.appendChild(style)
export { SearchBox, COUNTRIES, RADIUS_OPTIONS }