diff --git a/docs/src/components/select.njk b/docs/src/components/select.njk index 064ead3..a5fb727 100644 --- a/docs/src/components/select.njk +++ b/docs/src/components/select.njk @@ -55,6 +55,7 @@ toc: {{ select( trigger_attrs={"class": "w-[180px]"}, is_combobox=true, + multiple=true, items=[ { type: "group", @@ -175,8 +176,15 @@ toc:
<input type="hidden" name="{ NAME }" value="{ VALUE }">

The hidden input that holds the selected value.

-

For multiselect: multiple hidden inputs with the same name will be created (one for each selected value). When no options are selected, a single input with an empty value is submitted.

-

Note: This differs slightly from native <select multiple> behavior, which submits nothing when empty. This allows the component to work without JavaScript for initial rendering while maintaining a simpler architecture.

+

For single-select: contains the selected option's value as a string.

+

For multiselect: contains a JSON array of selected values (e.g., ["apple","banana"]). When no options are selected, contains an empty array ([]).

+

Backend handling: Parse the JSON value on the server side. For example:

+
@@ -200,21 +208,65 @@ toc: -

JavaScript methods

+

JavaScript methods and properties

-
selectByValue(value)
+
value (property)
-

Selects an option by value (i.e. the option with the matching data-value attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection without removing existing selections.

+

Get or set the current value. For single-select, this is a string. For multiselect, this is an array. The multiselect setter accepts both strings and arrays.

-{% set code_script %}{% endset %} -{{ code_block(code_script | prettyHtml, "html") }} +{{ code_block(code_value | prettyHtml, "html") }} +
+
select(value)
+
+

Selects an option by value (i.e. the option with the matching data-value attribute). For single-select, this will close the popover. For multiselect, this adds the value to the selection if not already selected.

+ +{% set code_select %}{% endset %} +{{ code_block(code_select | prettyHtml, "html") }} +
+
deselect(value) Multiselect only
+
+

Removes a specific value from the selection. Only available when the component is in multiselect mode (aria-multiselectable="true").

+ +{% set code_deselect %}{% endset %} +{{ code_block(code_deselect | prettyHtml, "html") }} +
+
toggle(value) Multiselect only
+
+

Toggles a specific value in the selection (adds if not present, removes if present). Only available when the component is in multiselect mode (aria-multiselectable="true").

+ +{% set code_toggle %}{% endset %} +{{ code_block(code_toggle | prettyHtml, "html") }}
selectAll() Multiselect only
@@ -240,6 +292,10 @@ toc: {% endset %} {{ code_block(code_script_none | prettyHtml, "html") }}
+
selectByValue(value)
+
+

Deprecated: Alias for select(value). Kept for backward compatibility.

+
@@ -360,7 +416,7 @@ toc:

Multiple

-

Enable multiple selection by setting multiple=true. The selected options are displayed as a comma-separated list in the trigger button. Use data-label to specify clean text labels for options that contain HTML (like icons), otherwise the text content will be extracted automatically.

+

Enable multiple selection by setting adding aria-multiselectable="true" to the listbox. The selected options are displayed as a comma-separated list in the trigger button. Use data-label to specify clean text labels for options that contain HTML (like icons), otherwise the text content will be extracted automatically.

{% set code %} diff --git a/src/jinja/select.html.jinja b/src/jinja/select.html.jinja index 685da73..cdaa95d 100644 --- a/src/jinja/select.html.jinja +++ b/src/jinja/select.html.jinja @@ -135,28 +135,14 @@ {% endif %} - {% if multiple and selectedArray %} - {% for val in selectedArray %} - 0 %}id="{{ id }}-value-{{ loop.index0 }}"{% endif %} - {% for key, value in input_attrs.items() %} - {% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %} - {% endfor %} - > + - {% endif %} + > {% endmacro %} diff --git a/src/js/select.js b/src/js/select.js index 34e4b9b..e3b2839 100644 --- a/src/js/select.js +++ b/src/js/select.js @@ -22,20 +22,16 @@ 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 selectedOptions = isMultiple ? new Set() : null; + const placeholder = isMultiple ? (selectComponent.dataset.placeholder || '') : null; const setActiveOption = (index) => { if (activeIndex > -1 && options[activeIndex]) { options[activeIndex].classList.remove('active'); } - + activeIndex = index; - + if (activeIndex > -1) { const activeOption = options[activeIndex]; activeOption.classList.add('active'); @@ -54,70 +50,43 @@ 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 = (optionOrOptions, triggerEvent = true) => { let value; if (isMultiple) { const opts = Array.isArray(optionOrOptions) ? optionOrOptions : []; - selectedValues = new Set(opts.map(opt => opt.dataset.value)); - options.forEach(opt => { - if (selectedValues.has(opt.dataset.value)) { - opt.setAttribute('aria-selected', 'true'); - } else { - opt.removeAttribute('aria-selected'); - } - }); - updateMultipleLabel(); - syncMultipleInputs(); - value = Array.from(selectedValues); + selectedOptions.clear(); + opts.forEach(opt => selectedOptions.add(opt)); + + // Get selected options in DOM order + const selected = options.filter(opt => selectedOptions.has(opt)); + 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'); + } + + value = selected.map(opt => opt.dataset.value); + input.value = JSON.stringify(value); } else { const option = optionOrOptions; if (!option) return; selectedLabel.innerHTML = option.innerHTML; - input.value = option.dataset.value; - options.forEach(opt => { - if (opt === option) { - opt.setAttribute('aria-selected', 'true'); - } else { - opt.removeAttribute('aria-selected'); - } - }); value = option.dataset.value; + input.value = value; } + options.forEach(opt => { + const isSelected = isMultiple ? selectedOptions.has(opt) : opt === optionOrOptions; + if (isSelected) { + opt.setAttribute('aria-selected', 'true'); + } else { + opt.removeAttribute('aria-selected'); + } + }); + if (triggerEvent) { selectComponent.dispatchEvent(new CustomEvent('change', { detail: { value }, @@ -126,85 +95,73 @@ } }; - const toggleMultipleValue = (value, triggerEvent = true) => { - if (!isMultiple || value == null) return; - - const newValues = new Set(selectedValues); - if (newValues.has(value)) { - newValues.delete(value); - } else { - newValues.add(value); - } - - const selectedOptions = options.filter(opt => newValues.has(opt.dataset.value)); - updateValue(selectedOptions, triggerEvent); - }; - const closePopover = (focusOnTrigger = true) => { if (popover.getAttribute('aria-hidden') === 'true') return; - + if (filter) { const resetFilter = () => { filter.value = ''; visibleOptions = [...options]; allOptions.forEach(opt => opt.setAttribute('aria-hidden', 'false')); }; - + if (hasTransition()) { popover.addEventListener('transitionend', resetFilter, { once: true }); } else { resetFilter(); } } - + if (focusOnTrigger) trigger.focus(); popover.setAttribute('aria-hidden', 'true'); trigger.setAttribute('aria-expanded', 'false'); setActiveOption(-1); - } - - const selectOption = (option) => { - if (!option) return; - - const oldValue = input.value; - const newValue = option.dataset.value; - - if (newValue != null && newValue !== oldValue) { - updateValue(option); - } - - closePopover(); }; - const selectByValue = (value) => { - const option = options.find(opt => opt.dataset.value === value); - if (isMultiple) { - if (value != null && selectedValues.has(value)) return; - if (option && value != null) { - const newValues = new Set(selectedValues); - newValues.add(value); - const selectedOptions = options.filter(opt => newValues.has(opt.dataset.value)); - updateValue(selectedOptions); - } + const toggleMultipleValue = (option) => { + if (selectedOptions.has(option)) { + selectedOptions.delete(option); } else { - selectOption(option); + selectedOptions.add(option); + } + updateValue(options.filter(opt => selectedOptions.has(opt))); + }; + + const select = (value) => { + if (isMultiple) { + const option = options.find(opt => opt.dataset.value === value && !selectedOptions.has(opt)); + if (!option) return; + selectedOptions.add(option); + updateValue(options.filter(opt => selectedOptions.has(opt))); + } else { + const option = options.find(opt => opt.dataset.value === value); + if (!option) return; + if (input.value !== value) { + updateValue(option); + } + closePopover(); } }; - const selectAll = () => { + const deselect = (value) => { if (!isMultiple) return; - updateValue(options.filter(opt => opt.dataset.value != null)); + const option = options.find(opt => opt.dataset.value === value && selectedOptions.has(opt)); + if (!option) return; + selectedOptions.delete(option); + updateValue(options.filter(opt => selectedOptions.has(opt))); }; - const selectNone = () => { + const toggle = (value) => { if (!isMultiple) return; - updateValue([]); + const option = options.find(opt => opt.dataset.value === value); + if (!option) return; + toggleMultipleValue(option); }; if (filter) { const filterOptions = () => { const searchTerm = filter.value.trim().toLowerCase(); - + setActiveOption(-1); visibleOptions = []; @@ -230,35 +187,38 @@ } }); }; - + filter.addEventListener('input', filterOptions); } + // Initialization 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)); + const ariaSelected = options.filter(opt => opt.getAttribute('aria-selected') === 'true'); + try { + const parsed = JSON.parse(input.value || '[]'); + const validValues = new Set(options.map(opt => opt.dataset.value).filter(v => v != null)); + const initialValues = Array.isArray(parsed) ? parsed.filter(v => validValues.has(v)) : []; - let initialOptions; - if (initialValues.length > 0) { - initialOptions = options.filter(opt => initialValues.includes(opt.dataset.value)); - } else { - initialOptions = options.filter(opt => opt.getAttribute('aria-selected') === 'true'); + const initialOptions = []; + if (initialValues.length > 0) { + // Match values to options in order, allowing duplicates + initialValues.forEach(val => { + const opt = options.find(o => o.dataset.value === val && !initialOptions.includes(o)); + if (opt) initialOptions.push(opt); + }); + } else { + initialOptions.push(...ariaSelected); + } + + updateValue(initialOptions, false); + } catch (e) { + updateValue(ariaSelected, false); } - - updateValue(initialOptions, false); } else { - let initialOption = options.find(opt => opt.dataset.value === input.value); - - if (!initialOption) { - initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0]; - } - - if (initialOption) { - updateValue(initialOption, false); - } + const initialOption = options.find(opt => opt.dataset.value === input.value) + || options.find(opt => opt.dataset.value !== undefined) + || options[0]; + if (initialOption) updateValue(initialOption, false); } const handleKeyNavigation = (event) => { @@ -275,20 +235,24 @@ } return; } - + event.preventDefault(); if (event.key === 'Escape') { closePopover(); return; } - + if (event.key === 'Enter') { if (activeIndex > -1) { + const option = options[activeIndex]; if (isMultiple) { - toggleMultipleValue(options[activeIndex].dataset.value); + toggleMultipleValue(option); } else { - selectOption(options[activeIndex]); + if (input.value !== option.dataset.value) { + updateValue(option); + } + closePopover(); } } return; @@ -355,7 +319,7 @@ document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: selectComponent } })); - + if (filter) { if (hasTransition()) { popover.addEventListener('transitionend', () => { @@ -368,7 +332,7 @@ popover.setAttribute('aria-hidden', 'false'); trigger.setAttribute('aria-expanded', 'true'); - + const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]'); if (selectedOption) { setActiveOption(options.indexOf(selectedOption)); @@ -387,18 +351,24 @@ listbox.addEventListener('click', (event) => { const clickedOption = event.target.closest('[role="option"]'); - if (clickedOption) { - if (isMultiple) { - toggleMultipleValue(clickedOption.dataset.value); - setActiveOption(options.indexOf(clickedOption)); - if (filter) { - filter.focus(); - } else { - trigger.focus(); - } + if (!clickedOption) return; + + const option = options.find(opt => opt === clickedOption); + if (!option) return; + + if (isMultiple) { + toggleMultipleValue(option); + setActiveOption(options.indexOf(option)); + if (filter) { + filter.focus(); } else { - selectOption(clickedOption); + trigger.focus(); } + } else { + if (input.value !== option.dataset.value) { + updateValue(option); + } + closePopover(); } }); @@ -416,10 +386,41 @@ popover.setAttribute('aria-hidden', 'true'); - selectComponent.selectByValue = selectByValue; + // Public API + Object.defineProperty(selectComponent, 'value', { + get: () => { + if (isMultiple) { + return options.filter(opt => selectedOptions.has(opt)).map(opt => opt.dataset.value); + } else { + return input.value; + } + }, + set: (val) => { + if (isMultiple) { + const values = Array.isArray(val) ? val : (val != null ? [val] : []); + const opts = []; + values.forEach(v => { + const opt = options.find(o => o.dataset.value === v && !opts.includes(o)); + if (opt) opts.push(opt); + }); + updateValue(opts); + } else { + const option = options.find(opt => opt.dataset.value === val); + if (option) { + updateValue(option); + closePopover(); + } + } + } + }); + + selectComponent.select = select; + selectComponent.selectByValue = select; // Backward compatibility alias if (isMultiple) { - selectComponent.selectAll = selectAll; - selectComponent.selectNone = selectNone; + selectComponent.deselect = deselect; + selectComponent.toggle = toggle; + selectComponent.selectAll = () => updateValue(options.filter(opt => opt.dataset.value != null)); + selectComponent.selectNone = () => updateValue([]); } selectComponent.dataset.selectInitialized = true; selectComponent.dispatchEvent(new CustomEvent('basecoat:initialized')); diff --git a/src/nunjucks/select.njk b/src/nunjucks/select.njk index 75b34c8..8c87650 100644 --- a/src/nunjucks/select.njk +++ b/src/nunjucks/select.njk @@ -135,28 +135,14 @@ {% endif %} - {% if multiple and selectedArray %} - {% for val in selectedArray %} - 0 %}id="{{ id }}-value-{{ loop.index0 }}"{% endif %} - {% for key, value in input_attrs %} - {% if key != 'name' and key != 'value' %}{{ key }}="{{ value }}"{% endif %} - {% endfor %} - > + - {% endif %} + > {% endmacro %}