Add autocomplete functionality for parks: implement BaseAutocomplete class and integrate with forms

This commit is contained in:
pacnpal
2025-02-22 13:36:24 -05:00
parent 5278ad39d0
commit 4339c5c5e0
15 changed files with 633 additions and 130 deletions

View File

@@ -47,50 +47,25 @@
{% block filter_section %}
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<label for="search" class="sr-only">Search parks</label>
<div class="relative">
<div x-data="{
open: false,
query: '{{ request.GET.search|default:'' }}',
focusedIndex: -1,
suggestions: []
}"
@click.away="open = false"
x-init="$watch('query', value => { console.log('query:', value); console.log('open:', open) })"
class="relative">
<input type="search"
name="search"
id="search"
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
placeholder="Search parks by name or location..."
x-model="query"
hx-get="{% url 'parks:search_parks' %}?view_mode={{ view_mode|default:'grid' }}"
hx-trigger="input delay:500ms, search"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-indicator"
aria-label="Search parks"
@keydown.down.prevent="focusedIndex = Math.min(focusedIndex + 1, $refs.suggestionsList?.children.length - 1 || 0)"
@keydown.up.prevent="focusedIndex = Math.max(focusedIndex - 1, -1)"
@keydown.enter.prevent="if (focusedIndex >= 0) $refs.suggestionsList?.children[focusedIndex]?.click()">
<div class="relative">
<div hx-get="{% url 'parks:suggest_parks' %}?view_mode={{ view_mode|default:'grid' }}"
hx-trigger="input[target.value.length > 1] delay:300ms from:input"
hx-target="this"
hx-swap="innerHTML"
hx-include="closest input[name=search]"
x-ref="suggestionsList"
@htmx:afterRequest="open = detail.xhr.response.trim().length > 0"
@htmx:beforeRequest="open = false"
class="absolute top-full left-0 right-0 mt-1 z-50"></div>
</div>
</div>
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<div id="search-indicator" class="htmx-indicator">
<div class="w-full relative">
<form hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="change from:.park-search">
{% csrf_token %}
{{ search_form.park }}
</form>
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-3"
role="status"
aria-label="Loading search results">
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
</div>
</div>
</div>
@@ -117,15 +92,4 @@
data-view-mode="{{ view_mode|default:'grid' }}">
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
</div>
{% endblock %}
{% block extra_css %}
<style>
[x-cloak] { display: none !important; }
</style>
{% endblock %}
{% block extra_js %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="{% static 'parks/js/search.js' %}"></script>
{% endblock %}

View File

@@ -1,5 +1,7 @@
{% load filter_utils %}
{% if suggestions %}
<div class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
<div id="search-suggestions-results"
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
x-show="open"
x-cloak
@keydown.escape.window="open = false"
@@ -9,21 +11,27 @@
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95">
{% for park in suggestions %}
<a href="{% url 'parks:park_detail' slug=park.slug %}"
class="block px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between"
:class="{ 'bg-gray-100': focusedIndex === {{ forloop.counter0 }} }"
hx-get="{% url 'parks:search_parks' %}?search={{ park.name }}&view_mode={{ view_mode|default:'grid' }}"
hx-target="#park-results"
hx-push-url="true"
@mousedown.prevent
@click="query = '{{ park.name }}'; open = false">
<span class="font-medium">{{ park.name }}</span>
<span class="text-gray-500">
{% if park.location.first.city %}{{ park.location.first.city }}, {% endif %}
{% if park.location.first.state %}{{ park.location.first.state }}{% endif %}
</span>
</a>
{% endfor %}
</div>
{% for park in suggestions %}
{% with location=park.location.first %}
<button type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
role="option"
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
tabindex="-1"
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
<div class="flex items-center gap-2">
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
<span class="text-gray-500">
{% if location.city %}{{ location.city }}, {% endif %}
{% if location.state %}{{ location.state }}{% endif %}
</span>
</div>
</button>
{% endwith %}
{% endfor %}
</div>
{% endif %}