Stable multi-select implementation

This commit is contained in:
Ronan Berder
2026-01-04 14:39:32 +08:00
parent b9274702d3
commit 09f868d474
5 changed files with 380 additions and 6349 deletions
+47 -19
View File
@@ -2,8 +2,10 @@
Renders a select or combobox component.
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string} [optional] - The initially selected value.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@@ -17,6 +19,8 @@
selected=None,
name=None,
items=[],
multiple=false,
placeholder=None,
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -27,8 +31,9 @@
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% set selectedArray = ((selected if (selected is iterable and selected is not string) else [selected]) if selected else []) %}
{% set first_option = namespace(item=None) %}
{% set selected_option = namespace(item=None) %}
{% set selected_options = namespace(items=[]) %}
{% if items %}
{% for item in items %}
@@ -37,26 +42,34 @@
{% if not first_option.item %}
{% set first_option.item = sub_item %}
{% endif %}
{% if selected and sub_item.value == selected and not selected_option.item %}
{% set selected_option.item = sub_item %}
{% if sub_item.value in selectedArray %}
{% set selected_options.items = selected_options.items + [sub_item] %}
{% endif %}
{% endfor %}
{% else %}
{% if not first_option.item %}
{% set first_option.item = item %}
{% endif %}
{% if selected and item.value == selected and not selected_option.item %}
{% set selected_option.item = item %}
{% if item.value in selectedArray %}
{% set selected_options.items = selected_options.items + [item] %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = selected_option.item or first_option.item or None %}
{% set default_option = (selected_options.items[0] if selected_options.items else first_option.item) %}
{% set labels = [] %}
{% if multiple and selected_options.items %}
{% for opt in selected_options.items %}
{% set labels = labels + [opt.label] %}
{% endfor %}
{% endif %}
{% set default_label = (', '.join(labels) if (multiple and selected_options.items) else ((placeholder or '') if (multiple and not selected_options.items) else (default_option.label if default_option else ''))) %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% for key, value in main_attrs.items() %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
@@ -72,7 +85,7 @@
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span class="truncate">{{ default_option.label }}</span>
<span class="truncate">{{ default_label }}</span>
{% if is_combobox %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
{% else %}
@@ -110,25 +123,40 @@
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs.items() %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items %}
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% if multiple and selectedArray %}
{% for val in selectedArray %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ val }}"
{% if loop.index0 > 0 %}id="{{ id }}-value-{{ loop.index0 }}"{% endif %}
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endfor %}
>
{% else %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs.items() %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endif %}
</div>
{% endmacro %}
@@ -138,7 +166,7 @@
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
@@ -153,7 +181,7 @@
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item['items'], selected, item_id) if item['items'] }}
{{ render_select_items(item['items'], selectedArray, item_id) if item['items'] }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
@@ -162,7 +190,7 @@
id="{{ item_id }}"
role="option"
data-value="{{ item.value }}"
{% if selected == item.value %}aria-selected="true"{% endif %}
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs.items() %}
{{ key }}="{{ value }}"
+175 -14
View File
@@ -3,23 +3,31 @@
const trigger = selectComponent.querySelector(':scope > button');
const selectedLabel = trigger.querySelector(':scope > span');
const popover = selectComponent.querySelector(':scope > [data-popover]');
const listbox = popover.querySelector('[role="listbox"]');
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
const input = selectComponent.querySelector(':scope > input[type="hidden"]');
const filter = selectComponent.querySelector('header input[type="text"]');
if (!trigger || !popover || !listbox || !input) {
const missing = [];
if (!trigger) missing.push('trigger');
if (!popover) missing.push('popover');
if (!listbox) missing.push('listbox');
if (!input) missing.push('input');
if (!input) missing.push('input');
console.error(`Select component initialisation failed. Missing element(s): ${missing.join(', ')}`, selectComponent);
return;
}
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true');
let visibleOptions = [...options];
let activeIndex = -1;
const isMultiple = listbox.getAttribute('aria-multiselectable') === 'true';
let selectedValues = isMultiple ? new Set() : null;
let placeholder = null;
if (isMultiple) {
placeholder = selectComponent.dataset.placeholder || '';
}
const setActiveOption = (index) => {
if (activeIndex > -1 && options[activeIndex]) {
@@ -46,13 +54,46 @@
return parseFloat(style.transitionDuration) > 0 || parseFloat(style.transitionDelay) > 0;
};
const syncMultipleInputs = () => {
if (!isMultiple) return;
const values = Array.from(selectedValues);
const inputs = Array.from(selectComponent.querySelectorAll(':scope > input[type="hidden"]'));
inputs.slice(1).forEach(inp => inp.remove());
if (values.length === 0) {
input.value = '';
} else {
input.value = values[0];
let insertAfter = input;
for (let i = 1; i < values.length; i++) {
const clone = input.cloneNode(true);
clone.removeAttribute('id');
clone.value = values[i];
insertAfter.after(clone);
insertAfter = clone;
}
}
};
const updateMultipleLabel = () => {
if (!isMultiple) return;
const selected = options.filter(opt => selectedValues.has(opt.dataset.value));
if (selected.length === 0) {
selectedLabel.textContent = placeholder;
selectedLabel.classList.add('text-muted-foreground');
} else {
selectedLabel.textContent = selected.map(opt => opt.dataset.label || opt.textContent.trim()).join(', ');
selectedLabel.classList.remove('text-muted-foreground');
}
};
const updateValue = (option, triggerEvent = true) => {
if (option) {
selectedLabel.innerHTML = option.innerHTML;
input.value = option.dataset.value;
listbox.querySelector('[role="option"][aria-selected="true"]')?.removeAttribute('aria-selected');
option.setAttribute('aria-selected', 'true');
if (triggerEvent) {
const event = new CustomEvent('change', {
detail: { value: option.dataset.value },
@@ -63,6 +104,35 @@
}
};
const toggleMultipleValue = (value, triggerEvent = true) => {
if (!isMultiple || value == null) return;
if (selectedValues.has(value)) {
selectedValues.delete(value);
} else {
selectedValues.add(value);
}
options.forEach(opt => {
if (selectedValues.has(opt.dataset.value)) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
updateMultipleLabel();
syncMultipleInputs();
if (triggerEvent) {
const event = new CustomEvent('change', {
detail: { value, values: Array.from(selectedValues) },
bubbles: true
});
selectComponent.dispatchEvent(event);
}
};
const closePopover = (focusOnTrigger = true) => {
if (popover.getAttribute('aria-hidden') === 'true') return;
@@ -101,7 +171,59 @@
const selectByValue = (value) => {
const option = options.find(opt => opt.dataset.value === value);
selectOption(option);
if (isMultiple) {
if (option && value != null) {
selectedValues.add(value);
options.forEach(opt => {
if (selectedValues.has(opt.dataset.value)) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
updateMultipleLabel();
syncMultipleInputs();
const event = new CustomEvent('change', {
detail: { value, values: Array.from(selectedValues) },
bubbles: true
});
selectComponent.dispatchEvent(event);
}
} else {
selectOption(option);
}
};
const selectAll = () => {
if (!isMultiple) return;
selectedValues = new Set(options.map(opt => opt.dataset.value).filter(v => v != null));
options.forEach(opt => {
if (selectedValues.has(opt.dataset.value)) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
updateMultipleLabel();
syncMultipleInputs();
const event = new CustomEvent('change', {
detail: { values: Array.from(selectedValues) },
bubbles: true
});
selectComponent.dispatchEvent(event);
};
const selectNone = () => {
if (!isMultiple) return;
selectedValues.clear();
options.forEach(opt => opt.removeAttribute('aria-selected'));
updateMultipleLabel();
syncMultipleInputs();
const event = new CustomEvent('change', {
detail: { values: [] },
bubbles: true
});
selectComponent.dispatchEvent(event);
};
if (filter) {
@@ -137,13 +259,40 @@
filter.addEventListener('input', filterOptions);
}
let initialOption = options.find(opt => opt.dataset.value === input.value);
if (!initialOption) {
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
}
if (isMultiple) {
const validValues = new Set(options.map(opt => opt.dataset.value).filter(v => v != null));
const inputs = Array.from(selectComponent.querySelectorAll(':scope > input[type="hidden"]'));
const initialValues = inputs
.map(inp => inp.value)
.filter(v => v != null && validValues.has(v));
if (initialValues.length > 0) {
initialValues.forEach(val => selectedValues.add(val));
} else {
const preSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true');
if (preSelected.length > 0) {
preSelected.forEach(opt => opt.dataset.value != null && selectedValues.add(opt.dataset.value));
}
}
options.forEach(opt => {
if (selectedValues.has(opt.dataset.value)) {
opt.setAttribute('aria-selected', 'true');
} else {
opt.removeAttribute('aria-selected');
}
});
updateMultipleLabel();
syncMultipleInputs();
} else {
let initialOption = options.find(opt => opt.dataset.value === input.value);
updateValue(initialOption, false);
if (!initialOption) {
initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0];
}
if (initialOption) {
updateValue(initialOption, false);
}
}
const handleKeyNavigation = (event) => {
const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';
@@ -169,7 +318,11 @@
if (event.key === 'Enter') {
if (activeIndex > -1) {
selectOption(options[activeIndex]);
if (isMultiple) {
toggleMultipleValue(options[activeIndex].dataset.value);
} else {
selectOption(options[activeIndex]);
}
}
return;
}
@@ -268,7 +421,11 @@
listbox.addEventListener('click', (event) => {
const clickedOption = event.target.closest('[role="option"]');
if (clickedOption) {
selectOption(clickedOption);
if (isMultiple) {
toggleMultipleValue(clickedOption.dataset.value);
} else {
selectOption(clickedOption);
}
}
});
@@ -285,8 +442,12 @@
});
popover.setAttribute('aria-hidden', 'true');
selectComponent.selectByValue = selectByValue;
if (isMultiple) {
selectComponent.selectAll = selectAll;
selectComponent.selectNone = selectNone;
}
selectComponent.dataset.selectInitialized = true;
selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized'));
};
+47 -19
View File
@@ -2,8 +2,10 @@
Renders a select or combobox component.
@param id {string} [optional] - Unique identifier for the select component.
@param selected {string} [optional] - The initially selected value.
@param selected {string|array} [optional] - The initially selected value(s).
@param name {string} [optional] - The name attribute for the hidden input storing the selected value.
@param multiple {boolean} [optional] [default=false] - Enables multiple selection mode.
@param placeholder {string} [optional] - Placeholder text shown when no options are selected (multiple mode only).
@param main_attrs {object} [optional] - Additional HTML attributes for the main container div.
@param trigger_attrs {object} [optional] - Additional HTML attributes for the trigger button.
@param popover_attrs {object} [optional] - Additional HTML attributes for the popover content div.
@@ -17,6 +19,8 @@
selected=None,
name=None,
items=None,
multiple=false,
placeholder=None,
main_attrs={},
trigger_attrs={},
popover_attrs={},
@@ -27,8 +31,9 @@
) %}
{% set id = id or ("select-" + (range(100000, 999999) | random | string)) %}
{% set selectedArray = ((selected if (selected is iterable and selected is not string) else [selected]) if selected else []) %}
{% set first_option = [] %}
{% set selected_option = [] %}
{% set selected_options = [] %}
{% if items %}
{% for item in items %}
@@ -37,26 +42,34 @@
{% if not first_option[0] %}
{% set first_option = (first_option.push(sub_item), first_option) %}
{% endif %}
{% if selected and sub_item.value == selected and not selected_option[0] %}
{% set selected_option = (selected_option.push(sub_item), selected_option) %}
{% if sub_item.value in selectedArray %}
{% set selected_options = (selected_options.push(sub_item), selected_options) %}
{% endif %}
{% endfor %}
{% else %}
{% if not first_option[0] %}
{% set first_option = (first_option.push(item), first_option) %}
{% endif %}
{% if selected and item.value == selected and not selected_option[0] %}
{% set selected_option = (selected_option.push(item), selected_option) %}
{% if item.value in selectedArray %}
{% set selected_options = (selected_options.push(item), selected_options) %}
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% set default_option = selected_option[0] or first_option[0] or None %}
{% set default_option = (selected_options[0] if selected_options[0] else (first_option[0] if first_option[0] else None)) %}
{% set labels = [] %}
{% if multiple and selected_options %}
{% for opt in selected_options %}
{% set labels = (labels.push(opt.label), labels) %}
{% endfor %}
{% endif %}
{% set default_label = (labels | join(', ') if (multiple and selected_options) else ((placeholder or '') if (multiple and not selected_options) else (default_option.label if default_option else ''))) %}
<div
id="{{ id }}"
class="select {{ main_attrs.class }}"
{% if multiple and placeholder %}data-placeholder="{{ placeholder }}"{% endif %}
{% for key, value in main_attrs %}
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
@@ -72,7 +85,7 @@
{% if key != 'class' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
<span class="truncate">{{ default_option.label }}</span>
<span class="truncate">{{ default_label }}</span>
{% if is_combobox %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down-icon lucide-chevrons-up-down text-muted-foreground opacity-50 shrink-0"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
{% else %}
@@ -110,25 +123,40 @@
id="{{ id }}-listbox"
aria-orientation="vertical"
aria-labelledby="{{ id }}-trigger"
{% if multiple %}aria-multiselectable="true"{% endif %}
{% for key, value in listbox_attrs %}
{{ key }}="{{ value }}"
{% endfor %}
>
{% if items and items.length > 0 %}
{{ render_select_items(items, default_option.value, id ~ "-items" if id else "items") }}
{{ render_select_items(items, selectedArray, id ~ "-items" if id else "items") }}
{% else %}
{{ caller() if caller }}
{% endif %}
</div>
</div>
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% if multiple and selectedArray %}
{% for val in selectedArray %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ val }}"
{% if loop.index0 > 0 %}id="{{ id }}-value-{{ loop.index0 }}"{% endif %}
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endfor %}
>
{% else %}
<input
type="hidden"
name="{{ name or id ~ '-value' }}"
value="{{ (default_option.value if default_option) or '' }}"
{% for key, value in input_attrs %}
{% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %}
{% endfor %}
>
{% endif %}
</div>
{% endmacro %}
@@ -138,7 +166,7 @@
@param items {array} - The array of items to render.
@param parent_id_prefix {string} [optional] - The prefix for the item id.
#}
{% macro render_select_items(items, selected, parent_id_prefix="items") %}
{% macro render_select_items(items, selectedArray, parent_id_prefix="items") %}
{% for item in items %}
{% set item_id = parent_id_prefix ~ "-" ~ loop.index %}
{% if item.type == "group" %}
@@ -153,7 +181,7 @@
{% endif %}
>
<div role="heading" id="{{ group_label_id }}">{{ item.label }}</div>
{{ render_select_items(item.items, selected, item_id) if item.items }}
{{ render_select_items(item.items, selectedArray, item_id) if item.items }}
</div>
{% elif item.type == "separator" %}
<hr role="separator" />
@@ -162,7 +190,7 @@
id="{{ item_id }}"
role="option"
data-value="{{ item.value }}"
{% if selected == item.value %}aria-selected="true"{% endif %}
{% if item.value in selectedArray %}aria-selected="true"{% endif %}
{% if item.attrs %}
{% for key, value in item.attrs %}
{{ key }}="{{ value }}"