mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-12 13:18:22 +00:00
466 lines
18 KiB
JavaScript
466 lines
18 KiB
JavaScript
(() => {
|
|
const states = new WeakMap();
|
|
|
|
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 getLabel = (option) => option.dataset.label || option.textContent.trim();
|
|
const getFormat = (root) => root.dataset.format === 'object' ? 'object' : 'value';
|
|
const isDisabled = (option) => option.getAttribute('aria-disabled') === 'true';
|
|
const toSelected = (option) => ({ value: getValue(option), label: getLabel(option) });
|
|
|
|
const getOptions = (listbox) => {
|
|
const allOptions = Array.from(listbox.querySelectorAll('[role="option"]'));
|
|
return {
|
|
allOptions,
|
|
options: allOptions.filter(option => !isDisabled(option)),
|
|
};
|
|
};
|
|
|
|
const parseStoredValues = (storedValue, { isMultiple, format }) => {
|
|
if (isMultiple) {
|
|
try {
|
|
const parsed = JSON.parse(storedValue || '[]');
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed
|
|
.map(item => format === 'object' && item && typeof item === 'object' ? item.value : item)
|
|
.filter(value => value != null)
|
|
.map(String);
|
|
} catch (_) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
if (format === 'object') {
|
|
try {
|
|
const parsed = JSON.parse(storedValue || 'null');
|
|
return parsed && typeof parsed === 'object' && parsed.value != null ? String(parsed.value) : '';
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
return storedValue || '';
|
|
};
|
|
|
|
const serializeSelection = (state, selected) => {
|
|
if (state.format === 'object') {
|
|
return JSON.stringify(state.isMultiple ? selected : (selected[0] || null));
|
|
}
|
|
|
|
const value = selected.map(item => item.value);
|
|
return state.isMultiple ? JSON.stringify(value) : (value[0] || '');
|
|
};
|
|
|
|
const showPlaceholder = (state) => {
|
|
state.selectedLabel.textContent = state.placeholder || '';
|
|
state.selectedLabel.classList.toggle('text-muted-foreground', Boolean(state.placeholder));
|
|
state.input.value = state.isMultiple ? serializeSelection(state, []) : '';
|
|
};
|
|
|
|
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;
|
|
let selectedDetail;
|
|
|
|
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));
|
|
selectedDetail = selectedInOrder.map(toSelected);
|
|
if (selectedInOrder.length === 0) {
|
|
state.selectedLabel.textContent = state.placeholder;
|
|
state.selectedLabel.classList.add('text-muted-foreground');
|
|
} else {
|
|
state.selectedLabel.textContent = selectedDetail.map(item => item.label).join(', ');
|
|
state.selectedLabel.classList.remove('text-muted-foreground');
|
|
}
|
|
|
|
value = selectedDetail.map(item => item.value);
|
|
state.input.value = serializeSelection(state, selectedDetail);
|
|
} else {
|
|
const option = optionOrOptions;
|
|
if (!option) {
|
|
state.options.forEach(option => option.removeAttribute('aria-selected'));
|
|
showPlaceholder(state);
|
|
selectedDetail = null;
|
|
value = '';
|
|
} else {
|
|
if (option.dataset.label) {
|
|
state.selectedLabel.textContent = option.dataset.label;
|
|
} else {
|
|
state.selectedLabel.innerHTML = option.innerHTML;
|
|
}
|
|
state.selectedLabel.classList.remove('text-muted-foreground');
|
|
selectedDetail = toSelected(option);
|
|
value = selectedDetail.value;
|
|
state.input.value = serializeSelection(state, [selectedDetail]);
|
|
}
|
|
}
|
|
|
|
state.options.forEach(option => {
|
|
const isSelected = state.isMultiple ? state.selectedOptions.has(option) : optionOrOptions && option === optionOrOptions;
|
|
if (isSelected) {
|
|
option.setAttribute('aria-selected', 'true');
|
|
} else {
|
|
option.removeAttribute('aria-selected');
|
|
}
|
|
});
|
|
|
|
if (triggerEvent) {
|
|
root.dispatchEvent(new CustomEvent('change', {
|
|
detail: { value, selected: selectedDetail },
|
|
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) {
|
|
const missing = [];
|
|
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);
|
|
return;
|
|
}
|
|
|
|
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.format = getFormat(root);
|
|
state.placeholder = root.dataset.placeholder || '';
|
|
state.closeOnSelect = root.dataset.closeOnSelect === 'true';
|
|
|
|
if (state.isMultiple) {
|
|
if (!state.selectedOptions) state.selectedOptions = new Set();
|
|
const values = parseStoredValues(previousValue, state);
|
|
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);
|
|
} else {
|
|
const value = parseStoredValues(previousValue, state);
|
|
const selected = value === '' && state.placeholder
|
|
? null
|
|
: state.options.find(option => getValue(option) === value)
|
|
|| state.options.find(option => option.getAttribute('aria-selected') === 'true');
|
|
state.options.forEach(option => option.removeAttribute('aria-selected'));
|
|
updateValue(root, selected || null, false);
|
|
}
|
|
|
|
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
|
|
setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1);
|
|
};
|
|
|
|
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.placeholder && getValue(option) === '') {
|
|
updateValue(root, null);
|
|
closePopover(state);
|
|
return;
|
|
}
|
|
if (root.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();
|
|
}
|
|
return;
|
|
}
|
|
|
|
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.placeholder && getValue(option) === '') {
|
|
updateValue(root, null);
|
|
} else if (root.value !== getValue(option)) {
|
|
updateValue(root, option);
|
|
}
|
|
root.close();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state.visibleOptions.length === 0) return;
|
|
|
|
const currentVisibleIndex = state.activeIndex > -1 ? state.visibleOptions.indexOf(state.options[state.activeIndex]) : -1;
|
|
let nextVisibleIndex = currentVisibleIndex;
|
|
|
|
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;
|
|
|
|
if (nextVisibleIndex !== currentVisibleIndex) {
|
|
const newActiveOption = state.visibleOptions[nextVisibleIndex];
|
|
setActiveOption(state, state.options.indexOf(newActiveOption));
|
|
scrollOptionIntoListbox(state, newActiveOption);
|
|
}
|
|
};
|
|
|
|
const initSelect = (root) => {
|
|
if (root.dataset.selectInitialized) return;
|
|
|
|
const state = { activeIndex: -1, selectedOptions: null, options: [], allOptions: [], visibleOptions: [], format: 'value' };
|
|
states.set(root, state);
|
|
root.refresh = () => refreshSelect(root);
|
|
|
|
refreshSelect(root);
|
|
if (!state.trigger || !state.selectedLabel || !state.popover || !state.listbox || !state.input) {
|
|
states.delete(root);
|
|
delete root.refresh;
|
|
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);
|
|
}
|
|
};
|
|
root.close = (focusOnTrigger = true) => closePopover(state, focusOnTrigger);
|
|
root.togglePopover = () => state.trigger.getAttribute('aria-expanded') === 'true' ? root.close() : root.open();
|
|
|
|
const handleTriggerKeydown = (event) => handleKeyNavigation(event, root);
|
|
const handleTriggerClick = root.togglePopover;
|
|
const handleListboxMousemove = (event) => {
|
|
const option = event.target.closest('[role="option"]');
|
|
if (option && state.visibleOptions.includes(option)) {
|
|
const index = state.options.indexOf(option);
|
|
if (index !== state.activeIndex) setActiveOption(state, index);
|
|
}
|
|
};
|
|
const handleListboxMouseleave = () => {
|
|
const selectedOption = state.listbox.querySelector('[role="option"][aria-selected="true"]');
|
|
setActiveOption(state, selectedOption ? state.options.indexOf(selectedOption) : -1);
|
|
};
|
|
const handleListboxClick = (event) => {
|
|
const clickedOption = event.target.closest('[role="option"]');
|
|
if (!clickedOption) return;
|
|
|
|
const option = state.options.find(opt => opt === clickedOption);
|
|
if (!option) return;
|
|
|
|
if (state.isMultiple) {
|
|
toggleMultipleValue(root, option);
|
|
if (state.closeOnSelect) {
|
|
root.close();
|
|
} else {
|
|
setActiveOption(state, state.options.indexOf(option));
|
|
state.trigger.focus();
|
|
}
|
|
} else {
|
|
if (state.placeholder && getValue(option) === '') {
|
|
updateValue(root, null);
|
|
} else if (root.value !== getValue(option)) {
|
|
updateValue(root, option);
|
|
}
|
|
root.close();
|
|
}
|
|
};
|
|
const handleDocumentClick = (event) => {
|
|
if (!root.contains(event.target)) root.close(false);
|
|
};
|
|
const handleDocumentPopover = (event) => {
|
|
if (event.detail.source !== root) root.close(false);
|
|
};
|
|
|
|
state.trigger.addEventListener('keydown', handleTriggerKeydown);
|
|
state.trigger.addEventListener('click', handleTriggerClick);
|
|
state.listbox.addEventListener('mousemove', handleListboxMousemove);
|
|
state.listbox.addEventListener('mouseleave', handleListboxMouseleave);
|
|
state.listbox.addEventListener('click', handleListboxClick);
|
|
document.addEventListener('click', handleDocumentClick);
|
|
document.addEventListener('basecoat:popover', handleDocumentPopover);
|
|
|
|
root._destroy = () => {
|
|
state.trigger.removeEventListener('keydown', handleTriggerKeydown);
|
|
state.trigger.removeEventListener('click', handleTriggerClick);
|
|
state.listbox.removeEventListener('mousemove', handleListboxMousemove);
|
|
state.listbox.removeEventListener('mouseleave', handleListboxMouseleave);
|
|
state.listbox.removeEventListener('click', handleListboxClick);
|
|
document.removeEventListener('click', handleDocumentClick);
|
|
document.removeEventListener('basecoat:popover', handleDocumentPopover);
|
|
states.delete(root);
|
|
delete root.refresh;
|
|
delete root.open;
|
|
delete root.close;
|
|
delete root.togglePopover;
|
|
delete root.select;
|
|
delete root.selectByValue;
|
|
delete root.deselect;
|
|
delete root.toggle;
|
|
delete root.selectAll;
|
|
delete root.selectNone;
|
|
};
|
|
|
|
Object.defineProperty(root, 'value', {
|
|
configurable: true,
|
|
get: () => state.isMultiple ? state.options.filter(option => state.selectedOptions.has(option)).map(getValue) : parseStoredValues(state.input.value, state),
|
|
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));
|
|
} else {
|
|
if (value == null || value === '') {
|
|
updateValue(root, null);
|
|
root.close();
|
|
return;
|
|
}
|
|
const option = state.options.find(opt => getValue(opt) === value);
|
|
if (option) {
|
|
updateValue(root, option);
|
|
root.close();
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
Object.defineProperty(root, 'selected', {
|
|
configurable: true,
|
|
get: () => {
|
|
if (state.isMultiple) return state.options.filter(option => state.selectedOptions.has(option)).map(toSelected);
|
|
const value = root.value;
|
|
const option = state.options.find(opt => getValue(opt) === value);
|
|
return option ? toSelected(option) : null;
|
|
},
|
|
});
|
|
|
|
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, []);
|
|
}
|
|
|
|
state.popover.setAttribute('aria-hidden', 'true');
|
|
state.trigger.setAttribute('aria-expanded', 'false');
|
|
root.dataset.selectInitialized = 'true';
|
|
root.dispatchEvent(new CustomEvent('basecoat:initialized'));
|
|
};
|
|
|
|
if (window.basecoat) {
|
|
window.basecoat.register('select', {
|
|
selector: 'div.select:not([data-select-initialized])',
|
|
init: initSelect,
|
|
refresh: refreshSelect,
|
|
});
|
|
}
|
|
})();
|