Merge pull request #47 from dsaub/copilot/implement-aria-combobox-pattern
Implement ARIA combobox/listbox pattern for search suggestions
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user