Files
TimeTracker/app/templates/components/multi_select.html
T
Dries Peeters 463704f054 feat(ui): refresh shared layout patterns and responsive screens
Unify buttons, cards, headers, toasts, and form treatments across the app so screens feel consistent and are easier to scan on desktop and mobile. Update the broader template set to use the shared UI primitives and responsive spacing patterns introduced in this refresh.
2026-03-06 22:15:06 +01:00

267 lines
11 KiB
HTML

{# ============================================
MULTI-SELECT COMPONENT
Reusable multi-select dropdown with checkboxes
============================================ #}
{% macro multi_select(
field_name,
label,
items,
selected_ids=[],
item_id_attr='id',
item_label_attr='name',
placeholder='All',
show_search=True,
form_id=None
) %}
{#
Parameters:
- field_name: Name of the hidden input field (e.g., 'project_ids')
- label: Label text for the dropdown
- items: List of items to display (e.g., projects, users)
- selected_ids: List of currently selected IDs
- item_id_attr: Attribute name for item ID (default: 'id')
- item_label_attr: Attribute name for item label (default: 'name')
- placeholder: Text to show when nothing is selected (default: 'All')
- show_search: Whether to show search box (default: True)
- form_id: Optional form ID for auto-submit
#}
<div class="multi-select-wrapper relative" data-field-name="{{ field_name }}">
<label for="{{ field_name }}_button" class="form-label">
{{ _(label) }}
</label>
{# Hidden input to store selected IDs as comma-separated values #}
<input type="hidden" name="{{ field_name }}" id="{{ field_name }}_input" value="{{ selected_ids|join(',') if selected_ids else '' }}">
{# Dropdown button #}
<button
type="button"
id="{{ field_name }}_button"
class="w-full bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark text-left flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
aria-haspopup="listbox"
aria-expanded="false"
>
<span class="multi-select-label">
{% if selected_ids %}
{{ _('Selected') }}: <span class="font-semibold">{{ selected_ids|length }}</span>
{% else %}
{{ _(placeholder) }}
{% endif %}
</span>
<i class="fas fa-chevron-down text-xs transition-transform"></i>
</button>
{# Dropdown menu #}
<div
class="multi-select-dropdown absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg hidden"
role="listbox"
aria-label="{{ _(label) }} options"
>
{# Search box #}
{% if show_search and items|length > 5 %}
<div class="p-2 border-b border-gray-200 dark:border-gray-700">
<input
type="text"
class="multi-select-search w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-text-light dark:text-text-dark focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="{{ _('Search...') }}"
aria-label="{{ _('Search') }} {{ _(label) }}"
>
</div>
{% endif %}
{# Options list #}
<div class="multi-select-options max-h-60 overflow-y-auto p-2">
{# "All" option #}
<label class="multi-select-option flex items-center px-3 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors min-h-[44px]">
<input
type="checkbox"
class="multi-select-all h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary"
aria-label="{{ _('Select all') }}"
>
<span class="ml-3 text-sm text-gray-700 dark:text-gray-300 font-medium">{{ _('All') }}</span>
</label>
<div class="border-t border-gray-200 dark:border-gray-700 my-2"></div>
{# Individual items (support both dict-like and ORM objects) #}
{% for item in items %}
{% set item_id = item.get(item_id_attr) if item is mapping else getattr(item, item_id_attr, None) %}
{% set item_label = (item.get(item_label_attr, item.get('display_name', item_id)) if item is mapping else getattr(item, item_label_attr, getattr(item, 'display_name', item_id))) %}
<label class="multi-select-option flex items-center px-3 py-2.5 hover:bg-gray-100 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors min-h-[44px]" data-search-text="{{ item_label|lower }}">
<input
type="checkbox"
class="multi-select-checkbox h-5 w-5 rounded border-gray-300 text-primary focus:ring-primary"
value="{{ item_id }}"
{% if item_id in selected_ids %}checked{% endif %}
aria-label="{{ item_label }}"
>
<span class="ml-3 text-sm text-gray-700 dark:text-gray-300">{{ item_label }}</span>
</label>
{% endfor %}
{% if not items %}
<div class="px-3 py-2 text-sm text-gray-500 dark:text-gray-400 text-center">
{{ _('No items available') }}
</div>
{% endif %}
</div>
{# Action buttons #}
<div class="p-2 border-t border-gray-200 dark:border-gray-700 flex gap-2">
<button
type="button"
class="multi-select-clear flex-1 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors min-h-[44px]"
>
{{ _('Clear') }}
</button>
<button
type="button"
class="multi-select-apply flex-1 px-3 py-2.5 text-sm text-white bg-primary hover:bg-primary/90 rounded-lg transition-colors min-h-[44px]"
>
{{ _('Apply') }}
</button>
</div>
</div>
</div>
{# JavaScript for multi-select functionality #}
<script>
(function() {
'use strict';
const wrapper = document.querySelector('.multi-select-wrapper[data-field-name="{{ field_name }}"]');
if (!wrapper) return;
const button = wrapper.querySelector('#{{ field_name }}_button');
const dropdown = wrapper.querySelector('.multi-select-dropdown');
const hiddenInput = wrapper.querySelector('#{{ field_name }}_input');
const labelSpan = wrapper.querySelector('.multi-select-label');
const searchInput = wrapper.querySelector('.multi-select-search');
const allCheckbox = wrapper.querySelector('.multi-select-all');
const checkboxes = wrapper.querySelectorAll('.multi-select-checkbox');
const clearBtn = wrapper.querySelector('.multi-select-clear');
const applyBtn = wrapper.querySelector('.multi-select-apply');
const options = wrapper.querySelectorAll('.multi-select-option');
const chevron = button.querySelector('.fa-chevron-down');
// Toggle dropdown
button.addEventListener('click', function(e) {
e.stopPropagation();
const isOpen = !dropdown.classList.contains('hidden');
// Close all other multi-selects
document.querySelectorAll('.multi-select-dropdown').forEach(d => {
if (d !== dropdown) {
d.classList.add('hidden');
const otherButton = d.parentElement.querySelector('[aria-expanded]');
if (otherButton) {
otherButton.setAttribute('aria-expanded', 'false');
const otherChevron = otherButton.querySelector('.fa-chevron-down');
if (otherChevron) otherChevron.classList.remove('rotate-180');
}
}
});
dropdown.classList.toggle('hidden');
button.setAttribute('aria-expanded', !isOpen);
chevron.classList.toggle('rotate-180');
if (!isOpen && searchInput) {
searchInput.focus();
}
});
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!wrapper.contains(e.target)) {
dropdown.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
chevron.classList.remove('rotate-180');
}
});
// Search functionality
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
options.forEach(option => {
if (option.classList.contains('multi-select-option') && option.dataset.searchText) {
const matches = option.dataset.searchText.includes(searchTerm);
option.style.display = matches ? '' : 'none';
}
});
});
}
// Update label and hidden input
function updateSelection() {
const selected = Array.from(checkboxes).filter(cb => cb.checked).map(cb => cb.value);
hiddenInput.value = selected.join(',');
if (selected.length === 0) {
labelSpan.innerHTML = '{{ _(placeholder) }}';
if (allCheckbox) allCheckbox.checked = false;
} else if (selected.length === checkboxes.length) {
labelSpan.innerHTML = '{{ _("All") }}';
if (allCheckbox) allCheckbox.checked = true;
} else {
labelSpan.innerHTML = '{{ _("Selected") }}: <span class="font-semibold">' + selected.length + '</span>';
if (allCheckbox) allCheckbox.checked = false;
}
}
// Handle individual checkbox changes
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
updateSelection();
});
});
// Handle "All" checkbox
if (allCheckbox) {
allCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
checkboxes.forEach(cb => cb.checked = isChecked);
updateSelection();
});
}
// Clear button
if (clearBtn) {
clearBtn.addEventListener('click', function() {
checkboxes.forEach(cb => cb.checked = false);
if (allCheckbox) allCheckbox.checked = false;
updateSelection();
});
}
// Apply button - close dropdown and optionally submit form
if (applyBtn) {
applyBtn.addEventListener('click', function() {
dropdown.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
chevron.classList.remove('rotate-180');
{% if form_id %}
// Auto-submit form if form_id is provided
const form = document.getElementById('{{ form_id }}');
if (form) {
// For AJAX forms, trigger custom event
if (form.hasAttribute('data-filter-form')) {
const event = new Event('change', { bubbles: true });
hiddenInput.dispatchEvent(event);
} else {
form.submit();
}
}
{% endif %}
});
}
// Initialize "All" checkbox state
updateSelection();
})();
</script>
{% endmacro %}