feat: initialize template shell and basic components
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// 1. Close all comboboxes when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
document.querySelectorAll('.autocomplete-combobox').forEach(combo => {
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
if (results && !combo.contains(event.target)) {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to get only currently visible combobox items
|
||||
function getVisibleItems(combo) {
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
if (!results || results.classList.contains('hidden')) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(results.querySelectorAll('.combobox-item')).filter(item => {
|
||||
return !item.classList.contains('hidden') && item.offsetParent !== null;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to show dropdown results
|
||||
function showDropdown(input) {
|
||||
const combo = input.closest('.autocomplete-combobox');
|
||||
if (!combo) return;
|
||||
if (combo.dataset.justSelected === 'true') return;
|
||||
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
if (!results) return;
|
||||
|
||||
if (!input.hasAttribute('hx-get')) {
|
||||
// Client-side: filter and display
|
||||
results.classList.remove('hidden');
|
||||
const query = input.value.toLowerCase().trim();
|
||||
const items = results.querySelectorAll('.combobox-item');
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
const text = item.getAttribute('data-name').toLowerCase();
|
||||
if (text.includes(query)) {
|
||||
item.classList.remove('hidden');
|
||||
visibleCount++;
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
if (visibleCount === 0 && query !== '') {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
// Server-side HTMX search: only show if results contain elements
|
||||
if (results.querySelector('.combobox-item') || results.querySelector('div')) {
|
||||
results.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Open dropdown on focusin and click
|
||||
document.addEventListener('focusin', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
if (input) {
|
||||
showDropdown(input);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
if (input) {
|
||||
showDropdown(input);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Clear values on input delete and perform client-side filtering
|
||||
document.addEventListener('input', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
if (!input) return;
|
||||
|
||||
const combo = input.closest('.autocomplete-combobox');
|
||||
const valueInput = combo.querySelector('.combobox-value');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
|
||||
if (input.value.trim() === '') {
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (results) results.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply client-side search filtering immediately
|
||||
if (!input.hasAttribute('hx-get')) {
|
||||
showDropdown(input);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Keydown navigation delegation (Arrows, Escape, Enter, Tab/Shift-Tab)
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
const item = event.target.closest('.combobox-item');
|
||||
|
||||
if (input) {
|
||||
const combo = input.closest('.autocomplete-combobox');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (results) results.classList.add('hidden');
|
||||
input.blur();
|
||||
} else if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) {
|
||||
const visibleItems = getVisibleItems(combo);
|
||||
if (visibleItems.length > 0) {
|
||||
event.preventDefault();
|
||||
visibleItems[0].focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
const combo = item.closest('.autocomplete-combobox');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
const inputField = combo.querySelector('.combobox-input');
|
||||
const visibleItems = getVisibleItems(combo);
|
||||
const index = visibleItems.indexOf(item);
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
selectItem(combo, item);
|
||||
} else if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
if (index >= 0 && index + 1 < visibleItems.length) {
|
||||
visibleItems[index + 1].focus();
|
||||
} else {
|
||||
// Loop back to first item
|
||||
if (visibleItems.length > 0) visibleItems[0].focus();
|
||||
}
|
||||
} else if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
if (index > 0) {
|
||||
visibleItems[index - 1].focus();
|
||||
} else if (inputField) {
|
||||
// Return focus back to search input
|
||||
inputField.focus();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (results) results.classList.add('hidden');
|
||||
if (inputField) inputField.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Click event delegation for selection
|
||||
document.addEventListener('click', (event) => {
|
||||
const item = event.target.closest('.combobox-item');
|
||||
if (item) {
|
||||
const combo = item.closest('.autocomplete-combobox');
|
||||
selectItem(combo, item);
|
||||
}
|
||||
});
|
||||
|
||||
// 6. HTMX Swap Integration to show results
|
||||
document.addEventListener('htmx:afterSwap', (event) => {
|
||||
const results = event.target.querySelector('.combobox-results')
|
||||
|| event.target.closest('.combobox-results');
|
||||
if (results) {
|
||||
results.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Core helper to perform selection swap
|
||||
function selectItem(combo, item) {
|
||||
const input = combo.querySelector('.combobox-input');
|
||||
const valueInput = combo.querySelector('.combobox-value');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
|
||||
const id = item.getAttribute('data-id');
|
||||
const name = item.getAttribute('data-name');
|
||||
|
||||
if (valueInput) {
|
||||
valueInput.value = id;
|
||||
valueInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = name;
|
||||
input.focus();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
if (results) {
|
||||
results.classList.add('hidden');
|
||||
// Prevent immediate reopening on focus
|
||||
combo.dataset.justSelected = 'true';
|
||||
setTimeout(() => {
|
||||
delete combo.dataset.justSelected;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user