diff --git a/docs/src/_includes/partials/kitchen-sink/form.njk b/docs/src/_includes/partials/kitchen-sink/form.njk index 5781255..e3a93a2 100644 --- a/docs/src/_includes/partials/kitchen-sink/form.njk +++ b/docs/src/_includes/partials/kitchen-sink/form.njk @@ -1,6 +1,9 @@

Form

+ + {% lucide "book-open", { "class": "size-4" } %} +
diff --git a/src/js/select.js b/src/js/select.js index 352dcb8..338761b 100644 --- a/src/js/select.js +++ b/src/js/select.js @@ -16,6 +16,10 @@ return; } + const isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true'; + const initialSelectedLabel = selectedLabel.cloneNode(true); + const allowedMultiSelectBadgeVariants = ['secondary', 'destructive', 'ghost', 'outline']; + const allOptions = Array.from(listbox.querySelectorAll('[role="option"]')); const options = allOptions.filter(opt => opt.getAttribute('aria-disabled') !== 'true'); let visibleOptions = [...options]; @@ -48,18 +52,156 @@ const updateValue = (option, triggerEvent = true) => { if (option) { - selectedLabel.innerHTML = option.dataset.label || option.innerHTML; - input.value = option.dataset.value; - listbox.querySelector('[role="option"][aria-selected="true"]')?.removeAttribute('aria-selected'); + if (isMultiSelect) { + updateValueMultiSelect(option, triggerEvent); + } else { + selectedLabel.innerHTML = option.dataset.label || 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 }, + bubbles: true + }); + selectComponent.dispatchEvent(event); + } + } + } + }; + + const updateValueMultiSelect = (option, triggerEvent = true) => { + const selectedOption = option.getAttribute('aria-selected'); + if (selectedOption === 'true') { + option.setAttribute('aria-selected', 'false'); + } else if (selectedOption === 'false') { option.setAttribute('aria-selected', 'true'); - - if (triggerEvent) { - const event = new CustomEvent('change', { - detail: { value: option.dataset.value }, - bubbles: true + } + + const selected = listbox.querySelectorAll('[role="option"][aria-selected="true"]'); + const threshold = listbox.dataset.multiselectThreshold || selected.length + + if (selected.length > threshold) { + + const thresholdText = document.createElement('p'); + thresholdText.textContent = `${selected.length} ` + (listbox.dataset.multiselectThresholdText || 'entries selected') + + if (listbox.getAttribute('aria-controls') !== null) { + const controlledElementId = listbox.getAttribute('aria-controls'); + const controlledElement = document.getElementById(controlledElementId); + if (controlledElement !== null) { + controlledElement.replaceChildren(thresholdText); + } else { + console.error(`aria-controls of listbox references an element that does not exist: ${controlledElementId}`, selectComponent); + return; + } + } else { + selectedLabel.replaceChildren(thresholdText); + } + } else if (selected.length > 0) { + const badgeContainer = document.createElement('div'); + badgeContainer.className = 'items-center gap-1 flex'; + + selected.forEach(opt => { + if (opt.dataset.multiselectDisplay !== undefined) { + let display = document.getElementById(opt.dataset.multiselectDisplay); + if (display === null) { + console.error(`data-multiselect-display of option references an element that does not exist: ${opt}`, selectComponent); + return; + } + display = display.cloneNode(true) + display.removeAttribute('hidden') + display.removeAttribute('id') + + badgeContainer.appendChild(display); + } else { + const span = document.createElement('span'); + const variant = opt.dataset.multiselectVariant; + if (variant !== undefined && allowedMultiSelectBadgeVariants.includes(variant)) { + span.className = `badge-${variant}`; + } else { + span.className = 'badge'; + } + span.className += ' font-normal'; + span.textContent = opt.dataset.label || opt.innerHTML; + + const removeButton = document.createElement('button'); + removeButton.className = 'btn-ghost w-4 h-4 -m-1'; + removeButton.innerHTML = ` `; + removeButton.addEventListener('click', () => { + updateValue(opt); + }); + + span.insertBefore(removeButton, span.firstChild); + badgeContainer.appendChild(span); + } + }); + + if (listbox.getAttribute('aria-controls') !== null) { + const controlledElementId = listbox.getAttribute('aria-controls'); + const controlledElement = document.getElementById(controlledElementId); + if (controlledElement !== null) { + controlledElement.replaceChildren(...badgeContainer.childNodes); + } else { + console.error(`aria-controls of listbox references an element that does not exist: ${controlledElementId}`, selectComponent); + return; + } + } else { + selectedLabel.replaceChildren(badgeContainer); + } + + const clearButton = listbox.querySelector('[data-multiselect-clearelement]'); + + if (clearButton === null) { + const listboxSeparator = document.createElement('hr'); + listboxSeparator.setAttribute('data-multiselect-clearelement', ""); + listboxSeparator.role = 'separator'; + + const clearSelectionOption = document.createElement('button'); + clearSelectionOption.setAttribute('data-multiselect-clearelement', ""); + clearSelectionOption.className = 'btn-ghost w-full justify-center'; + clearSelectionOption.textContent = listbox.dataset.multiselectClosetext || 'Clear'; + clearSelectionOption.addEventListener('click', () => { + const selected = listbox.querySelectorAll('[role="option"][aria-selected="true"]'); + selected.forEach(opt => { + opt.setAttribute('aria-selected', 'false'); + }); + updateValue(clearSelectionOption); }); - selectComponent.dispatchEvent(event); + listbox.append(listboxSeparator, clearSelectionOption) + } + } else if (selected.length === 0) { + if (listbox.getAttribute('aria-controls') !== null) { + const controlledElementId = listbox.getAttribute('aria-controls'); + const controlledElement = document.getElementById(controlledElementId); + if (controlledElement !== null) { + controlledElement.replaceChildren(); + } else { + console.error(`aria-controls of listbox references an element that does not exist: ${controlledElementId}`, selectComponent); + return; + } + } else { + let initial = initialSelectedLabel.cloneNode(true); + selectedLabel.replaceChildren(...initial.childNodes); } + + const clearButtonElements = listbox.querySelectorAll('[data-multiselect-clearelement]'); + clearButtonElements.forEach(el => { + listbox.removeChild(el); + }); + } + + const inputValue = JSON.stringify(Array.from(selected).map(opt => opt.dataset.value)) + + input.value = inputValue + + if (triggerEvent) { + const event = new CustomEvent('change', { + detail: { value: inputValue }, + bubbles: true + }); + selectComponent.dispatchEvent(event); } }; @@ -96,7 +238,10 @@ updateValue(option); } - closePopover(); + if (!isMultiSelect) + { + closePopover(); + } }; const selectByValue = (value) => { @@ -130,7 +275,9 @@ initialOption = options.find(opt => opt.dataset.value !== undefined) ?? options[0]; } - updateValue(initialOption, false); + if (!isMultiSelect) { + updateValue(initialOption, false); + } const handleKeyNavigation = (event) => { const isPopoverOpen = popover.getAttribute('aria-hidden') === 'false';