Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/src/_includes/partials/kitchen-sink/form.njk
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<section id="form" class="w-full rounded-lg border scroll-mt-16">
<header class="border-b px-4 py-3">
<h2 class="text-sm font-medium">Form</h2>
<a href="/components/form" class="text-muted-foreground hover:text-foreground" data-tooltip="See documentation" data-side="left">
{% lucide "book-open", { "class": "size-4" } %}
</a>
</header>
<div class="p-4">
<form class="form grid w-full max-w-sm gap-6">
Expand Down
169 changes: 158 additions & 11 deletions src/js/select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path> <path d="m6 6 12 12"></path></svg>`;
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);
}
};

Expand Down Expand Up @@ -96,7 +238,10 @@
updateValue(option);
}

closePopover();
if (!isMultiSelect)
{
closePopover();
}
};

const selectByValue = (value) => {
Expand Down Expand Up @@ -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';
Expand Down