Merge pull request #47 from dsaub/copilot/implement-aria-combobox-pattern

Implement ARIA combobox/listbox pattern for search suggestions
This commit is contained in:
Daniel (elordenador)
2026-04-28 09:17:11 +02:00
committed by GitHub
+74 -13
View File
@@ -50,8 +50,11 @@
transition: background-color 0.2s; transition: background-color 0.2s;
} }
.search-suggestion-item:hover { .search-suggestion-item:hover,
.search-suggestion-item.active {
background-color: #f8f9fa; background-color: #f8f9fa;
outline: 2px solid #0d6efd;
outline-offset: -2px;
} }
.search-suggestion-item:last-child { .search-suggestion-item:last-child {
@@ -107,10 +110,10 @@
<!-- Barra de búsqueda con sugerencias --> <!-- Barra de búsqueda con sugerencias -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm"> <form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<div class="input-group"> <div class="input-group">
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off"> <input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="" aria-haspopup="listbox">
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button> <button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
</div> </div>
<div class="search-suggestions" id="searchSuggestions"></div> <div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
</form> </form>
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation"> <div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
@@ -201,6 +204,35 @@
const searchSuggestions = document.getElementById('searchSuggestions'); const searchSuggestions = document.getElementById('searchSuggestions');
const searchForm = document.getElementById('searchForm'); const searchForm = document.getElementById('searchForm');
let searchTimeout; let searchTimeout;
let currentFocusIndex = -1;
// Helpers para gestionar el estado ARIA del combobox
function openSuggestions() {
searchSuggestions.classList.add('show');
searchInput.setAttribute('aria-expanded', 'true');
}
function closeSuggestions() {
searchSuggestions.classList.remove('show');
searchInput.setAttribute('aria-expanded', 'false');
searchInput.setAttribute('aria-activedescendant', '');
currentFocusIndex = -1;
}
function updateFocus(options) {
options.forEach((option, index) => {
const active = index === currentFocusIndex;
option.classList.toggle('active', active);
option.setAttribute('aria-selected', active ? 'true' : 'false');
});
if (currentFocusIndex >= 0) {
const activeOption = options[currentFocusIndex];
searchInput.setAttribute('aria-activedescendant', activeOption.id);
activeOption.scrollIntoView({ block: 'nearest' });
} else {
searchInput.setAttribute('aria-activedescendant', '');
}
}
// Escuchar cambios en el input // Escuchar cambios en el input
searchInput.addEventListener('input', function() { searchInput.addEventListener('input', function() {
@@ -208,7 +240,7 @@
const query = this.value.trim(); const query = this.value.trim();
if (query.length < 2) { if (query.length < 2) {
searchSuggestions.classList.remove('show'); closeSuggestions();
return; return;
} }
@@ -218,6 +250,31 @@
}, 300); }, 300);
}); });
// Navegación por teclado (ArrowDown/ArrowUp/Enter/Escape)
searchInput.addEventListener('keydown', function(event) {
const options = searchSuggestions.querySelectorAll('[role="option"]');
if (!options.length || !searchSuggestions.classList.contains('show')) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
currentFocusIndex = Math.min(currentFocusIndex + 1, options.length - 1);
updateFocus(options);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
currentFocusIndex = Math.max(currentFocusIndex - 1, -1);
updateFocus(options);
} else if (event.key === 'Enter' && currentFocusIndex >= 0) {
event.preventDefault();
const selected = options[currentFocusIndex];
window.location.href = selected.dataset.href;
} else if (event.key === 'Escape') {
closeSuggestions();
searchInput.focus();
}
});
// Función para obtener sugerencias del servidor // Función para obtener sugerencias del servidor
function fetchSuggestions(query) { function fetchSuggestions(query) {
fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`) fetch(`{% url 'search_suggestions' %}?q=${encodeURIComponent(query)}`)
@@ -227,33 +284,37 @@
}) })
.catch(error => { .catch(error => {
console.error('Error fetching suggestions:', error); console.error('Error fetching suggestions:', error);
searchSuggestions.classList.remove('show'); closeSuggestions();
}); });
} }
// Función para mostrar las sugerencias // Función para mostrar las sugerencias
function displaySuggestions(suggestions, query) { function displaySuggestions(suggestions, query) {
currentFocusIndex = -1;
if (suggestions.length === 0) { if (suggestions.length === 0) {
searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>'; searchSuggestions.innerHTML = '<div class="search-suggestion-item text-muted">No se encontraron productos</div>';
searchSuggestions.classList.add('show'); openSuggestions();
return; return;
} }
let html = ''; let html = '';
suggestions.forEach(suggestion => { suggestions.forEach((suggestion, index) => {
// Resaltar la coincidencia en el nombre // Resaltar la coincidencia en el nombre
const highlightedName = highlightMatch(suggestion.name, query); const highlightedName = highlightMatch(suggestion.name, query);
const priceWithVAT = (suggestion.price * 1.21).toFixed(2); const priceWithVAT = (suggestion.price * 1.21).toFixed(2);
html += ` html += `
<a href="/tienda/producto/${suggestion.id}" class="search-suggestion-item text-decoration-none"> <div class="search-suggestion-item" role="option" id="search-option-${index}"
aria-selected="false" tabindex="-1"
data-href="/tienda/producto/${suggestion.id}"
onclick="window.location.href=this.dataset.href">
<span class="suggestion-name">${highlightedName}</span> <span class="suggestion-name">${highlightedName}</span>
<span class="suggestion-price">€${priceWithVAT}</span> <span class="suggestion-price">€${priceWithVAT}</span>
</a> </div>
`; `;
}); });
searchSuggestions.innerHTML = html; searchSuggestions.innerHTML = html;
searchSuggestions.classList.add('show'); openSuggestions();
} }
// Función para resaltar el texto que coincide // Función para resaltar el texto que coincide
@@ -265,19 +326,19 @@
// Cerrar sugerencias cuando se hace clic fuera // Cerrar sugerencias cuando se hace clic fuera
document.addEventListener('click', function(event) { document.addEventListener('click', function(event) {
if (!searchForm.contains(event.target)) { if (!searchForm.contains(event.target)) {
searchSuggestions.classList.remove('show'); closeSuggestions();
} }
}); });
// Cerrar sugerencias al enviar el formulario // Cerrar sugerencias al enviar el formulario
searchForm.addEventListener('submit', function() { searchForm.addEventListener('submit', function() {
searchSuggestions.classList.remove('show'); closeSuggestions();
}); });
// Mostrar sugerencias al hacer clic en el input (si hay texto) // Mostrar sugerencias al hacer clic en el input (si hay texto)
searchInput.addEventListener('focus', function() { searchInput.addEventListener('focus', function() {
if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) { if (this.value.trim().length >= 2 && searchSuggestions.innerHTML) {
searchSuggestions.classList.add('show'); openSuggestions();
} }
}); });
</script> </script>