Remove dependency on tail select component and instead role my own to minmise bundle size
This commit is contained in:
196
templates/partials/custom_select.html
Normal file
196
templates/partials/custom_select.html
Normal 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>
|
||||
@@ -46,7 +46,9 @@
|
||||
_="on htmx:afterRequest toggle .hidden on #show-add-tag-form-btn then toggle .hidden on me then set me.tag_name.value to ''">
|
||||
{# Hide form, show button, clear input after submit #}
|
||||
|
||||
<input type="hidden" name="person_id" value="{{ person_id | default('', true) }}">
|
||||
{% if person_id %}
|
||||
<input type="hidden" name="person_id" value="{{ person_id }}">
|
||||
{% endif %}
|
||||
<input type="hidden" name="current_filter" value="{{ request.query_string.decode() | default('', true) }}">
|
||||
{# Pass
|
||||
context
|
||||
|
||||
@@ -21,19 +21,13 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-full">
|
||||
<select name="exercise_id"
|
||||
class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||
_="init js(me)
|
||||
tail.select(me, {
|
||||
search: true,
|
||||
placeholder: 'Filter exercises',
|
||||
})
|
||||
end">
|
||||
{% for exercise in exercises|default([], true) %}
|
||||
<option value="{{ exercise.exercise_id }}" {% if exercise.exercise_id==exercise_id %}selected{% endif
|
||||
%}>{{ exercise.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ render_partial('partials/custom_select.html',
|
||||
name='exercise_id',
|
||||
options=exercises|default([], true),
|
||||
multiple=false,
|
||||
search=true,
|
||||
placeholder='Filter exercises')
|
||||
}}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -34,24 +34,15 @@
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="w-full">
|
||||
<select multiple name="tag_id"
|
||||
hx-post="{{ url_for('tags.add_tag_to_workout', workout_id=workout_id) }}"
|
||||
hx-target="#tag-wrapper-w-{{ workout_id }}"
|
||||
class="block appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 pr-8 rounded leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
|
||||
_="init js(me)
|
||||
tail.select(me, {
|
||||
search: true,
|
||||
multiple: true,
|
||||
placeholder: 'Select tags',
|
||||
})
|
||||
end">
|
||||
{% for tag in tags %}
|
||||
<option value="{{ tag.tag_id }}" {% if tag.is_selected %}selected{% endif %}>
|
||||
{{
|
||||
tag.tag_name
|
||||
}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ render_partial('partials/custom_select.html',
|
||||
name='tag_id',
|
||||
options=tags,
|
||||
multiple=true,
|
||||
search=true,
|
||||
placeholder='Select tags',
|
||||
hx_post=url_for('tags.add_tag_to_workout', workout_id=workout_id),
|
||||
hx_target='#tag-wrapper-w-' + workout_id|string)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user