/* ───────────────────────────────────────────────────────────────────────── * components.js – client-side logic for htmx server-rendered components * ───────────────────────────────────────────────────────────────────────── */ // ── Calendar ────────────────────────────────────────────────────────────── (function () { var MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; function renderCalendar(root) { var view = root.dataset.view || 'days'; var year = parseInt(root.dataset.year, 10); var month = parseInt(root.dataset.month, 10); var selD = parseInt(root.dataset.selDay, 10); var selM = parseInt(root.dataset.selMonth, 10); var selY = parseInt(root.dataset.selYear, 10); var labelBtn = root.querySelector('.cal-month-label'); var grid = root.querySelector('.cal-grid'); var dowRow = root.querySelector('.cal-dow-row'); // ── Update header label based on view ── if (view === 'days') { labelBtn.textContent = MONTHS[month] + ' ' + year; } else if (view === 'months') { labelBtn.textContent = year; } else { // years var ds = Math.floor(year / 12) * 12; labelBtn.textContent = ds + ' – ' + (ds + 11); } // Show DOW row only in day view if (dowRow) dowRow.style.display = view === 'days' ? '' : 'none'; grid.innerHTML = ''; if (view === 'days') { grid.style.gridTemplateColumns = ''; // let CSS class (grid-cols-7) take over var firstDay = new Date(year, month, 1).getDay(); var daysInMonth = new Date(year, month + 1, 0).getDate(); for (var i = 0; i < firstDay; i++) { grid.appendChild(document.createElement('div')); } for (var d = 1; d <= daysInMonth; d++) { var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = d; btn.className = 'cal-day'; if (d === selD && month === selM && year === selY) { btn.classList.add('cal-day-selected'); } btn.dataset.date = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(d).padStart(2, '0'); btn.addEventListener('click', (function (b, r) { return function () { var parts = b.dataset.date.split('-'); r.dataset.selYear = parts[0]; r.dataset.selMonth = parseInt(parts[1], 10) - 1; r.dataset.selDay = parseInt(parts[2], 10); r.querySelectorAll('.cal-day').forEach(function (el) { el.classList.remove('cal-day-selected'); }); b.classList.add('cal-day-selected'); r.querySelector('.cal-hidden-input').value = b.dataset.date; r.dispatchEvent(new CustomEvent('calendarChange', { detail: { date: b.dataset.date }, bubbles: true })); }; })(btn, root)); grid.appendChild(btn); } } else if (view === 'months') { grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; MONTHS.forEach(function (name, i) { var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = name.slice(0, 3); btn.className = 'cal-view-btn' + (i === month ? ' cal-view-btn-selected' : ''); btn.addEventListener('click', function () { root.dataset.month = i; root.dataset.view = 'days'; renderCalendar(root); }); grid.appendChild(btn); }); } else { // years var decadeStart = Math.floor(year / 12) * 12; grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; for (var yi = 0; yi < 12; yi++) { (function (y) { var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = y; btn.className = 'cal-view-btn' + (y === year ? ' cal-view-btn-selected' : ''); btn.addEventListener('click', function () { root.dataset.year = y; root.dataset.view = 'months'; renderCalendar(root); }); grid.appendChild(btn); })(decadeStart + yi); } } } function initCalendar(root) { root.querySelector('.cal-prev').addEventListener('click', function () { var view = root.dataset.view || 'days'; var m = parseInt(root.dataset.month, 10); var y = parseInt(root.dataset.year, 10); if (view === 'days') { if (m === 0) { m = 11; y--; } else { m--; } root.dataset.month = m; root.dataset.year = y; } else if (view === 'months') { root.dataset.year = y - 1; } else { // years root.dataset.year = Math.floor(y / 12) * 12 - 12; } renderCalendar(root); }); root.querySelector('.cal-next').addEventListener('click', function () { var view = root.dataset.view || 'days'; var m = parseInt(root.dataset.month, 10); var y = parseInt(root.dataset.year, 10); if (view === 'days') { if (m === 11) { m = 0; y++; } else { m++; } root.dataset.month = m; root.dataset.year = y; } else if (view === 'months') { root.dataset.year = y + 1; } else { // years root.dataset.year = Math.floor(y / 12) * 12 + 12; } renderCalendar(root); }); root.querySelector('.cal-month-label').addEventListener('click', function () { var view = root.dataset.view || 'days'; if (view === 'days') root.dataset.view = 'months'; else if (view === 'months') root.dataset.view = 'years'; // already at years — nothing deeper renderCalendar(root); }); renderCalendar(root); } // Initialise all calendars on page load, and again after any htmx swap function initAll() { document.querySelectorAll('.calendar-root').forEach(initCalendar); } document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); // ── CalendarRange ───────────────────────────────────────────────────────── (function () { var MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; function cmpDate(a, b) { return a < b ? -1 : a > b ? 1 : 0; } function isBetween(d, start, end) { return cmpDate(d, start) > 0 && cmpDate(d, end) < 0; } function toDateStr(year, month, day) { return year + '-' + String(month + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0'); } function updateLabel(root) { var lbl = root.querySelector('.calr-label'); if (!lbl) return; var start = root.dataset.start; var end = root.dataset.end; if (start && end) { lbl.textContent = start + ' → ' + end; return; } if (start) { lbl.textContent = start + ' → pick end date'; return; } lbl.textContent = ''; } // Only updates CSS classes on already-rendered buttons — no DOM destruction. function updateHoverClasses(root, hoverDate) { var start = root.dataset.start; var end = root.dataset.end; var rangeEnd = (start && !end && hoverDate && cmpDate(hoverDate, start) >= 0) ? hoverDate : end; root.querySelectorAll('.calr-day').forEach(function (btn) { var ds = btn.dataset.date; var isStart = !!(start && ds === start); var isEnd = !!(end && ds === end); var isHoverEnd = !!(!end && start && hoverDate && ds === hoverDate && cmpDate(hoverDate, start) > 0); var isMid = !!(start && rangeEnd && isBetween(ds, start, rangeEnd)); btn.classList.remove('calr-day-start', 'calr-day-end', 'calr-day-mid', 'calr-day-plain'); if (isStart) btn.classList.add('calr-day-start'); if (isEnd || isHoverEnd) btn.classList.add('calr-day-end'); if (isMid) btn.classList.add('calr-day-mid'); if (!isStart && !isEnd && !isHoverEnd && !isMid) btn.classList.add('calr-day-plain'); }); } // Full re-render of the grid. Called on mount, click, and view changes. function renderRange(root) { var view = root.dataset.view || 'days'; var year = parseInt(root.dataset.year, 10); var month = parseInt(root.dataset.month, 10); var start = root.dataset.start || ''; var end = root.dataset.end || ''; var labelBtn = root.querySelector('.calr-month-label'); var grid = root.querySelector('.calr-grid'); var dowRow = root.querySelector('.cal-dow-row'); // ── Update header label ── if (view === 'days') { labelBtn.textContent = MONTHS[month] + ' ' + year; } else if (view === 'months') { labelBtn.textContent = year; } else { // years var ds = Math.floor(year / 12) * 12; labelBtn.textContent = ds + ' – ' + (ds + 11); } if (dowRow) dowRow.style.display = view === 'days' ? '' : 'none'; grid.innerHTML = ''; // ── Clear event handlers (will be reassigned per view below) ── grid.onmouseover = null; grid.onmouseleave = null; if (view === 'days') { grid.style.gridTemplateColumns = ''; var firstDay = new Date(year, month, 1).getDay(); var daysInMonth = new Date(year, month + 1, 0).getDate(); for (var i = 0; i < firstDay; i++) { grid.appendChild(document.createElement('div')); } for (var d = 1; d <= daysInMonth; d++) { var dateStr = toDateStr(year, month, d); var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = d; btn.dataset.date = dateStr; var isStart = start && dateStr === start; var isEnd = end && dateStr === end; var isMid = start && end && isBetween(dateStr, start, end); var cls = 'calr-day'; if (isStart) cls += ' calr-day-start'; else if (isEnd) cls += ' calr-day-end'; else if (isMid) cls += ' calr-day-mid'; else cls += ' calr-day-plain'; btn.className = cls; grid.appendChild(btn); } // Click: update state → full re-render grid.onclick = function (e) { var btn = e.target.closest('.calr-day'); if (!btn) return; var ds = btn.dataset.date; var s = root.dataset.start; var en = root.dataset.end; if (!s || (s && en)) { root.dataset.start = ds; root.dataset.end = ''; } else { if (cmpDate(ds, s) > 0) { root.dataset.end = ds; } else if (cmpDate(ds, s) < 0) { root.dataset.start = ds; root.dataset.end = ''; } else { root.dataset.start = ''; root.dataset.end = ''; } } root.querySelector('.calr-hidden-start').value = root.dataset.start; root.querySelector('.calr-hidden-end').value = root.dataset.end; root.dispatchEvent(new CustomEvent('rangeChange', { detail: { start: root.dataset.start, end: root.dataset.end }, bubbles: true })); renderRange(root); updateLabel(root); }; grid.onmouseover = function (e) { var btn = e.target.closest('.calr-day'); if (!btn) return; updateHoverClasses(root, btn.dataset.date); }; grid.onmouseleave = function () { updateHoverClasses(root, null); }; } else if (view === 'months') { grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; MONTHS.forEach(function (name, i) { var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = name.slice(0, 3); btn.className = 'cal-view-btn' + (i === month ? ' cal-view-btn-selected' : ''); btn.addEventListener('click', function () { root.dataset.month = i; root.dataset.view = 'days'; renderRange(root); }); grid.appendChild(btn); }); } else { // years var decadeStart = Math.floor(year / 12) * 12; grid.style.gridTemplateColumns = 'repeat(3, minmax(0, 1fr))'; for (var yi = 0; yi < 12; yi++) { (function (y) { var btn = document.createElement('button'); btn.type = 'button'; btn.textContent = y; btn.className = 'cal-view-btn' + (y === year ? ' cal-view-btn-selected' : ''); btn.addEventListener('click', function () { root.dataset.year = y; root.dataset.view = 'months'; renderRange(root); }); grid.appendChild(btn); })(decadeStart + yi); } } } function initCalendarRange(root) { root.querySelector('.calr-prev').addEventListener('click', function () { var view = root.dataset.view || 'days'; var m = parseInt(root.dataset.month, 10); var y = parseInt(root.dataset.year, 10); if (view === 'days') { if (m === 0) { m = 11; y--; } else { m--; } root.dataset.month = m; root.dataset.year = y; } else if (view === 'months') { root.dataset.year = y - 1; } else { // years root.dataset.year = Math.floor(y / 12) * 12 - 12; } renderRange(root); }); root.querySelector('.calr-next').addEventListener('click', function () { var view = root.dataset.view || 'days'; var m = parseInt(root.dataset.month, 10); var y = parseInt(root.dataset.year, 10); if (view === 'days') { if (m === 11) { m = 0; y++; } else { m++; } root.dataset.month = m; root.dataset.year = y; } else if (view === 'months') { root.dataset.year = y + 1; } else { // years root.dataset.year = Math.floor(y / 12) * 12 + 12; } renderRange(root); }); root.querySelector('.calr-month-label').addEventListener('click', function () { var view = root.dataset.view || 'days'; if (view === 'days') root.dataset.view = 'months'; else if (view === 'months') root.dataset.view = 'years'; renderRange(root); }); renderRange(root); updateLabel(root); } function initAll() { document.querySelectorAll('.calr-root').forEach(initCalendarRange); } document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); // ── TimePicker ──────────────────────────────────────────────────────────── (function () { function syncTime(root) { var h = parseInt(root.querySelector('.timepicker-hour').value, 10) || 0; var m = parseInt(root.querySelector('.timepicker-minute').value, 10) || 0; var use12h = root.dataset.use12h === 'true'; var h24 = h; if (use12h) { var ampmEl = root.querySelector('.timepicker-ampm'); var ampm = ampmEl ? ampmEl.value : 'AM'; if (ampm === 'PM') { h24 = h === 12 ? 12 : h + 12; } else { h24 = h === 12 ? 0 : h; } } root.querySelector('.timepicker-hidden').value = String(h24).padStart(2, '0') + ':' + String(m).padStart(2, '0'); } function initTimePicker(root) { var sync = syncTime.bind(null, root); root.querySelector('.timepicker-hour').addEventListener('input', sync); root.querySelector('.timepicker-minute').addEventListener('input', sync); var ampmEl = root.querySelector('.timepicker-ampm'); if (ampmEl) ampmEl.addEventListener('change', sync); sync(); } function initAll() { document.querySelectorAll('.timepicker-root').forEach(initTimePicker); } document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); // ── Switch ──────────────────────────────────────────────────────────────── (function () { function updateSwitch(input) { var track = input.parentElement && input.parentElement.querySelector('.switch-track'); if (!track) return; var thumb = track.querySelector('.switch-thumb'); if (input.checked) { track.classList.add('bg-primary'); track.classList.remove('bg-input'); if (thumb) thumb.style.transform = 'translateX(1.375rem)'; } else { track.classList.remove('bg-primary'); track.classList.add('bg-input'); if (thumb) thumb.style.transform = ''; } } function initAll() { document.querySelectorAll('.switch-checkbox').forEach(function (input) { updateSwitch(input); if (!input._switchBound) { input._switchBound = true; input.addEventListener('change', function () { updateSwitch(input); }); } }); } document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); // ── Tabs ────────────────────────────────────────────────────────────────── (function () { var ACTIVE = 'bg-background text-foreground shadow-sm'; var INACTIVE = 'text-muted-foreground'; function initTabs(root) { if (root._tabsInitialised) return; root._tabsInitialised = true; var triggers = Array.from(root.querySelectorAll('.tabs-trigger')); var panels = Array.from(root.querySelectorAll('.tabs-panel')); function activate(idx) { triggers.forEach(function (t, i) { var active = i === idx; t.setAttribute('aria-selected', String(active)); ACTIVE.split(' ').forEach(function (c) { t.classList.toggle(c, active); }); INACTIVE.split(' ').forEach(function (c) { t.classList.toggle(c, !active); }); }); panels.forEach(function (p, i) { p.hidden = i !== idx; }); } triggers.forEach(function (trigger, idx) { trigger.addEventListener('click', function () { activate(idx); }); }); activate(0); } function initAll() { document.querySelectorAll('.tabs-root').forEach(initTabs); } document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); // ── Accordion ───────────────────────────────────────────────────────────── (function () { function initAccordion(root) { if (root._accInitialised) return; root._accInitialised = true; root.querySelectorAll('.accordion-trigger').forEach(function (trigger) { trigger.addEventListener('click', function () { var expanded = trigger.getAttribute('aria-expanded') === 'true'; var panel = trigger.closest('.accordion-item').querySelector('.accordion-panel'); if (expanded) { trigger.setAttribute('aria-expanded', 'false'); panel.style.height = '0'; panel.style.opacity = '0'; } else { trigger.setAttribute('aria-expanded', 'true'); panel.style.height = panel.scrollHeight + 'px'; panel.style.opacity = '1'; } var chevron = trigger.querySelector('.accordion-chevron'); if (chevron) chevron.style.transform = expanded ? '' : 'rotate(180deg)'; }); }); } function initAll() { document.querySelectorAll('.accordion-root').forEach(initAccordion); } document.addEventListener('DOMContentLoaded', initAll); document.addEventListener('htmx:afterSwap', initAll); })(); // ── Toast ───────────────────────────────────────────────────────────────── (function () { function dismissToast(toast) { toast.style.opacity = '0'; toast.style.transform = 'translateY(0.5rem)'; setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); } window.showToast = function (options) { var viewport = document.querySelector('.toast-viewport'); if (!viewport) return; var title = options.title || ''; var description = options.description || ''; var variant = options.variant || 'default'; var duration = typeof options.duration === 'number' ? options.duration : 5000; var variantCls = variant === 'destructive' ? ' border-destructive text-destructive' : ''; var toast = document.createElement('div'); toast.setAttribute('role', 'alert'); toast.className = 'toast-item pointer-events-auto relative flex w-full items-center justify-between' + ' space-x-4 overflow-hidden rounded-md border border-border bg-background p-4' + ' shadow-lg transition-all duration-300 opacity-0 translate-y-2' + variantCls; toast.innerHTML = '