Some checks failed
Add defer attribute to 145 <script> tags across 103 template files (PERF-067) and loading="lazy" to 22 <img> tags across 13 template files (PERF-058). Both improve page load performance. Validator totals: 0 errors, 2 warnings, 1360 info (down from 1527). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
628 lines
28 KiB
HTML
628 lines
28 KiB
HTML
{#
|
|
Search Bar Components
|
|
=====================
|
|
Product search with autocomplete and suggestions for shop pages.
|
|
|
|
Usage:
|
|
{% from 'shared/macros/storefront/search-bar.html' import search_bar, search_autocomplete, mobile_search %}
|
|
#}
|
|
|
|
|
|
{#
|
|
Search Bar
|
|
==========
|
|
Basic search input with icon and optional button.
|
|
|
|
Parameters:
|
|
- placeholder: Placeholder text (default: 'Search products...')
|
|
- action: Form action URL (default: '/search')
|
|
- method: Form method (default: 'get')
|
|
- name: Input name (default: 'q')
|
|
- value: Initial value (default: '')
|
|
- show_button: Show search button (default: false)
|
|
- button_label: Button text (default: 'Search')
|
|
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
|
- variant: 'default' | 'filled' | 'minimal' (default: 'default')
|
|
|
|
Usage:
|
|
{{ search_bar(placeholder='Search for products...') }}
|
|
{{ search_bar(show_button=true, size='lg') }}
|
|
#}
|
|
{% macro search_bar(
|
|
placeholder='Search products...',
|
|
action='/search',
|
|
method='get',
|
|
name='q',
|
|
value='',
|
|
show_button=false,
|
|
button_label='Search',
|
|
size='md',
|
|
variant='default'
|
|
) %}
|
|
{% set sizes = {
|
|
'sm': {'input': 'py-1.5 pl-8 pr-3 text-sm', 'icon': 'w-4 h-4 left-2.5', 'button': 'px-3 py-1.5 text-sm'},
|
|
'md': {'input': 'py-2.5 pl-10 pr-4 text-sm', 'icon': 'w-5 h-5 left-3', 'button': 'px-4 py-2.5 text-sm'},
|
|
'lg': {'input': 'py-3 pl-12 pr-4 text-base', 'icon': 'w-6 h-6 left-3.5', 'button': 'px-5 py-3 text-base'}
|
|
} %}
|
|
{% set variants = {
|
|
'default': 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20',
|
|
'filled': 'bg-gray-100 dark:bg-gray-700 border border-transparent focus:bg-white dark:focus:bg-gray-800 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20',
|
|
'minimal': 'bg-transparent border-b border-gray-300 dark:border-gray-600 rounded-none focus:border-purple-500 dark:focus:border-purple-400'
|
|
} %}
|
|
<form action="{{ action }}" method="{{ method }}" class="relative flex items-center gap-2">
|
|
<div class="relative flex-1">
|
|
<span class="absolute top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 pointer-events-none">
|
|
<span x-html="$icon('search', 'w-full h-full')"></span>
|
|
</span>
|
|
<input
|
|
type="search"
|
|
name="{{ name }}"
|
|
value="{{ value }}"
|
|
placeholder="{{ placeholder }}"
|
|
class="w-full {{ sizes[size].input }} {{ variants[variant] }} {{ 'rounded-lg' if variant != 'minimal' else '' }} text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none transition-colors"
|
|
>
|
|
</div>
|
|
{% if show_button %}
|
|
<button
|
|
type="submit"
|
|
class="{{ sizes[size].button }} bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition-colors"
|
|
>
|
|
{{ button_label }}
|
|
</button>
|
|
{% endif %}
|
|
</form>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Search Autocomplete
|
|
===================
|
|
Search input with dropdown suggestions and autocomplete.
|
|
|
|
Parameters:
|
|
- placeholder: Placeholder text (default: 'Search products...')
|
|
- action: Form action URL (default: '/search')
|
|
- search_endpoint: API endpoint for suggestions (default: '/api/search/suggest')
|
|
- min_chars: Minimum characters to trigger search (default: 2)
|
|
- debounce: Debounce delay in ms (default: 300)
|
|
- show_recent: Show recent searches (default: true)
|
|
- show_popular: Show popular searches (default: true)
|
|
- max_suggestions: Maximum suggestions to show (default: 5)
|
|
- size: 'sm' | 'md' | 'lg' (default: 'md')
|
|
|
|
Usage:
|
|
{{ search_autocomplete(search_endpoint='/api/products/search') }}
|
|
#}
|
|
{% macro search_autocomplete(
|
|
placeholder='Search products...',
|
|
action='/search',
|
|
search_endpoint='/api/search/suggest',
|
|
min_chars=2,
|
|
debounce=300,
|
|
show_recent=true,
|
|
show_popular=true,
|
|
max_suggestions=5,
|
|
size='md'
|
|
) %}
|
|
{% set sizes = {
|
|
'sm': {'input': 'py-1.5 pl-8 pr-8 text-sm', 'icon': 'w-4 h-4', 'dropdown': 'mt-1'},
|
|
'md': {'input': 'py-2.5 pl-10 pr-10 text-sm', 'icon': 'w-5 h-5', 'dropdown': 'mt-2'},
|
|
'lg': {'input': 'py-3 pl-12 pr-12 text-base', 'icon': 'w-6 h-6', 'dropdown': 'mt-2'}
|
|
} %}
|
|
<div
|
|
x-data="{
|
|
query: '',
|
|
isOpen: false,
|
|
isLoading: false,
|
|
suggestions: [],
|
|
recentSearches: JSON.parse(localStorage.getItem('recentSearches') || '[]').slice(0, 5),
|
|
popularSearches: ['Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books'],
|
|
selectedIndex: -1,
|
|
|
|
async search() {
|
|
if (this.query.length < {{ min_chars }}) {
|
|
this.suggestions = [];
|
|
return;
|
|
}
|
|
this.isLoading = true;
|
|
try {
|
|
const response = await fetch('{{ search_endpoint }}?q=' + encodeURIComponent(this.query));
|
|
const data = await response.json();
|
|
this.suggestions = (data.suggestions || data.results || data).slice(0, {{ max_suggestions }});
|
|
} catch (e) {
|
|
this.suggestions = [];
|
|
}
|
|
this.isLoading = false;
|
|
},
|
|
|
|
selectSuggestion(suggestion) {
|
|
this.query = typeof suggestion === 'string' ? suggestion : suggestion.name;
|
|
this.saveRecent(this.query);
|
|
this.$refs.form.submit();
|
|
},
|
|
|
|
saveRecent(term) {
|
|
let recent = this.recentSearches.filter(s => s !== term);
|
|
recent.unshift(term);
|
|
recent = recent.slice(0, 5);
|
|
localStorage.setItem('recentSearches', JSON.stringify(recent));
|
|
this.recentSearches = recent;
|
|
},
|
|
|
|
clearRecent() {
|
|
localStorage.removeItem('recentSearches');
|
|
this.recentSearches = [];
|
|
},
|
|
|
|
handleKeydown(e) {
|
|
const items = this.getItems();
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
|
} else if (e.key === 'Enter' && this.selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
this.selectSuggestion(items[this.selectedIndex]);
|
|
} else if (e.key === 'Escape') {
|
|
this.isOpen = false;
|
|
}
|
|
},
|
|
|
|
getItems() {
|
|
if (this.query.length >= {{ min_chars }}) return this.suggestions;
|
|
return [...this.recentSearches, ...this.popularSearches.filter(p => !this.recentSearches.includes(p))];
|
|
}
|
|
}"
|
|
x-init="$watch('query', () => { selectedIndex = -1; })"
|
|
class="relative"
|
|
@click.away="isOpen = false"
|
|
>
|
|
<form x-ref="form" action="{{ action }}" method="get" @submit="saveRecent(query)">
|
|
<div class="relative">
|
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 pointer-events-none">
|
|
<span x-html="$icon('search', 'w-full h-full')"></span>
|
|
</span>
|
|
<input
|
|
type="search"
|
|
name="q"
|
|
x-model="query"
|
|
@input.debounce.{{ debounce }}ms="search()"
|
|
@focus="isOpen = true"
|
|
@keydown="handleKeydown($event)"
|
|
placeholder="{{ placeholder }}"
|
|
autocomplete="off"
|
|
class="w-full {{ sizes[size].input }} bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:border-purple-500 dark:focus:border-purple-400 focus:ring-2 focus:ring-purple-500/20 outline-none transition-colors"
|
|
>
|
|
<span
|
|
x-show="isLoading"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400"
|
|
>
|
|
<span x-html="$icon('refresh', 'w-full h-full animate-spin')"></span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
x-show="query.length > 0 && !isLoading"
|
|
@click="query = ''; suggestions = []; $refs.form.q.focus()"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 {{ sizes[size].icon }} text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<span x-html="$icon('x', 'w-full h-full')"></span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{# Dropdown #}
|
|
<div
|
|
x-show="isOpen && (query.length >= {{ min_chars }} ? suggestions.length > 0 : (recentSearches.length > 0 || {{ 'true' if show_popular else 'false' }}))"
|
|
x-transition:enter="transition ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
|
x-transition:enter-end="opacity-100 transform translate-y-0"
|
|
x-transition:leave="transition ease-in duration-75"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="absolute left-0 right-0 {{ sizes[size].dropdown }} bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
|
style="display: none;"
|
|
>
|
|
{# Search Suggestions #}
|
|
<template x-if="query.length >= {{ min_chars }}">
|
|
<ul class="py-2">
|
|
<template x-for="(suggestion, index) in suggestions" :key="index">
|
|
<li>
|
|
<button
|
|
type="button"
|
|
@click="selectSuggestion(suggestion)"
|
|
@mouseenter="selectedIndex = index"
|
|
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
|
:class="selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
|
>
|
|
<span x-html="$icon('search', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
|
<template x-if="typeof suggestion === 'string'">
|
|
<span class="text-gray-900 dark:text-white" x-text="suggestion"></span>
|
|
</template>
|
|
<template x-if="typeof suggestion === 'object'">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-gray-900 dark:text-white truncate" x-text="suggestion.name"></p>
|
|
<p x-show="suggestion.category" class="text-xs text-gray-500 dark:text-gray-400" x-text="'in ' + suggestion.category"></p>
|
|
</div>
|
|
</template>
|
|
<template x-if="typeof suggestion === 'object' && suggestion.image">
|
|
<img loading="lazy" :src="suggestion.image" :alt="suggestion.name" class="w-10 h-10 object-cover rounded">
|
|
</template>
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</template>
|
|
|
|
{# Recent & Popular Searches #}
|
|
<template x-if="query.length < {{ min_chars }}">
|
|
<div class="py-2">
|
|
{% if show_recent %}
|
|
<template x-if="recentSearches.length > 0">
|
|
<div>
|
|
<div class="flex items-center justify-between px-4 py-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recent Searches</span>
|
|
<button
|
|
type="button"
|
|
@click="clearRecent()"
|
|
class="text-xs text-purple-600 dark:text-purple-400 hover:underline"
|
|
>Clear</button>
|
|
</div>
|
|
<template x-for="(term, index) in recentSearches" :key="'recent-' + index">
|
|
<button
|
|
type="button"
|
|
@click="selectSuggestion(term)"
|
|
@mouseenter="selectedIndex = index"
|
|
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
|
:class="selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
|
>
|
|
<span x-html="$icon('clock', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
|
<span class="text-gray-900 dark:text-white" x-text="term"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
{% endif %}
|
|
|
|
{% if show_popular %}
|
|
<div :class="recentSearches.length > 0 && 'border-t border-gray-200 dark:border-gray-700 mt-2 pt-2'">
|
|
<div class="px-4 py-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Popular Searches</span>
|
|
</div>
|
|
<template x-for="(term, index) in popularSearches" :key="'popular-' + index">
|
|
<button
|
|
type="button"
|
|
@click="selectSuggestion(term)"
|
|
@mouseenter="selectedIndex = recentSearches.length + index"
|
|
class="w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors"
|
|
:class="selectedIndex === recentSearches.length + index ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'"
|
|
>
|
|
<span x-html="$icon('trending-up', 'w-4 h-4 text-gray-400 flex-shrink-0')"></span>
|
|
<span class="text-gray-900 dark:text-white" x-text="term"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Mobile Search
|
|
=============
|
|
Full-screen search overlay for mobile devices.
|
|
|
|
Parameters:
|
|
- show_var: Alpine.js variable for visibility (default: 'showMobileSearch')
|
|
- placeholder: Placeholder text (default: 'Search products...')
|
|
- action: Form action URL (default: '/search')
|
|
- search_endpoint: API endpoint for suggestions (default: '/api/search/suggest')
|
|
|
|
Usage:
|
|
{{ mobile_search(show_var='showSearch') }}
|
|
#}
|
|
{% macro mobile_search(
|
|
show_var='showMobileSearch',
|
|
placeholder='Search products...',
|
|
action='/search',
|
|
search_endpoint='/api/search/suggest'
|
|
) %}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="fixed inset-0 z-50 bg-white dark:bg-gray-900 lg:hidden"
|
|
style="display: none;"
|
|
x-data="{
|
|
query: '',
|
|
suggestions: [],
|
|
recentSearches: JSON.parse(localStorage.getItem('recentSearches') || '[]').slice(0, 5),
|
|
isLoading: false,
|
|
|
|
async search() {
|
|
if (this.query.length < 2) {
|
|
this.suggestions = [];
|
|
return;
|
|
}
|
|
this.isLoading = true;
|
|
try {
|
|
const response = await fetch('{{ search_endpoint }}?q=' + encodeURIComponent(this.query));
|
|
const data = await response.json();
|
|
this.suggestions = (data.suggestions || data.results || data).slice(0, 8);
|
|
} catch (e) {
|
|
this.suggestions = [];
|
|
}
|
|
this.isLoading = false;
|
|
},
|
|
|
|
selectSuggestion(suggestion) {
|
|
const term = typeof suggestion === 'string' ? suggestion : suggestion.name;
|
|
this.saveRecent(term);
|
|
window.location.href = '{{ action }}?q=' + encodeURIComponent(term);
|
|
},
|
|
|
|
saveRecent(term) {
|
|
let recent = this.recentSearches.filter(s => s !== term);
|
|
recent.unshift(term);
|
|
recent = recent.slice(0, 5);
|
|
localStorage.setItem('recentSearches', JSON.stringify(recent));
|
|
this.recentSearches = recent;
|
|
},
|
|
|
|
clearRecent() {
|
|
localStorage.removeItem('recentSearches');
|
|
this.recentSearches = [];
|
|
}
|
|
}"
|
|
>
|
|
{# Header #}
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
|
<button
|
|
type="button"
|
|
@click="{{ show_var }} = false"
|
|
class="p-2 -ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
>
|
|
<span x-html="$icon('arrow-left', 'w-5 h-5')"></span>
|
|
</button>
|
|
<form action="{{ action }}" method="get" class="flex-1" @submit="saveRecent(query)">
|
|
<div class="relative">
|
|
<input
|
|
type="search"
|
|
name="q"
|
|
x-model="query"
|
|
@input.debounce.300ms="search()"
|
|
placeholder="{{ placeholder }}"
|
|
autocomplete="off"
|
|
autofocus
|
|
class="w-full py-2 px-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none"
|
|
>
|
|
<button
|
|
type="button"
|
|
x-show="query.length > 0"
|
|
@click="query = ''; suggestions = []"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<span x-html="$icon('x', 'w-5 h-5')"></span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{# Content #}
|
|
<div class="overflow-y-auto h-[calc(100%-60px)]">
|
|
{# Loading State #}
|
|
<div x-show="isLoading" class="flex items-center justify-center py-8">
|
|
<span x-html="$icon('refresh', 'w-6 h-6 text-gray-400 animate-spin')"></span>
|
|
</div>
|
|
|
|
{# Search Results #}
|
|
<template x-if="!isLoading && query.length >= 2 && suggestions.length > 0">
|
|
<ul class="py-2">
|
|
<template x-for="(suggestion, index) in suggestions" :key="index">
|
|
<li>
|
|
<button
|
|
type="button"
|
|
@click="selectSuggestion(suggestion)"
|
|
class="w-full flex items-center gap-4 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
<template x-if="typeof suggestion === 'object' && suggestion.image">
|
|
<img loading="lazy" :src="suggestion.image" :alt="suggestion.name" class="w-12 h-12 object-cover rounded">
|
|
</template>
|
|
<template x-if="typeof suggestion === 'string' || !suggestion.image">
|
|
<span class="w-12 h-12 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
|
|
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
|
|
</span>
|
|
</template>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-gray-900 dark:text-white font-medium truncate" x-text="typeof suggestion === 'string' ? suggestion : suggestion.name"></p>
|
|
<template x-if="typeof suggestion === 'object'">
|
|
<p x-show="suggestion.category || suggestion.price" class="text-sm text-gray-500 dark:text-gray-400">
|
|
<span x-text="suggestion.category"></span>
|
|
<span x-show="suggestion.category && suggestion.price"> · </span>
|
|
<span x-show="suggestion.price" x-text="'$' + suggestion.price"></span>
|
|
</p>
|
|
</template>
|
|
</div>
|
|
<span x-html="$icon('arrow-right', 'w-5 h-5 text-gray-400')"></span>
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</template>
|
|
|
|
{# No Results #}
|
|
<template x-if="!isLoading && query.length >= 2 && suggestions.length === 0">
|
|
<div class="text-center py-8">
|
|
<span x-html="$icon('search', 'w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
|
<p class="mt-2 text-gray-500 dark:text-gray-400">No results found for "<span x-text="query"></span>"</p>
|
|
</div>
|
|
</template>
|
|
|
|
{# Recent Searches #}
|
|
<template x-if="query.length < 2 && recentSearches.length > 0">
|
|
<div class="py-4">
|
|
<div class="flex items-center justify-between px-4 mb-2">
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">Recent Searches</span>
|
|
<button
|
|
type="button"
|
|
@click="clearRecent()"
|
|
class="text-sm text-purple-600 dark:text-purple-400"
|
|
>Clear all</button>
|
|
</div>
|
|
<template x-for="(term, index) in recentSearches" :key="index">
|
|
<button
|
|
type="button"
|
|
@click="selectSuggestion(term)"
|
|
class="w-full flex items-center gap-4 px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
|
>
|
|
<span class="w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded-full">
|
|
<span x-html="$icon('clock', 'w-5 h-5 text-gray-400')"></span>
|
|
</span>
|
|
<span class="flex-1 text-gray-900 dark:text-white" x-text="term"></span>
|
|
<span x-html="$icon('arrow-right', 'w-5 h-5 text-gray-400')"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Search Trigger Button
|
|
====================
|
|
Button to open mobile search or focus desktop search.
|
|
|
|
Parameters:
|
|
- show_var: Alpine.js variable to toggle (default: 'showMobileSearch')
|
|
- sr_label: Screen reader label (default: 'Open search')
|
|
|
|
Usage:
|
|
{{ search_trigger(show_var='showSearch') }}
|
|
#}
|
|
{% macro search_trigger(
|
|
show_var='showMobileSearch',
|
|
sr_label='Open search'
|
|
) %}
|
|
<button
|
|
type="button"
|
|
@click="{{ show_var }} = true"
|
|
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:hidden"
|
|
>
|
|
<span x-html="$icon('search', 'w-5 h-5')"></span>
|
|
<span class="sr-only">{{ sr_label }}</span>
|
|
</button>
|
|
{% endmacro %}
|
|
|
|
|
|
{#
|
|
Instant Search Results
|
|
=====================
|
|
Inline search results component (for header search).
|
|
|
|
Parameters:
|
|
- results_var: Alpine.js expression for search results (default: 'searchResults')
|
|
- loading_var: Alpine.js expression for loading state (default: 'isSearching')
|
|
- query_var: Alpine.js expression for search query (default: 'searchQuery')
|
|
- show_var: Alpine.js expression for visibility (default: 'showResults')
|
|
|
|
Usage:
|
|
{{ instant_search_results(results_var='searchResults') }}
|
|
#}
|
|
{% macro instant_search_results(
|
|
results_var='searchResults',
|
|
loading_var='isSearching',
|
|
query_var='searchQuery',
|
|
show_var='showResults'
|
|
) %}
|
|
<div
|
|
x-show="{{ show_var }}"
|
|
x-transition:enter="transition ease-out duration-100"
|
|
x-transition:enter-start="opacity-0 transform -translate-y-2"
|
|
x-transition:enter-end="opacity-100 transform translate-y-0"
|
|
x-transition:leave="transition ease-in duration-75"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
class="absolute left-0 right-0 mt-2 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden z-50"
|
|
style="display: none;"
|
|
>
|
|
{# Loading #}
|
|
<div x-show="{{ loading_var }}" class="flex items-center justify-center py-6">
|
|
<span x-html="$icon('refresh', 'w-5 h-5 text-gray-400 animate-spin')"></span>
|
|
<span class="ml-2 text-sm text-gray-500">Searching...</span>
|
|
</div>
|
|
|
|
{# Results #}
|
|
<template x-if="!{{ loading_var }} && {{ results_var }}.length > 0">
|
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{# Products #}
|
|
<div class="py-2">
|
|
<div class="px-4 py-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Products</span>
|
|
</div>
|
|
<template x-for="product in {{ results_var }}.filter(r => r.type === 'product').slice(0, 4)" :key="product.id">
|
|
<a
|
|
:href="product.url"
|
|
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
>
|
|
<img loading="lazy" :src="product.image" :alt="product.name" class="w-10 h-10 object-cover rounded">
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm text-gray-900 dark:text-white truncate" x-text="product.name"></p>
|
|
<p class="text-sm text-purple-600 dark:text-purple-400 font-medium" x-text="'$' + product.price"></p>
|
|
</div>
|
|
</a>
|
|
</template>
|
|
</div>
|
|
|
|
{# Categories #}
|
|
<template x-if="{{ results_var }}.filter(r => r.type === 'category').length > 0">
|
|
<div class="py-2">
|
|
<div class="px-4 py-2">
|
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Categories</span>
|
|
</div>
|
|
<template x-for="category in {{ results_var }}.filter(r => r.type === 'category').slice(0, 3)" :key="category.id">
|
|
<a
|
|
:href="category.url"
|
|
class="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
|
>
|
|
<span class="w-10 h-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
|
|
<span x-html="$icon('folder', 'w-5 h-5 text-gray-400')"></span>
|
|
</span>
|
|
<span class="text-sm text-gray-900 dark:text-white" x-text="category.name"></span>
|
|
</a>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
{# View All Results #}
|
|
<div class="p-3 bg-gray-50 dark:bg-gray-700/50">
|
|
<a
|
|
:href="'/search?q=' + encodeURIComponent({{ query_var }})"
|
|
class="flex items-center justify-center gap-2 text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
|
>
|
|
View all results
|
|
<span x-html="$icon('arrow-right', 'w-4 h-4')"></span>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
{# No Results #}
|
|
<template x-if="!{{ loading_var }} && {{ results_var }}.length === 0 && {{ query_var }}.length >= 2">
|
|
<div class="py-8 text-center">
|
|
<span x-html="$icon('search', 'w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto')"></span>
|
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No results found</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
{% endmacro %}
|