2025-06-12 10:37:19 +08:00
|
|
|
(() => {
|
2026-06-06 16:41:18 +08:00
|
|
|
const states = new WeakMap();
|
2026-01-04 14:39:32 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const getElements = (root) => {
|
|
|
|
|
const trigger = root.querySelector(':scope > button');
|
|
|
|
|
const selectedLabel = trigger?.querySelector(':scope > span') || null;
|
|
|
|
|
const popover = root.querySelector(':scope > [data-popover]');
|
|
|
|
|
const listbox = popover ? popover.querySelector('[role="listbox"]') : null;
|
|
|
|
|
const input = root.querySelector(':scope > input[type="hidden"]');
|
|
|
|
|
return { trigger, selectedLabel, popover, listbox, input };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getValue = (option) => option.dataset.value ?? option.textContent.trim();
|
|
|
|
|
const isDisabled = (option) => option.getAttribute('aria-disabled') === 'true';
|
|
|
|
|
|
|
|
|
|
const getOptions = (listbox) => {
|
|
|
|
|
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
|
|
|
|
|
return {
|
|
|
|
|
allOptions,
|
|
|
|
|
options: allOptions.filter(option => !isDisabled(option)),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scrollOptionIntoListbox = (state, option) => {
|
|
|
|
|
const optionRect = option.getBoundingClientRect();
|
|
|
|
|
const listboxRect = state.listbox.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
if (optionRect.top < listboxRect.top) {
|
|
|
|
|
state.listbox.scrollTop -= listboxRect.top - optionRect.top;
|
|
|
|
|
} else if (optionRect.bottom > listboxRect.bottom) {
|
|
|
|
|
state.listbox.scrollTop += optionRect.bottom - listboxRect.bottom;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const setActiveOption = (state, index) => {
|
|
|
|
|
if (state.activeIndex > -1 && state.options[state.activeIndex]) {
|
|
|
|
|
state.options[state.activeIndex].classList.remove('active');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.activeIndex = index;
|
|
|
|
|
|
|
|
|
|
if (state.activeIndex > -1) {
|
|
|
|
|
const activeOption = state.options[state.activeIndex];
|
|
|
|
|
activeOption.classList.add('active');
|
|
|
|
|
if (activeOption.id) {
|
|
|
|
|
state.trigger.setAttribute('aria-activedescendant', activeOption.id);
|
|
|
|
|
} else {
|
|
|
|
|
state.trigger.removeAttribute('aria-activedescendant');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
state.trigger.removeAttribute('aria-activedescendant');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateValue = (root, optionOrOptions, triggerEvent = true) => {
|
|
|
|
|
const state = states.get(root);
|
|
|
|
|
let value;
|
|
|
|
|
|
|
|
|
|
if (state.isMultiple) {
|
|
|
|
|
const selected = Array.isArray(optionOrOptions) ? optionOrOptions : [];
|
|
|
|
|
state.selectedOptions.clear();
|
|
|
|
|
selected.forEach(option => state.selectedOptions.add(option));
|
|
|
|
|
|
|
|
|
|
const selectedInOrder = state.options.filter(option => state.selectedOptions.has(option));
|
|
|
|
|
if (selectedInOrder.length === 0) {
|
|
|
|
|
state.selectedLabel.textContent = state.placeholder;
|
|
|
|
|
state.selectedLabel.classList.add('text-muted-foreground');
|
|
|
|
|
} else {
|
|
|
|
|
state.selectedLabel.textContent = selectedInOrder.map(option => option.dataset.label || option.textContent.trim()).join(', ');
|
|
|
|
|
state.selectedLabel.classList.remove('text-muted-foreground');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
value = selectedInOrder.map(getValue);
|
|
|
|
|
state.input.value = JSON.stringify(value);
|
|
|
|
|
} else {
|
|
|
|
|
const option = optionOrOptions;
|
|
|
|
|
if (!option) return;
|
|
|
|
|
state.selectedLabel.innerHTML = option.innerHTML;
|
|
|
|
|
value = getValue(option);
|
|
|
|
|
state.input.value = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.options.forEach(option => {
|
|
|
|
|
const isSelected = state.isMultiple ? state.selectedOptions.has(option) : option === optionOrOptions;
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
option.setAttribute('aria-selected', 'true');
|
|
|
|
|
} else {
|
|
|
|
|
option.removeAttribute('aria-selected');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (triggerEvent) {
|
|
|
|
|
root.dispatchEvent(new CustomEvent('change', {
|
|
|
|
|
detail: { value },
|
|
|
|
|
bubbles: true,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closePopover = (state, focusOnTrigger = true) => {
|
|
|
|
|
if (state.popover.getAttribute('aria-hidden') === 'true') return;
|
|
|
|
|
if (focusOnTrigger) state.trigger.focus();
|
|
|
|
|
state.popover.setAttribute('aria-hidden', 'true');
|
|
|
|
|
state.trigger.setAttribute('aria-expanded', 'false');
|
|
|
|
|
setActiveOption(state, -1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const refreshSelect = (root) => {
|
|
|
|
|
const state = states.get(root);
|
|
|
|
|
if (!state) return;
|
|
|
|
|
|
|
|
|
|
const elements = getElements(root);
|
|
|
|
|
if (!elements.trigger || !elements.selectedLabel || !elements.popover || !elements.listbox || !elements.input) {
|
2025-06-18 19:42:26 +08:00
|
|
|
const missing = [];
|
2026-06-06 16:41:18 +08:00
|
|
|
if (!elements.trigger) missing.push('trigger');
|
|
|
|
|
if (!elements.selectedLabel) missing.push('selected label');
|
|
|
|
|
if (!elements.popover) missing.push('popover');
|
|
|
|
|
if (!elements.listbox) missing.push('listbox');
|
|
|
|
|
if (!elements.input) missing.push('input');
|
|
|
|
|
console.error(`Select component refresh failed. Missing element(s): ${missing.join(', ')}`, root);
|
2025-06-18 19:42:26 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-04 14:39:32 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const previousValue = elements.input.value;
|
|
|
|
|
Object.assign(state, elements, getOptions(elements.listbox));
|
|
|
|
|
state.visibleOptions = [...state.options];
|
|
|
|
|
state.isMultiple = state.listbox.getAttribute('aria-multiselectable') === 'true';
|
|
|
|
|
state.placeholder = state.isMultiple ? (root.dataset.placeholder || '') : null;
|
|
|
|
|
state.closeOnSelect = root.dataset.closeOnSelect === 'true';
|
2025-05-01 20:53:03 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
if (state.isMultiple) {
|
|
|
|
|
if (!state.selectedOptions) state.selectedOptions = new Set();
|
|
|
|
|
let values = [];
|
2026-01-05 11:28:05 +08:00
|
|
|
try {
|
2026-06-06 16:41:18 +08:00
|
|
|
const parsed = JSON.parse(previousValue || '[]');
|
|
|
|
|
values = Array.isArray(parsed) ? parsed : [];
|
|
|
|
|
} catch (_) {
|
|
|
|
|
values = [];
|
2026-01-04 14:39:32 +08:00
|
|
|
}
|
2026-06-06 16:41:18 +08:00
|
|
|
const selected = values.length
|
|
|
|
|
? values.map(value => state.options.find(option => getValue(option) === value)).filter(Boolean)
|
|
|
|
|
: state.options.filter(option => option.getAttribute('aria-selected') === 'true');
|
|
|
|
|
updateValue(root, selected, false);
|
2026-01-04 14:39:32 +08:00
|
|
|
} else {
|
2026-06-06 16:41:18 +08:00
|
|
|
const selected = state.options.find(option => getValue(option) === previousValue)
|
|
|
|
|
|| state.options.find(option => option.getAttribute('aria-selected') === 'true');
|
|
|
|
|
state.options.forEach(option => option.removeAttribute('aria-selected'));
|
|
|
|
|
if (selected) updateValue(root, selected, false);
|
2026-01-04 14:39:32 +08:00
|
|
|
}
|
2025-06-12 10:37:19 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
|
|
|
|
|
setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1);
|
|
|
|
|
};
|
2025-06-16 17:58:01 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const toggleMultipleValue = (root, option) => {
|
|
|
|
|
const state = states.get(root);
|
|
|
|
|
if (state.selectedOptions.has(option)) {
|
|
|
|
|
state.selectedOptions.delete(option);
|
|
|
|
|
} else {
|
|
|
|
|
state.selectedOptions.add(option);
|
|
|
|
|
}
|
|
|
|
|
updateValue(root, state.options.filter(opt => state.selectedOptions.has(opt)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectValue = (root, value) => {
|
|
|
|
|
const state = states.get(root);
|
|
|
|
|
if (state.isMultiple) {
|
|
|
|
|
const option = state.options.find(opt => getValue(opt) === value && !state.selectedOptions.has(opt));
|
|
|
|
|
if (!option) return;
|
|
|
|
|
state.selectedOptions.add(option);
|
|
|
|
|
updateValue(root, state.options.filter(opt => state.selectedOptions.has(opt)));
|
|
|
|
|
} else {
|
|
|
|
|
const option = state.options.find(opt => getValue(opt) === value);
|
|
|
|
|
if (!option) return;
|
|
|
|
|
if (state.input.value !== value) updateValue(root, option);
|
|
|
|
|
closePopover(state);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const deselectValue = (root, value) => {
|
|
|
|
|
const state = states.get(root);
|
|
|
|
|
if (!state.isMultiple) return;
|
|
|
|
|
const option = state.options.find(opt => getValue(opt) === value && state.selectedOptions.has(opt));
|
|
|
|
|
if (!option) return;
|
|
|
|
|
state.selectedOptions.delete(option);
|
|
|
|
|
updateValue(root, state.options.filter(opt => state.selectedOptions.has(opt)));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyNavigation = (event, root) => {
|
|
|
|
|
const state = states.get(root);
|
|
|
|
|
const isPopoverOpen = state.popover.getAttribute('aria-hidden') === 'false';
|
|
|
|
|
|
|
|
|
|
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Home', 'End', 'Escape'].includes(event.key)) return;
|
|
|
|
|
|
|
|
|
|
if (!isPopoverOpen) {
|
|
|
|
|
if (event.key !== 'Enter' && event.key !== 'Escape') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
root.open();
|
2025-05-01 20:53:03 +08:00
|
|
|
}
|
2026-06-06 16:41:18 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
root.close();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Enter') {
|
|
|
|
|
if (state.activeIndex > -1) {
|
|
|
|
|
const option = state.options[state.activeIndex];
|
|
|
|
|
if (state.isMultiple) {
|
|
|
|
|
toggleMultipleValue(root, option);
|
|
|
|
|
if (state.closeOnSelect) root.close();
|
|
|
|
|
} else {
|
|
|
|
|
if (state.input.value !== getValue(option)) updateValue(root, option);
|
|
|
|
|
root.close();
|
2025-06-12 18:44:17 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-06 16:41:18 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 11:28:05 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
if (state.visibleOptions.length === 0) return;
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const currentVisibleIndex = state.activeIndex > -1 ? state.visibleOptions.indexOf(state.options[state.activeIndex]) : -1;
|
|
|
|
|
let nextVisibleIndex = currentVisibleIndex;
|
2026-01-05 11:28:05 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
if (event.key === 'ArrowDown' && currentVisibleIndex < state.visibleOptions.length - 1) nextVisibleIndex = currentVisibleIndex + 1;
|
|
|
|
|
if (event.key === 'ArrowUp') nextVisibleIndex = currentVisibleIndex > 0 ? currentVisibleIndex - 1 : 0;
|
|
|
|
|
if (event.key === 'Home') nextVisibleIndex = 0;
|
|
|
|
|
if (event.key === 'End') nextVisibleIndex = state.visibleOptions.length - 1;
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
if (nextVisibleIndex !== currentVisibleIndex) {
|
|
|
|
|
const newActiveOption = state.visibleOptions[nextVisibleIndex];
|
|
|
|
|
setActiveOption(state, state.options.indexOf(newActiveOption));
|
|
|
|
|
scrollOptionIntoListbox(state, newActiveOption);
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const initSelect = (root) => {
|
|
|
|
|
if (root.dataset.selectInitialized) return;
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [] };
|
|
|
|
|
states.set(root, state);
|
|
|
|
|
root.refresh = () => refreshSelect(root);
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
refreshSelect(root);
|
|
|
|
|
if (!state.trigger || !state.selectedLabel || !state.popover || !state.listbox || !state.input) {
|
|
|
|
|
states.delete(root);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
root.open = () => {
|
|
|
|
|
document.dispatchEvent(new CustomEvent('basecoat:popover', { detail: { source: root } }));
|
|
|
|
|
root.refresh();
|
|
|
|
|
state.popover.setAttribute('aria-hidden', 'false');
|
|
|
|
|
state.trigger.setAttribute('aria-expanded', 'true');
|
|
|
|
|
|
|
|
|
|
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
|
|
|
|
|
if (selectedOption) {
|
|
|
|
|
setActiveOption(state, state.options.indexOf(selectedOption));
|
|
|
|
|
scrollOptionIntoListbox(state, selectedOption);
|
2025-06-12 18:44:17 +08:00
|
|
|
}
|
|
|
|
|
};
|
2026-06-06 16:41:18 +08:00
|
|
|
root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger);
|
|
|
|
|
root.togglePopover = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open();
|
2025-06-12 18:44:17 +08:00
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
state.trigger.addEventListener('keydown', (event) => handleKeyNavigation(event, root));
|
|
|
|
|
state.trigger.addEventListener('click', root.togglePopover);
|
|
|
|
|
|
|
|
|
|
state.listbox.addEventListener('mousemove', (event) => {
|
2025-06-25 09:27:29 +08:00
|
|
|
const option = event.target.closest('[role="option"]');
|
2026-06-06 16:41:18 +08:00
|
|
|
if (option && state.visibleOptions.includes(option)) {
|
|
|
|
|
const index = state.options.indexOf(option);
|
|
|
|
|
if (index !== state.activeIndex) setActiveOption(state, index);
|
2025-06-25 09:27:29 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
state.listbox.addEventListener('mouseleave', () => {
|
|
|
|
|
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
|
|
|
|
|
setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1);
|
2025-06-25 09:27:29 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
state.listbox.addEventListener('click', (event) => {
|
2025-06-18 19:42:26 +08:00
|
|
|
const clickedOption = event.target.closest('[role="option"]');
|
2026-01-05 11:28:05 +08:00
|
|
|
if (!clickedOption) return;
|
|
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
const option = state.options.find(opt => opt === clickedOption);
|
2026-01-05 11:28:05 +08:00
|
|
|
if (!option) return;
|
|
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
if (state.isMultiple) {
|
|
|
|
|
toggleMultipleValue(root, option);
|
|
|
|
|
if (state.closeOnSelect) {
|
|
|
|
|
root.close();
|
2026-01-04 14:39:32 +08:00
|
|
|
} else {
|
2026-06-06 16:41:18 +08:00
|
|
|
setActiveOption(state, state.options.indexOf(option));
|
|
|
|
|
state.trigger.focus();
|
2026-01-04 14:39:32 +08:00
|
|
|
}
|
2026-01-05 11:28:05 +08:00
|
|
|
} else {
|
2026-06-06 16:41:18 +08:00
|
|
|
if (state.input.value !== getValue(option)) updateValue(root, option);
|
|
|
|
|
root.close();
|
2025-06-12 18:44:17 +08:00
|
|
|
}
|
2025-06-12 10:37:19 +08:00
|
|
|
});
|
|
|
|
|
|
2025-06-18 19:42:26 +08:00
|
|
|
document.addEventListener('click', (event) => {
|
2026-06-06 16:41:18 +08:00
|
|
|
if (!root.contains(event.target)) root.close(false);
|
2025-06-12 10:37:19 +08:00
|
|
|
});
|
|
|
|
|
|
2025-06-18 20:55:32 +08:00
|
|
|
document.addEventListener('basecoat:popover', (event) => {
|
2026-06-06 16:41:18 +08:00
|
|
|
if (event.detail.source !== root) root.close(false);
|
2025-06-18 20:55:32 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
Object.defineProperty(root, 'value', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : state.input.value,
|
|
|
|
|
set: (value) => {
|
|
|
|
|
if (state.isMultiple) {
|
|
|
|
|
const values = Array.isArray(value) ? value : (value != null ? [value] : []);
|
|
|
|
|
updateValue(root, values.map(v => state.options.find(option => getValue(option) === v)).filter(Boolean));
|
2026-01-05 11:28:05 +08:00
|
|
|
} else {
|
2026-06-06 16:41:18 +08:00
|
|
|
const option = state.options.find(opt => getValue(opt) === value);
|
2026-01-05 11:28:05 +08:00
|
|
|
if (option) {
|
2026-06-06 16:41:18 +08:00
|
|
|
updateValue(root, option);
|
|
|
|
|
root.close();
|
2026-01-05 11:28:05 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-06 16:41:18 +08:00
|
|
|
},
|
2026-01-05 11:28:05 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-06 16:41:18 +08:00
|
|
|
root.select = (value) => selectValue(root, value);
|
|
|
|
|
root.selectByValue = root.select;
|
|
|
|
|
if (state.isMultiple) {
|
|
|
|
|
root.deselect = (value) => deselectValue(root, value);
|
|
|
|
|
root.toggle = (value) => {
|
|
|
|
|
const option = state.options.find(opt => getValue(opt) === value);
|
|
|
|
|
if (option) toggleMultipleValue(root, option);
|
|
|
|
|
};
|
|
|
|
|
root.selectAll = () => updateValue(root, state.options);
|
|
|
|
|
root.selectNone = () => updateValue(root, []);
|
2026-01-04 14:39:32 +08:00
|
|
|
}
|
2026-06-06 16:41:18 +08:00
|
|
|
|
|
|
|
|
state.popover.setAttribute('aria-hidden', 'true');
|
|
|
|
|
state.trigger.setAttribute('aria-expanded', 'false');
|
|
|
|
|
root.dataset.selectInitialized = 'true';
|
|
|
|
|
root.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
2025-06-12 10:37:19 +08:00
|
|
|
};
|
|
|
|
|
|
2025-07-28 18:16:41 +08:00
|
|
|
if (window.basecoat) {
|
2026-06-06 16:41:18 +08:00
|
|
|
window.basecoat.register('select', {
|
|
|
|
|
selector: 'div.select:not([data-select-initialized])',
|
|
|
|
|
init: initSelect,
|
|
|
|
|
refresh: refreshSelect,
|
|
|
|
|
});
|
2025-07-28 18:16:41 +08:00
|
|
|
}
|
2025-12-10 14:49:32 +08:00
|
|
|
})();
|