mirror of
https://github.com/mivodev/mivo.git
synced 2026-01-27 05:52:03 +07:00
262 lines
9.9 KiB
JavaScript
262 lines
9.9 KiB
JavaScript
class CustomSelect {
|
|
static instances = [];
|
|
|
|
constructor(selectElement) {
|
|
if (selectElement.dataset.customSelectInitialized === 'true') {
|
|
return;
|
|
}
|
|
selectElement.dataset.customSelectInitialized = 'true';
|
|
|
|
this.originalSelect = selectElement;
|
|
this.originalSelect.style.display = 'none';
|
|
this.options = Array.from(this.originalSelect.options);
|
|
|
|
// Settings
|
|
this.wrapper = document.createElement('div');
|
|
|
|
// Standard classes
|
|
let wrapperClasses = 'custom-select-wrapper relative active-select';
|
|
|
|
// Intelligent Width:
|
|
// If original select expects full width, wrapper must be full width.
|
|
// Otherwise, use w-fit (Crucial for Right-Alignment in toolbars to work).
|
|
const widthClass = Array.from(this.originalSelect.classList).find(c => c.startsWith('w-') && c !== 'w-full');
|
|
const isFullWidth = this.originalSelect.classList.contains('w-full') ||
|
|
this.originalSelect.classList.contains('form-control') ||
|
|
this.originalSelect.classList.contains('form-input');
|
|
|
|
if (widthClass) {
|
|
wrapperClasses += ' ' + widthClass;
|
|
} else if (isFullWidth) {
|
|
wrapperClasses += ' w-full';
|
|
} else {
|
|
wrapperClasses += ' w-fit';
|
|
}
|
|
|
|
this.wrapper.className = wrapperClasses;
|
|
|
|
this.init();
|
|
|
|
// Store instance
|
|
if (!CustomSelect.instances) CustomSelect.instances = [];
|
|
CustomSelect.instances.push(this);
|
|
}
|
|
|
|
init() {
|
|
// Create Trigger
|
|
this.trigger = document.createElement('div');
|
|
|
|
const isFilter = this.originalSelect.classList.contains('form-filter');
|
|
const baseClass = isFilter ? 'form-filter' : 'form-input';
|
|
|
|
this.trigger.className = `${baseClass} flex items-center justify-between cursor-pointer pr-3`;
|
|
this.trigger.style.paddingLeft = '0.75rem';
|
|
|
|
this.trigger.innerHTML = `
|
|
<span class="custom-select-value truncate text-foreground flex-1 text-left">${this.originalSelect.options[this.originalSelect.selectedIndex].text}</span>
|
|
<div class="custom-select-icon flex-shrink-0 ml-2 transition-transform duration-200 transform">
|
|
<i data-lucide="chevron-down" class="w-4 h-4 text-foreground opacity-70"></i>
|
|
</div>
|
|
`;
|
|
|
|
// Inherit classes from original select (excluding custom-select marker)
|
|
if (this.originalSelect.classList.length > 0) {
|
|
const inheritedClasses = Array.from(this.originalSelect.classList)
|
|
.filter(c => c !== 'custom-select' && c !== 'hidden')
|
|
.join(' ');
|
|
if (inheritedClasses) {
|
|
this.trigger.className += ' ' + inheritedClasses;
|
|
}
|
|
}
|
|
|
|
// Final sanity check for full width
|
|
if (this.wrapper.classList.contains('w-full')) {
|
|
this.trigger.classList.add('w-full');
|
|
}
|
|
|
|
// Create Options Menu Wrapper (No Scroll Here)
|
|
this.menu = document.createElement('div');
|
|
// Create Options Menu Wrapper (No Scroll Here)
|
|
// Create Options Menu Wrapper (No Scroll Here)
|
|
this.menu = document.createElement('div');
|
|
this.menu.className = 'custom-select-dropdown';
|
|
|
|
// Create Scrollable List Container
|
|
this.listContainer = document.createElement('div');
|
|
this.listContainer.className = 'overflow-y-auto flex-1 py-1 custom-scrollbar';
|
|
|
|
// Search Functionality
|
|
if (this.originalSelect.dataset.search === 'true') {
|
|
const searchContainer = document.createElement('div');
|
|
searchContainer.className = 'p-2 bg-background z-10 border-b border-accents-2 flex-shrink-0 rounded-t-md';
|
|
|
|
this.searchInput = document.createElement('input');
|
|
this.searchInput.type = 'text';
|
|
this.searchInput.className = 'w-full px-2 py-1 text-sm bg-accents-1 border border-accents-2 rounded focus:outline-none focus:ring-1 focus:ring-foreground';
|
|
this.searchInput.placeholder = 'Search...';
|
|
|
|
searchContainer.appendChild(this.searchInput);
|
|
this.menu.appendChild(searchContainer);
|
|
|
|
// Search Event
|
|
this.searchInput.addEventListener('input', (e) => {
|
|
const term = e.target.value.toLowerCase();
|
|
this.options.forEach((option, index) => {
|
|
const item = this.listContainer.querySelector(`[data-index="${index}"]`);
|
|
if (item) {
|
|
const text = option.text.toLowerCase();
|
|
item.style.display = text.includes(term) ? 'flex' : 'none';
|
|
}
|
|
});
|
|
});
|
|
|
|
this.searchInput.addEventListener('click', (e) => e.stopPropagation());
|
|
}
|
|
|
|
// Build Options
|
|
this.options.forEach((option, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center justify-between whitespace-nowrap';
|
|
if(option.selected) item.classList.add('bg-accents-1', 'font-medium');
|
|
|
|
item.textContent = option.text;
|
|
item.dataset.value = option.value;
|
|
item.dataset.index = index;
|
|
|
|
item.addEventListener('click', () => {
|
|
this.select(index);
|
|
});
|
|
|
|
this.listContainer.appendChild(item);
|
|
});
|
|
|
|
// Append List to Menu
|
|
this.menu.appendChild(this.listContainer);
|
|
|
|
// Append to wrapper
|
|
this.wrapper.appendChild(this.trigger);
|
|
this.wrapper.appendChild(this.menu);
|
|
this.originalSelect.parentNode.insertBefore(this.wrapper, this.originalSelect);
|
|
|
|
// Event Listeners
|
|
this.trigger.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.toggle();
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
if (!this.wrapper.contains(e.target)) {
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons({ root: this.trigger });
|
|
}
|
|
}
|
|
|
|
toggle() {
|
|
if (!this.menu.classList.contains('open')) {
|
|
this.open();
|
|
} else {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
open() {
|
|
CustomSelect.instances.forEach(instance => {
|
|
if (instance !== this) instance.close();
|
|
});
|
|
|
|
// Smart Positioning
|
|
const rect = this.wrapper.getBoundingClientRect();
|
|
const spaceRight = window.innerWidth - rect.left;
|
|
|
|
// Reset positioning classes
|
|
this.menu.classList.remove('right-0', 'origin-top-right', 'left-0', 'origin-top-left');
|
|
|
|
// Logic: Zone Check - If near right edge (< 300px), Force Right Align.
|
|
// Doing this purely based on coordinates prevents "Layout Jumping" caused by measuring content width.
|
|
if (spaceRight < 300) {
|
|
this.menu.classList.add('right-0', 'origin-top-right');
|
|
} else {
|
|
this.menu.classList.add('left-0', 'origin-top-left');
|
|
}
|
|
|
|
// Apply visual open states
|
|
this.menu.classList.add('open');
|
|
|
|
this.trigger.classList.add('ring-1', 'ring-foreground');
|
|
const icon = this.trigger.querySelector('.custom-select-icon');
|
|
if(icon) icon.classList.add('rotate-180');
|
|
|
|
if (this.searchInput) {
|
|
setTimeout(() => this.searchInput.focus(), 50);
|
|
}
|
|
}
|
|
|
|
close() {
|
|
this.menu.classList.remove('open');
|
|
|
|
this.trigger.classList.remove('ring-1', 'ring-foreground');
|
|
const icon = this.trigger.querySelector('.custom-select-icon');
|
|
if(icon) icon.classList.remove('rotate-180');
|
|
}
|
|
|
|
select(index) {
|
|
// Update Original Select
|
|
this.originalSelect.selectedIndex = index;
|
|
|
|
// Update UI
|
|
this.trigger.querySelector('.custom-select-value').textContent = this.options[index].text;
|
|
|
|
// Update Active State in List
|
|
Array.from(this.listContainer.children).forEach((child) => {
|
|
// Safe check
|
|
if (!child.dataset.index) return;
|
|
|
|
if (parseInt(child.dataset.index) === index) {
|
|
child.classList.add('bg-accents-1', 'font-medium');
|
|
} else {
|
|
child.classList.remove('bg-accents-1', 'font-medium');
|
|
}
|
|
});
|
|
|
|
this.close();
|
|
this.originalSelect.dispatchEvent(new Event('change'));
|
|
}
|
|
|
|
refresh() {
|
|
// Clear list items
|
|
this.listContainer.innerHTML = '';
|
|
|
|
// Re-read options
|
|
this.options = Array.from(this.originalSelect.options);
|
|
|
|
this.options.forEach((option, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'px-3 py-2 text-sm cursor-pointer hover:bg-accents-1 transition-colors flex items-center justify-between whitespace-nowrap';
|
|
if(option.selected) item.classList.add('bg-accents-1', 'font-medium');
|
|
|
|
item.textContent = option.text;
|
|
item.dataset.value = option.value;
|
|
item.dataset.index = index;
|
|
|
|
item.addEventListener('click', () => {
|
|
this.select(index);
|
|
});
|
|
|
|
this.listContainer.appendChild(item);
|
|
});
|
|
|
|
// Update Trigger
|
|
if (this.originalSelect.selectedIndex >= 0) {
|
|
this.trigger.querySelector('.custom-select-value').textContent = this.originalSelect.options[this.originalSelect.selectedIndex].text;
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
document.querySelectorAll('select.custom-select').forEach(el => new CustomSelect(el));
|
|
});
|