feat: initialize template shell and basic components
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// 1. Close all comboboxes when clicking outside
|
||||
document.addEventListener('click', (event) => {
|
||||
document.querySelectorAll('.autocomplete-combobox').forEach(combo => {
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
if (results && !combo.contains(event.target)) {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to get only currently visible combobox items
|
||||
function getVisibleItems(combo) {
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
if (!results || results.classList.contains('hidden')) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(results.querySelectorAll('.combobox-item')).filter(item => {
|
||||
return !item.classList.contains('hidden') && item.offsetParent !== null;
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to show dropdown results
|
||||
function showDropdown(input) {
|
||||
const combo = input.closest('.autocomplete-combobox');
|
||||
if (!combo) return;
|
||||
if (combo.dataset.justSelected === 'true') return;
|
||||
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
if (!results) return;
|
||||
|
||||
if (!input.hasAttribute('hx-get')) {
|
||||
// Client-side: filter and display
|
||||
results.classList.remove('hidden');
|
||||
const query = input.value.toLowerCase().trim();
|
||||
const items = results.querySelectorAll('.combobox-item');
|
||||
let visibleCount = 0;
|
||||
|
||||
items.forEach(item => {
|
||||
const text = item.getAttribute('data-name').toLowerCase();
|
||||
if (text.includes(query)) {
|
||||
item.classList.remove('hidden');
|
||||
visibleCount++;
|
||||
} else {
|
||||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
if (visibleCount === 0 && query !== '') {
|
||||
results.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
// Server-side HTMX search: only show if results contain elements
|
||||
if (results.querySelector('.combobox-item') || results.querySelector('div')) {
|
||||
results.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Open dropdown on focusin and click
|
||||
document.addEventListener('focusin', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
if (input) {
|
||||
showDropdown(input);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
if (input) {
|
||||
showDropdown(input);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Clear values on input delete and perform client-side filtering
|
||||
document.addEventListener('input', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
if (!input) return;
|
||||
|
||||
const combo = input.closest('.autocomplete-combobox');
|
||||
const valueInput = combo.querySelector('.combobox-value');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
|
||||
if (input.value.trim() === '') {
|
||||
if (valueInput) valueInput.value = '';
|
||||
if (results) results.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply client-side search filtering immediately
|
||||
if (!input.hasAttribute('hx-get')) {
|
||||
showDropdown(input);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Keydown navigation delegation (Arrows, Escape, Enter, Tab/Shift-Tab)
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const input = event.target.closest('.combobox-input');
|
||||
const item = event.target.closest('.combobox-item');
|
||||
|
||||
if (input) {
|
||||
const combo = input.closest('.autocomplete-combobox');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (results) results.classList.add('hidden');
|
||||
input.blur();
|
||||
} else if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) {
|
||||
const visibleItems = getVisibleItems(combo);
|
||||
if (visibleItems.length > 0) {
|
||||
event.preventDefault();
|
||||
visibleItems[0].focus();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
const combo = item.closest('.autocomplete-combobox');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
const inputField = combo.querySelector('.combobox-input');
|
||||
const visibleItems = getVisibleItems(combo);
|
||||
const index = visibleItems.indexOf(item);
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
selectItem(combo, item);
|
||||
} else if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
if (index >= 0 && index + 1 < visibleItems.length) {
|
||||
visibleItems[index + 1].focus();
|
||||
} else {
|
||||
// Loop back to first item
|
||||
if (visibleItems.length > 0) visibleItems[0].focus();
|
||||
}
|
||||
} else if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) {
|
||||
event.preventDefault();
|
||||
if (index > 0) {
|
||||
visibleItems[index - 1].focus();
|
||||
} else if (inputField) {
|
||||
// Return focus back to search input
|
||||
inputField.focus();
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (results) results.classList.add('hidden');
|
||||
if (inputField) inputField.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Click event delegation for selection
|
||||
document.addEventListener('click', (event) => {
|
||||
const item = event.target.closest('.combobox-item');
|
||||
if (item) {
|
||||
const combo = item.closest('.autocomplete-combobox');
|
||||
selectItem(combo, item);
|
||||
}
|
||||
});
|
||||
|
||||
// 6. HTMX Swap Integration to show results
|
||||
document.addEventListener('htmx:afterSwap', (event) => {
|
||||
const results = event.target.querySelector('.combobox-results')
|
||||
|| event.target.closest('.combobox-results');
|
||||
if (results) {
|
||||
results.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Core helper to perform selection swap
|
||||
function selectItem(combo, item) {
|
||||
const input = combo.querySelector('.combobox-input');
|
||||
const valueInput = combo.querySelector('.combobox-value');
|
||||
const results = combo.querySelector('.combobox-results');
|
||||
|
||||
const id = item.getAttribute('data-id');
|
||||
const name = item.getAttribute('data-name');
|
||||
|
||||
if (valueInput) {
|
||||
valueInput.value = id;
|
||||
valueInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
if (input) {
|
||||
input.value = name;
|
||||
input.focus();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
if (results) {
|
||||
results.classList.add('hidden');
|
||||
// Prevent immediate reopening on focus
|
||||
combo.dataset.justSelected = 'true';
|
||||
setTimeout(() => {
|
||||
delete combo.dataset.justSelected;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// --- DIALOG / MODAL ---
|
||||
window.openModal = function(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) return;
|
||||
modal.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content) {
|
||||
content.classList.remove('scale-95', 'opacity-0');
|
||||
content.classList.add('scale-100', 'opacity-100');
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
window.closeModal = function(modal) {
|
||||
if (typeof modal === 'string') modal = document.getElementById(modal);
|
||||
if (!modal) return;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content) {
|
||||
content.classList.remove('scale-100', 'opacity-100');
|
||||
content.classList.add('scale-95', 'opacity-0');
|
||||
}
|
||||
setTimeout(() => {
|
||||
modal.classList.add('hidden');
|
||||
}, 300); // Wait for transition animation
|
||||
};
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
// Modal Trigger Buttons
|
||||
const trigger = event.target.closest('[data-modal-target]');
|
||||
if (trigger) {
|
||||
const targetId = trigger.getAttribute('data-modal-target');
|
||||
window.openModal(targetId);
|
||||
}
|
||||
|
||||
// Modal Close Buttons
|
||||
if (event.target.closest('.modal-close') || event.target.closest('.modal-backdrop')) {
|
||||
const modal = event.target.closest('.modal-dialog');
|
||||
window.closeModal(modal);
|
||||
}
|
||||
});
|
||||
|
||||
// --- DROPDOWNS ---
|
||||
document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('.dropdown-trigger');
|
||||
|
||||
// Close all other dropdowns
|
||||
document.querySelectorAll('.dropdown-menu').forEach(menu => {
|
||||
const content = menu.querySelector('.dropdown-content');
|
||||
if (content && (!trigger || menu !== trigger.closest('.dropdown-menu'))) {
|
||||
content.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle selected dropdown
|
||||
if (trigger) {
|
||||
const menu = trigger.closest('.dropdown-menu');
|
||||
const content = menu.querySelector('.dropdown-content');
|
||||
if (content) content.classList.toggle('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- TOAST NOTIFICATIONS ---
|
||||
window.showToast = function(message, type = 'success') {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-item flex items-center gap-3 w-80 p-4 rounded-lg border border-border bg-card shadow-lg transform transition-all duration-300 translate-y-10 opacity-0`;
|
||||
toast.innerHTML = `
|
||||
<div class="grow text-sm font-semibold text-slate-100">${message}</div>
|
||||
<button class="toast-close text-slate-500 hover:text-slate-350">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
// Slide in
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('translate-y-10', 'opacity-0');
|
||||
}, 10);
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
const dismissTimeout = setTimeout(() => dismissToast(toast), 4000);
|
||||
|
||||
// Manual dismiss listener
|
||||
toast.querySelector('.toast-close').addEventListener('click', () => {
|
||||
clearTimeout(dismissTimeout);
|
||||
dismissToast(toast);
|
||||
});
|
||||
};
|
||||
|
||||
function dismissToast(toast) {
|
||||
toast.classList.add('translate-y-10', 'opacity-0');
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// --- CUSTOM DATE PICKER LOGIC ---
|
||||
function formatDateLabel(dateStr) {
|
||||
if (!dateStr) return 'Pick a date';
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length !== 3) return dateStr;
|
||||
const d = new Date(parts[0], parts[1] - 1, parts[2]);
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
return `${monthNames[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
function renderCalendar(picker) {
|
||||
const valueInput = picker.querySelector('.datepicker-value');
|
||||
const monthYearLabel = picker.querySelector('.datepicker-month-year');
|
||||
const daysContainer = picker.querySelector('.datepicker-days');
|
||||
if (!valueInput || !monthYearLabel || !daysContainer) return;
|
||||
|
||||
let currentYear = parseInt(picker.dataset.year);
|
||||
let currentMonth = parseInt(picker.dataset.month); // 0-indexed
|
||||
|
||||
if (isNaN(currentYear) || isNaN(currentMonth)) {
|
||||
const val = valueInput.value;
|
||||
const d = val ? new Date(val) : new Date();
|
||||
currentYear = d.getFullYear();
|
||||
currentMonth = d.getMonth();
|
||||
picker.dataset.year = currentYear;
|
||||
picker.dataset.month = currentMonth;
|
||||
}
|
||||
|
||||
const selectedDateVal = valueInput.value;
|
||||
const today = new Date();
|
||||
|
||||
const monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
monthYearLabel.textContent = `${monthNames[currentMonth]} ${currentYear}`;
|
||||
|
||||
daysContainer.innerHTML = '';
|
||||
|
||||
const firstDayIndex = new Date(currentYear, currentMonth, 1).getDay();
|
||||
const totalDays = new Date(currentYear, currentMonth + 1, 0).getDate();
|
||||
const prevTotalDays = new Date(currentYear, currentMonth, 0).getDate();
|
||||
|
||||
// Prev month padding
|
||||
for (let i = firstDayIndex - 1; i >= 0; i--) {
|
||||
const dayNum = prevTotalDays - i;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.disabled = true;
|
||||
btn.className = 'py-1 text-center text-xs text-slate-700 cursor-not-allowed font-medium';
|
||||
btn.textContent = dayNum;
|
||||
daysContainer.appendChild(btn);
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let day = 1; day <= totalDays; day++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
const yearStr = currentYear;
|
||||
const monthStr = String(currentMonth + 1).padStart(2, '0');
|
||||
const dayStr = String(day).padStart(2, '0');
|
||||
const dateStr = `${yearStr}-${monthStr}-${dayStr}`;
|
||||
|
||||
const isSelected = selectedDateVal === dateStr;
|
||||
const isToday = today.getFullYear() === currentYear && today.getMonth() === currentMonth && today.getDate() === day;
|
||||
|
||||
btn.className = `py-1 text-xs rounded-lg font-semibold hover:bg-accent hover:text-accent-foreground transition flex items-center justify-center h-7 w-7 mx-auto ${
|
||||
isSelected ? 'bg-indigo-600 text-white hover:bg-indigo-650' :
|
||||
isToday ? 'border border-sky-500/50 text-sky-400' : 'text-slate-350'
|
||||
}`;
|
||||
btn.dataset.date = dateStr;
|
||||
btn.textContent = day;
|
||||
daysContainer.appendChild(btn);
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const totalGrids = 42;
|
||||
const currentGrids = firstDayIndex + totalDays;
|
||||
for (let i = 1; i <= (totalGrids - currentGrids); i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.disabled = true;
|
||||
btn.className = 'py-1 text-center text-xs text-slate-700 cursor-not-allowed font-medium';
|
||||
btn.textContent = i;
|
||||
daysContainer.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize custom date pickers
|
||||
document.querySelectorAll('.custom-datepicker').forEach(picker => {
|
||||
renderCalendar(picker);
|
||||
const val = picker.querySelector('.datepicker-value').value;
|
||||
const text = picker.querySelector('.datepicker-text');
|
||||
if (text && val) text.textContent = formatDateLabel(val);
|
||||
});
|
||||
|
||||
// Custom Date Picker events
|
||||
document.addEventListener('click', (event) => {
|
||||
// Trigger Click
|
||||
const trigger = event.target.closest('.datepicker-trigger');
|
||||
if (trigger) {
|
||||
const picker = trigger.closest('.custom-datepicker');
|
||||
const popover = picker.querySelector('.datepicker-popover');
|
||||
|
||||
// Close other pickers
|
||||
document.querySelectorAll('.datepicker-popover, .timepicker-popover').forEach(pop => {
|
||||
if (pop !== popover) pop.classList.add('hidden');
|
||||
});
|
||||
|
||||
popover.classList.toggle('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prev Month
|
||||
const prevBtn = event.target.closest('.datepicker-prev');
|
||||
if (prevBtn) {
|
||||
const picker = prevBtn.closest('.custom-datepicker');
|
||||
let currentMonth = parseInt(picker.dataset.month);
|
||||
let currentYear = parseInt(picker.dataset.year);
|
||||
currentMonth--;
|
||||
if (currentMonth < 0) {
|
||||
currentMonth = 11;
|
||||
currentYear--;
|
||||
}
|
||||
picker.dataset.month = currentMonth;
|
||||
picker.dataset.year = currentYear;
|
||||
renderCalendar(picker);
|
||||
return;
|
||||
}
|
||||
|
||||
// Next Month
|
||||
const nextBtn = event.target.closest('.datepicker-next');
|
||||
if (nextBtn) {
|
||||
const picker = nextBtn.closest('.custom-datepicker');
|
||||
let currentMonth = parseInt(picker.dataset.month);
|
||||
let currentYear = parseInt(picker.dataset.year);
|
||||
currentMonth++;
|
||||
if (currentMonth > 11) {
|
||||
currentMonth = 0;
|
||||
currentYear++;
|
||||
}
|
||||
picker.dataset.month = currentMonth;
|
||||
picker.dataset.year = currentYear;
|
||||
renderCalendar(picker);
|
||||
return;
|
||||
}
|
||||
|
||||
// Day click
|
||||
const dayBtn = event.target.closest('.datepicker-days button[data-date]');
|
||||
if (dayBtn) {
|
||||
const picker = dayBtn.closest('.custom-datepicker');
|
||||
const valueInput = picker.querySelector('.datepicker-value');
|
||||
const textLabel = picker.querySelector('.datepicker-text');
|
||||
const popover = picker.querySelector('.datepicker-popover');
|
||||
|
||||
const selectedDate = dayBtn.dataset.date;
|
||||
valueInput.value = selectedDate;
|
||||
textLabel.textContent = formatDateLabel(selectedDate);
|
||||
renderCalendar(picker);
|
||||
popover.classList.add('hidden');
|
||||
valueInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Close outside
|
||||
const openPickerPopover = document.querySelector('.datepicker-popover:not(.hidden)');
|
||||
if (openPickerPopover && !event.target.closest('.custom-datepicker')) {
|
||||
openPickerPopover.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- CUSTOM TIME PICKER LOGIC ---
|
||||
function initTimePicker(picker) {
|
||||
const hoursCol = picker.querySelector('.timepicker-col-hours');
|
||||
const minutesCol = picker.querySelector('.timepicker-col-minutes');
|
||||
const valueInput = picker.querySelector('.timepicker-value');
|
||||
if (!hoursCol || !minutesCol || !valueInput) return;
|
||||
|
||||
if (hoursCol.children.length === 0) {
|
||||
for (let hr = 1; hr <= 12; hr++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'timepicker-btn-hour block w-full py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition text-slate-350 font-bold rounded-md';
|
||||
btn.textContent = hr;
|
||||
hoursCol.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
if (minutesCol.children.length === 0) {
|
||||
for (let min = 0; min < 60; min += 5) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'timepicker-btn-minute block w-full py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition text-slate-350 font-bold rounded-md';
|
||||
btn.textContent = String(min).padStart(2, '0');
|
||||
minutesCol.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
updateTimePickerActiveStates(picker);
|
||||
}
|
||||
|
||||
function updateTimePickerActiveStates(picker) {
|
||||
const valueInput = picker.querySelector('.timepicker-value');
|
||||
const textLabel = picker.querySelector('.timepicker-text');
|
||||
if (!valueInput || !textLabel) return;
|
||||
|
||||
const val = valueInput.value || "12:00 PM";
|
||||
const matches = val.match(/^(\d+):(\d+)\s*(AM|PM)$/i);
|
||||
if (!matches) return;
|
||||
|
||||
const hr = parseInt(matches[1]);
|
||||
const min = matches[2];
|
||||
const ampm = matches[3].toUpperCase();
|
||||
|
||||
picker.querySelectorAll('.timepicker-btn-hour').forEach(btn => {
|
||||
if (parseInt(btn.textContent) === hr) {
|
||||
btn.className = 'timepicker-btn-hour block w-full py-1.5 text-xs bg-indigo-600 text-white font-bold rounded-md';
|
||||
} else {
|
||||
btn.className = 'timepicker-btn-hour block w-full py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition text-slate-350 font-bold rounded-md';
|
||||
}
|
||||
});
|
||||
|
||||
picker.querySelectorAll('.timepicker-btn-minute').forEach(btn => {
|
||||
if (btn.textContent === min) {
|
||||
btn.className = 'timepicker-btn-minute block w-full py-1.5 text-xs bg-indigo-600 text-white font-bold rounded-md';
|
||||
} else {
|
||||
btn.className = 'timepicker-btn-minute block w-full py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition text-slate-350 font-bold rounded-md';
|
||||
}
|
||||
});
|
||||
|
||||
picker.querySelectorAll('.timepicker-ampm-btn').forEach(btn => {
|
||||
if (btn.textContent.toUpperCase() === ampm) {
|
||||
btn.className = 'timepicker-ampm-btn py-1.5 rounded-lg text-xs font-bold bg-indigo-650 text-white';
|
||||
} else {
|
||||
btn.className = 'timepicker-ampm-btn py-1.5 rounded-lg text-xs font-bold hover:bg-accent hover:text-accent-foreground transition text-slate-400';
|
||||
}
|
||||
});
|
||||
|
||||
textLabel.textContent = `${hr}:${min} ${ampm}`;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.custom-timepicker').forEach(picker => {
|
||||
initTimePicker(picker);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('.timepicker-trigger');
|
||||
if (trigger) {
|
||||
const picker = trigger.closest('.custom-timepicker');
|
||||
const popover = picker.querySelector('.timepicker-popover');
|
||||
|
||||
document.querySelectorAll('.datepicker-popover, .timepicker-popover').forEach(pop => {
|
||||
if (pop !== popover) pop.classList.add('hidden');
|
||||
});
|
||||
|
||||
popover.classList.toggle('hidden');
|
||||
|
||||
if (!popover.classList.contains('hidden')) {
|
||||
setTimeout(() => {
|
||||
const activeHour = picker.querySelector('.timepicker-btn-hour.bg-indigo-600');
|
||||
const activeMin = picker.querySelector('.timepicker-btn-minute.bg-indigo-600');
|
||||
if (activeHour) activeHour.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||||
if (activeMin) activeMin.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||||
}, 10);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const btnHour = event.target.closest('.timepicker-btn-hour');
|
||||
const btnMinute = event.target.closest('.timepicker-btn-minute');
|
||||
const btnAmpm = event.target.closest('.timepicker-ampm-btn');
|
||||
|
||||
if (btnHour || btnMinute || btnAmpm) {
|
||||
const picker = event.target.closest('.custom-timepicker');
|
||||
if (!picker) return;
|
||||
|
||||
const valueInput = picker.querySelector('.timepicker-value');
|
||||
let val = valueInput.value || "12:00 PM";
|
||||
const matches = val.match(/^(\d+):(\d+)\s*(AM|PM)$/i);
|
||||
if (!matches) return;
|
||||
|
||||
let hr = matches[1];
|
||||
let min = matches[2];
|
||||
let ampm = matches[3];
|
||||
|
||||
if (btnHour) hr = btnHour.textContent;
|
||||
if (btnMinute) min = btnMinute.textContent;
|
||||
if (btnAmpm) ampm = btnAmpm.textContent;
|
||||
|
||||
valueInput.value = `${hr}:${min} ${ampm}`;
|
||||
updateTimePickerActiveStates(picker);
|
||||
valueInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const openTimePopover = document.querySelector('.timepicker-popover:not(.hidden)');
|
||||
if (openTimePopover && !event.target.closest('.custom-timepicker')) {
|
||||
openTimePopover.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- INTERACTIVE TABS LOGIC ---
|
||||
document.addEventListener('click', (event) => {
|
||||
const tabTrigger = event.target.closest('[data-tab-target]');
|
||||
if (tabTrigger) {
|
||||
const tabGroup = tabTrigger.getAttribute('data-tab-group');
|
||||
const targetId = tabTrigger.getAttribute('data-tab-target');
|
||||
if (!tabGroup || !targetId) return;
|
||||
|
||||
// Deactivate all tab triggers in the same group
|
||||
document.querySelectorAll(`[data-tab-group="${tabGroup}"]`).forEach(trig => {
|
||||
trig.classList.remove('border-sky-500', 'text-sky-400');
|
||||
trig.classList.add('border-transparent', 'text-slate-400', 'hover:text-slate-200');
|
||||
});
|
||||
|
||||
// Activate selected tab trigger
|
||||
tabTrigger.classList.remove('border-transparent', 'text-slate-400', 'hover:text-slate-200');
|
||||
tabTrigger.classList.add('border-sky-500', 'text-sky-400');
|
||||
|
||||
// Hide all tab content panes in the same group
|
||||
document.querySelectorAll(`[data-tab-content-group="${tabGroup}"]`).forEach(pane => {
|
||||
pane.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Show the targeted pane
|
||||
const targetPane = document.getElementById(targetId);
|
||||
if (targetPane) targetPane.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// --- ACCORDION LOGIC ---
|
||||
document.addEventListener('click', (event) => {
|
||||
const header = event.target.closest('.accordion-trigger');
|
||||
if (header) {
|
||||
const container = header.closest('.accordion-item');
|
||||
const content = container.querySelector('.accordion-content');
|
||||
const chevron = header.querySelector('.accordion-chevron');
|
||||
if (!content) return;
|
||||
|
||||
const isCollapsed = content.classList.contains('hidden');
|
||||
|
||||
// Toggle Accordion Content
|
||||
if (isCollapsed) {
|
||||
content.classList.remove('hidden');
|
||||
if (chevron) chevron.classList.add('rotate-180');
|
||||
} else {
|
||||
content.classList.add('hidden');
|
||||
if (chevron) chevron.classList.remove('rotate-180');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- SHEET / DRAWER LOGIC ---
|
||||
window.openSheet = function(sheetId) {
|
||||
const sheet = document.getElementById(sheetId);
|
||||
if (!sheet) return;
|
||||
sheet.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
const content = sheet.querySelector('.sheet-content');
|
||||
const backdrop = sheet.querySelector('.sheet-backdrop');
|
||||
if (backdrop) backdrop.classList.remove('opacity-0');
|
||||
if (content) {
|
||||
content.classList.remove('translate-x-full');
|
||||
content.classList.add('translate-x-0');
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
window.closeSheet = function(sheet) {
|
||||
if (typeof sheet === 'string') sheet = document.getElementById(sheet);
|
||||
if (!sheet) return;
|
||||
const content = sheet.querySelector('.sheet-content');
|
||||
const backdrop = sheet.querySelector('.sheet-backdrop');
|
||||
if (backdrop) backdrop.classList.add('opacity-0');
|
||||
if (content) {
|
||||
content.classList.remove('translate-x-0');
|
||||
content.classList.add('translate-x-full');
|
||||
}
|
||||
setTimeout(() => {
|
||||
sheet.classList.add('hidden');
|
||||
}, 300);
|
||||
};
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
// Open Sheet Triggers
|
||||
const trigger = event.target.closest('[data-sheet-target]');
|
||||
if (trigger) {
|
||||
const targetId = trigger.getAttribute('data-sheet-target');
|
||||
window.openSheet(targetId);
|
||||
}
|
||||
|
||||
// Close Sheet Triggers
|
||||
if (event.target.closest('.sheet-close') || event.target.closest('.sheet-backdrop')) {
|
||||
const sheet = event.target.closest('.sheet-dialog');
|
||||
window.closeSheet(sheet);
|
||||
}
|
||||
});
|
||||
|
||||
// --- SIDEBAR NAVIGATION ACTIVE HIGHLIGHTING ---
|
||||
const currentPath = window.location.pathname;
|
||||
const sidebar = document.getElementById('wiki-sidebar');
|
||||
if (sidebar) {
|
||||
sidebar.querySelectorAll('a[data-wiki-path]').forEach(link => {
|
||||
const path = link.getAttribute('data-wiki-path');
|
||||
if (currentPath === path) {
|
||||
link.className = 'flex items-center px-3 py-2 text-xs font-semibold rounded-lg bg-indigo-600/20 text-indigo-400 border border-indigo-500/10 transition';
|
||||
} else {
|
||||
link.className = 'flex items-center px-3 py-2 text-xs font-semibold text-muted-foreground hover:text-white rounded-lg hover:bg-accent hover:text-accent-foreground transition';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- CUSTOM SELECT DROPDOWN LOGIC ---
|
||||
document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('.select-trigger');
|
||||
const item = event.target.closest('.select-item');
|
||||
|
||||
// Close all other select dropdowns when clicking trigger or outside
|
||||
if (trigger || !event.target.closest('.custom-select')) {
|
||||
document.querySelectorAll('.custom-select').forEach(select => {
|
||||
const popover = select.querySelector('.select-popover');
|
||||
const chevron = select.querySelector('.select-chevron');
|
||||
if (popover && (!trigger || select !== trigger.closest('.custom-select'))) {
|
||||
popover.classList.add('hidden');
|
||||
if (chevron) chevron.classList.remove('rotate-180');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle dropdown open state
|
||||
if (trigger) {
|
||||
const select = trigger.closest('.custom-select');
|
||||
const popover = select.querySelector('.select-popover');
|
||||
const chevron = select.querySelector('.select-chevron');
|
||||
if (popover) {
|
||||
const isHidden = popover.classList.toggle('hidden');
|
||||
if (chevron) {
|
||||
if (isHidden) {
|
||||
chevron.classList.remove('rotate-180');
|
||||
} else {
|
||||
chevron.classList.add('rotate-180');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle item selection
|
||||
if (item) {
|
||||
const select = item.closest('.custom-select');
|
||||
const triggerBtn = select.querySelector('.select-trigger');
|
||||
const valueInput = select.querySelector('.select-value');
|
||||
const textLabel = select.querySelector('.select-text');
|
||||
const popover = select.querySelector('.select-popover');
|
||||
const chevron = select.querySelector('.select-chevron');
|
||||
|
||||
const value = item.getAttribute('data-value');
|
||||
const labelText = item.textContent.trim();
|
||||
|
||||
if (valueInput) valueInput.value = value;
|
||||
if (textLabel) textLabel.textContent = labelText;
|
||||
|
||||
// Mark selected item visually
|
||||
select.querySelectorAll('.select-item').forEach(i => {
|
||||
i.classList.remove('bg-accent', 'text-accent-foreground', 'font-semibold');
|
||||
});
|
||||
item.classList.add('bg-accent', 'text-accent-foreground', 'font-semibold');
|
||||
|
||||
// Close dropdown
|
||||
if (popover) popover.classList.add('hidden');
|
||||
if (chevron) chevron.classList.remove('rotate-180');
|
||||
if (triggerBtn) triggerBtn.focus();
|
||||
|
||||
// Trigger change event on input
|
||||
if (valueInput) {
|
||||
valueInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard support for custom select triggers
|
||||
document.addEventListener('keydown', (event) => {
|
||||
const trigger = event.target.closest('.select-trigger');
|
||||
const item = event.target.closest('.select-item');
|
||||
|
||||
if (trigger) {
|
||||
const select = trigger.closest('.custom-select');
|
||||
const popover = select.querySelector('.select-popover');
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (popover) {
|
||||
popover.classList.remove('hidden');
|
||||
const chevron = select.querySelector('.select-chevron');
|
||||
if (chevron) chevron.classList.add('rotate-180');
|
||||
}
|
||||
const firstItem = select.querySelector('.select-item');
|
||||
if (firstItem) firstItem.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item) {
|
||||
const select = item.closest('.custom-select');
|
||||
const triggerBtn = select.querySelector('.select-trigger');
|
||||
const items = Array.from(select.querySelectorAll('.select-item'));
|
||||
const index = items.indexOf(item);
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
const next = items[index + 1] || items[0];
|
||||
if (next) next.focus();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const prev = items[index - 1] || items[items.length - 1];
|
||||
if (prev) prev.focus();
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
item.click();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
const popover = select.querySelector('.select-popover');
|
||||
const chevron = select.querySelector('.select-chevron');
|
||||
if (popover) popover.classList.add('hidden');
|
||||
if (chevron) chevron.classList.remove('rotate-180');
|
||||
if (triggerBtn) triggerBtn.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global keydown listener for Escape to dismiss modals
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const openDialog = document.querySelector('.modal-dialog:not(.hidden)');
|
||||
if (openDialog) {
|
||||
window.closeModal(openDialog);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- COPY CODE SNIPPETS HELPER ---
|
||||
window.copyCodeSnippet = function(button) {
|
||||
const pre = button.closest('.relative').querySelector('pre');
|
||||
if (!pre) return;
|
||||
const code = pre.querySelector('code');
|
||||
const text = code ? code.innerText : pre.innerText;
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = `
|
||||
<svg class="h-3 w-3 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span class="text-sky-400">Copied!</span>
|
||||
`;
|
||||
button.classList.add('border-sky-500/30', 'bg-sky-500/5');
|
||||
if (window.showToast) {
|
||||
window.showToast('Snippet copied to clipboard!');
|
||||
}
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('border-sky-500/30', 'bg-sky-500/5');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
if (window.showToast) {
|
||||
window.showToast('Failed to copy to clipboard', 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user