Remove dependency on tail select component and instead role my own to minmise bundle size

This commit is contained in:
Peter Stockings
2026-01-29 12:49:12 +11:00
parent 04fe00412a
commit 509d11443d
13 changed files with 309 additions and 581 deletions

View File

@@ -0,0 +1,196 @@
<div class="relative w-full custom-select-container" data-multiple="{{ 'true' if multiple else 'false' }}"
data-placeholder="{{ placeholder|default('Select options') }}">
<!-- Display area -->
<button type="button"
class="toggle-btn w-full bg-gray-50 border border-gray-300 rounded-lg p-2.5 text-sm text-gray-900 flex justify-between items-center focus:ring-blue-500 focus:border-blue-500">
<span class="selected-label truncate border-none outline-none">
{% set ns = namespace(selected_count=0, selected_name='') %}
{% for option in options %}
{% set opt_selected = option.selected if option.selected is defined else (option.is_selected if
option.is_selected is defined else false) %}
{% if opt_selected %}
{% set ns.selected_count = ns.selected_count + 1 %}
{% set opt_name = option.name if option.name is defined else (option.tag_name if option.tag_name is defined
else (option.exercise_name if option.exercise_name is defined else option.label)) %}
{% set ns.selected_name = opt_name %}
{% endif %}
{% endfor %}
{% if multiple %}
<span class="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded">{{
ns.selected_count }}</span>
{{ placeholder|default('Select options') }}
{% else %}
{{ ns.selected_name if ns.selected_count > 0 else (placeholder|default('Select an option')) }}
{% endif %}
</span>
<svg class="w-4 h-4 ml-2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- Hidden native select for HTMX/Form submission -->
<select name="{{ name }}" id="select-{{ name }}" {% if multiple %}multiple{% endif %} class="hidden native-select"
{% if hx_get %}hx-get="{{ hx_get }}" {% endif %} {% if hx_post %}hx-post="{{ hx_post }}" {% endif %} {% if
hx_include %}hx-include="{{ hx_include }}" {% endif %} {% if hx_target %}hx-target="{{ hx_target }}" {% endif %}
{% if hx_push_url is sameas true %}hx-push-url="true" {% elif hx_push_url %}hx-push-url="{{ hx_push_url }}" {%
endif %} {% if hx_vals %}hx-vals='{{ hx_vals|safe }}' {% endif %} {% if hx_swap %}hx-swap="{{ hx_swap }}" {%
endif %}>
{% for option in options %}
{% set opt_id = option.id if option.id is defined else (option.tag_id if option.tag_id is defined else
(option.exercise_id if option.exercise_id is defined else option.value)) %}
{% set opt_name = option.name if option.name is defined else (option.tag_name if option.tag_name is defined else
(option.exercise_name if option.exercise_name is defined else option.label)) %}
{% set opt_selected = option.selected if option.selected is defined else (option.is_selected if
option.is_selected is defined else false) %}
<option value="{{ opt_id }}" {% if opt_selected %}selected{% endif %}>{{ opt_name }}</option>
{% endfor %}
</select>
<!-- Dropdown -->
<div
class="dropdown-menu hidden absolute z-40 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg overflow-hidden">
{% if search %}
<div class="p-2 border-b bg-gray-50">
<input type="text" placeholder="Search..."
class="search-input w-full p-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
{% endif %}
<ul class="options-list max-h-60 overflow-y-auto p-1">
{% for option in options %}
{% set opt_id = option.id if option.id is defined else (option.tag_id if option.tag_id is defined else
(option.exercise_id if option.exercise_id is defined else option.value)) %}
{% set opt_name = option.name if option.name is defined else (option.tag_name if option.tag_name is defined
else (option.exercise_name if option.exercise_name is defined else option.label)) %}
{% set opt_selected = option.selected if option.selected is defined else (option.is_selected if
option.is_selected is defined else false) %}
<li class="option flex items-center p-2 hover:bg-gray-100 cursor-pointer rounded-md {% if opt_selected %}bg-blue-50{% endif %}"
data-index="{{ loop.index0 }}" data-value="{{ opt_id }}">
{% if multiple %}
<input type="checkbox" class="mr-2 pointer-events-none" {% if opt_selected %}checked{% endif %}>
{% endif %}
<span class="truncate opt-text">{{ opt_name }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
<script>
(function () {
function initSelect(container) {
if (container.dataset.initialized) return;
container.dataset.initialized = 'true';
const toggleBtn = container.querySelector('.toggle-btn');
const dropdown = container.querySelector('.dropdown-menu');
const searchInput = container.querySelector('.search-input');
const nativeSelect = container.querySelector('.native-select');
const label = container.querySelector('.selected-label');
const options = container.querySelectorAll('.option');
const isMultiple = container.dataset.multiple === 'true';
const placeholder = container.dataset.placeholder;
function refreshUI() {
if (isMultiple) {
let count = 0;
for (let i = 0; i < nativeSelect.options.length; i++) {
if (nativeSelect.options[i].selected) count++;
}
label.innerHTML = `<span class="bg-blue-100 text-blue-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded">${count}</span> ${placeholder}`;
} else {
const selectedOpt = nativeSelect.options[nativeSelect.selectedIndex];
label.textContent = selectedOpt ? selectedOpt.text : placeholder;
}
}
// Toggle dropdown
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('hidden');
if (!isOpen) { // is now Open
container.style.zIndex = '50';
if (searchInput) searchInput.focus();
} else {
container.style.zIndex = '';
}
});
// Close on click outside
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
dropdown.classList.add('hidden');
container.style.zIndex = '';
}
});
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const val = e.target.value.toLowerCase();
options.forEach(opt => {
const text = opt.querySelector('.opt-text').textContent.toLowerCase();
opt.style.display = text.includes(val) ? 'flex' : 'none';
});
});
}
// Selection logic
options.forEach(opt => {
opt.addEventListener('click', (e) => {
e.stopPropagation();
const index = parseInt(opt.dataset.index);
const nativeOption = nativeSelect.options[index];
if (isMultiple) {
const checkbox = opt.querySelector('input[type="checkbox"]');
const newState = !nativeOption.selected;
nativeOption.selected = newState;
if (newState) nativeOption.setAttribute('selected', '');
else nativeOption.removeAttribute('selected');
checkbox.checked = newState;
opt.classList.toggle('bg-blue-50', newState);
refreshUI();
} else {
// Single select
options.forEach(o => o.classList.remove('bg-blue-50'));
for (let i = 0; i < nativeSelect.options.length; i++) {
nativeSelect.options[i].selected = false;
nativeSelect.options[i].removeAttribute('selected');
}
nativeOption.selected = true;
nativeOption.setAttribute('selected', '');
opt.classList.add('bg-blue-50');
refreshUI();
dropdown.classList.add('hidden');
container.style.zIndex = '';
}
// Trigger HTMX
nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));
});
});
// Initial UI refresh to match server state
refreshUI();
}
// Run on load and HTMX swaps
if (window.htmx) {
htmx.onLoad(function (content) {
if (!content) return;
const selects = content.querySelectorAll ? content.querySelectorAll('.custom-select-container') : [];
selects.forEach(initSelect);
if (content.classList && content.classList.contains('custom-select-container')) {
initSelect(content);
}
});
} else {
document.querySelectorAll('.custom-select-container').forEach(initSelect);
}
})();
</script>