import './bootstrap'; import htmx from 'htmx.org'; const SELECTORS = { calendarToggle: '.calendar-toggle', calendarViewForm: '#calendar-view', calendarExpandToggle: '[data-calendar-expand]', colorPicker: '[data-colorpicker]', colorPickerColor: '[data-colorpicker-color]', colorPickerHex: '[data-colorpicker-hex]', colorPickerRandom: '[data-colorpicker-random]', eventAllDayToggle: '[data-all-day-toggle]', eventStartInput: '[data-event-start]', eventEndInput: '[data-event-end]', recurrenceFrequency: '[data-recurrence-frequency]', recurrenceInterval: '[data-recurrence-interval]', recurrenceUnit: '[data-recurrence-unit]', recurrenceSection: '[data-recurrence-section]', monthlyMode: '[data-monthly-mode]', monthlyDays: '[data-monthly-days]', monthlyWeekday: '[data-monthly-weekday]', modalDialog: 'dialog', modalContent: '#modal', modalClassSource: '[data-modal-class]', tabsRoot: '[data-tabs]', tabButton: '[role=\"tab\"]', tabPanel: '[role=\"tabpanel\"]', monthDay: '.calendar.month .day', monthDayEvent: 'a.event', monthDayMore: '[data-day-more]', monthDayMoreWrap: '.more-events', }; function syncModalRootClass(modal) { if (!modal) return; const previous = (modal.dataset.appliedClass || '') .split(/\s+/) .filter(Boolean); if (previous.length) { modal.classList.remove(...previous); } const source = modal.querySelector(SELECTORS.modalClassSource); const next = (source?.dataset?.modalClass || '') .split(/\s+/) .filter(Boolean); if (next.length) { modal.classList.add(...next); } modal.dataset.appliedClass = next.join(' '); } function clearModalRootClass(modal) { if (!modal) return; const previous = (modal.dataset.appliedClass || '') .split(/\s+/) .filter(Boolean); if (previous.length) { modal.classList.remove(...previous); } modal.dataset.appliedClass = ''; } /** * * htmx/global */ // make html globally visible to use the devtools and extensions window.htmx = htmx; // global htmx config htmx.config.historyEnabled = true; // HX-Boost back/forward support htmx.logger = console.log; // verbose logging during dev // csrf on htmx requests document.addEventListener('htmx:configRequest', (evt) => { const token = document.querySelector('meta[name="csrf-token"]')?.content if (token) evt.detail.headers['X-CSRF-TOKEN'] = token }) // modal htmx tracking document.addEventListener('htmx:beforeSwap', (evt) => { const target = evt.detail?.target || evt.target; if (target && target.id === 'modal') { target.dataset.prevUrl = window.location.href; syncModalRootClass(target); } }); /** * * global auth expiry redirect (fetch/axios) */ const AUTH_REDIRECT_STATUSES = new Set([401, 419]); const redirectToLogin = () => { if (window.location.pathname !== '/login') { window.location.assign('/login'); } }; if (window.fetch) { const originalFetch = window.fetch.bind(window); window.fetch = async (...args) => { const response = await originalFetch(...args); if (response && AUTH_REDIRECT_STATUSES.has(response.status)) { redirectToLogin(); } return response; }; } if (window.axios) { window.axios.interceptors.response.use( (response) => response, (error) => { const status = error?.response?.status; if (AUTH_REDIRECT_STATUSES.has(status)) { redirectToLogin(); } return Promise.reject(error); } ); } /** * * calendar ui improvements */ // progressive enhancement on html form with no JS document.addEventListener('change', (event) => { const target = event.target; if (target?.matches(SELECTORS.calendarToggle)) { const slug = target.value; const show = target.checked; document .querySelectorAll(`[data-calendar="${slug}"]`) .forEach(el => el.classList.toggle('hidden', !show)); return; } const form = target?.form; if (!form || form.id !== 'calendar-view') return; if (target.name !== 'view') return; form.requestSubmit(); }); // close event modal on back/forward navigation window.addEventListener('popstate', () => { if (!document.querySelector('article#calendar')) return; const dialog = document.querySelector('dialog'); if (!dialog?.open) return; const modal = dialog.querySelector('#modal'); if (!modal?.querySelector('[data-modal-kind="event"]')) return; dialog.close(); }); /** * event form all-day toggle */ function initEventAllDayToggles(root = document) { const toDate = (value) => { if (!value) return ''; return value.split('T')[0]; }; const withTime = (date, time) => { if (!date) return ''; return `${date}T${time}`; }; root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => { if (toggle.__allDayWired) return; toggle.__allDayWired = true; const form = toggle.closest('form'); if (!form) return; const start = form.querySelector(SELECTORS.eventStartInput); const end = form.querySelector(SELECTORS.eventEndInput); if (!start || !end) return; const apply = () => { if (toggle.checked) { if (start.type === 'datetime-local') { start.dataset.datetimeValue = start.value; } if (end.type === 'datetime-local') { end.dataset.datetimeValue = end.value; } const startDate = toDate(start.value); const endDate = toDate(end.value); start.type = 'date'; end.type = 'date'; if (startDate) start.value = startDate; if (endDate) end.value = endDate; if (start.value && end.value && end.value < start.value) { end.value = start.value; } } else { const startDate = toDate(start.value); const endDate = toDate(end.value); const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00'; const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00'; start.type = 'datetime-local'; end.type = 'datetime-local'; start.value = start.dataset.datetimeValue || withTime(startDate, startTime); end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime); } }; toggle.addEventListener('change', apply); apply(); }); } /** * recurrence preset selector */ function initRecurrenceControls(root = document) { const sections = root.querySelectorAll(SELECTORS.recurrenceSection); if (!sections.length) return; const select = root.querySelector(SELECTORS.recurrenceFrequency); const intervalRow = root.querySelector(SELECTORS.recurrenceInterval); const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit); const monthModes = root.querySelectorAll(SELECTORS.monthlyMode); const monthDays = root.querySelector(SELECTORS.monthlyDays); const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday); if (!select) return; const unitMap = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year', }; const applyMonthlyMode = () => { if (!monthDays || !monthWeekday) return; const modeInput = Array.from(monthModes).find((input) => input.checked); const mode = modeInput?.value || 'days'; if (mode === 'weekday') { monthDays.classList.add('hidden'); monthWeekday.classList.remove('hidden'); } else { monthDays.classList.remove('hidden'); monthWeekday.classList.add('hidden'); } }; const apply = () => { const value = select.value; const show = value !== ''; sections.forEach((section) => { const type = section.getAttribute('data-recurrence-section'); if (!show) { section.classList.add('hidden'); return; } section.classList.toggle('hidden', type !== value); }); if (intervalRow) { intervalRow.classList.toggle('hidden', !show); } if (intervalUnit) { const unit = unitMap[value] || 'day'; intervalUnit.textContent = unit ? `${unit}(s)` : ''; } if (value === 'monthly') { applyMonthlyMode(); } }; select.addEventListener('change', apply); monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode)); apply(); } /** * * modal behaviors (backdrop close + url restore) */ function initModalHandlers(root = document) { const dialog = root.querySelector(SELECTORS.modalDialog); if (!dialog || dialog.__modalWired) return; dialog.__modalWired = true; dialog.addEventListener('click', (event) => { if (event.target === dialog) { dialog.close(); } }); dialog.addEventListener('close', () => { const modal = dialog.querySelector(SELECTORS.modalContent); if (!modal) return; const isEvent = modal.querySelector('[data-modal-kind="event"]'); const prevUrl = modal.dataset.prevUrl; modal.innerHTML = ''; clearModalRootClass(modal); if (isEvent && prevUrl) { history.replaceState({}, '', prevUrl); } }); } /** * tabs (simple modal panels) */ function initTabs(root = document) { root.querySelectorAll(SELECTORS.tabsRoot).forEach((tabs) => { if (tabs.__tabsWired) return; tabs.__tabsWired = true; const tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton)); const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel)); if (!tabEls.length || !panels.length) return; const getPanelForTab = (tab, index) => { const controls = tab.getAttribute('aria-controls'); if (controls) { const panelById = tabs.querySelector(`[role="tabpanel"]#${controls}`); if (panelById) return panelById; } return panels[index] || null; }; const panelByTab = new Map(); tabEls.forEach((tab, index) => { const panel = getPanelForTab(tab, index); if (panel) { panelByTab.set(tab, panel); } //
  • is not focusable by default. if (!tab.hasAttribute('tabindex')) { tab.setAttribute('tabindex', '-1'); } }); const activate = (activeTab, moveFocus = false) => { tabEls.forEach((tab) => { const isActive = tab === activeTab; tab.setAttribute('aria-selected', isActive ? 'true' : 'false'); tab.setAttribute('tabindex', isActive ? '0' : '-1'); }); panels.forEach((panel) => { panel.hidden = true; }); const panel = panelByTab.get(activeTab); if (panel) { panel.hidden = false; } if (moveFocus && typeof activeTab.focus === 'function') { activeTab.focus(); } }; tabs.addEventListener('click', (event) => { const tab = event.target.closest(SELECTORS.tabButton); if (!tab || !tabs.contains(tab)) return; event.preventDefault(); activate(tab, false); }); tabs.addEventListener('keydown', (event) => { const currentTab = event.target.closest(SELECTORS.tabButton); if (!currentTab || !tabs.contains(currentTab)) return; const currentIndex = tabEls.indexOf(currentTab); if (currentIndex === -1) return; let nextIndex = null; const horizontal = ['ArrowLeft', 'ArrowRight']; const vertical = ['ArrowUp', 'ArrowDown']; if (event.key === 'Home') nextIndex = 0; if (event.key === 'End') nextIndex = tabEls.length - 1; if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { nextIndex = (currentIndex - 1 + tabEls.length) % tabEls.length; } if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { nextIndex = (currentIndex + 1) % tabEls.length; } const orientation = tabs.querySelector('[role="tablist"]')?.getAttribute('aria-orientation') || 'horizontal'; if (orientation === 'vertical' && horizontal.includes(event.key)) return; if (orientation !== 'vertical' && vertical.includes(event.key)) return; if (nextIndex === null) return; event.preventDefault(); activate(tabEls[nextIndex], true); }); const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || tabEls[0]; if (current) activate(current, false); }); } /** * * auto-scroll time views to 8am on load (when daytime hours are disabled) */ function initTimeViewAutoScroll(root = document) { // make sure we're on a time calendar const calendar = root.querySelector('.calendar.time'); if (!calendar) return; // get out if we're autoscrolled or daytime hours is set if (calendar.dataset.autoscrolled === '1') return; if (calendar.dataset.daytimeHoursEnabled === '1') return; // find the target minute (8:00am) const target = calendar.querySelector('[data-slot-minutes="480"]'); if (!target) return; // get the scroll container and offset const container = calendar.closest('article') || document.querySelector('article#calendar'); if (!container) return; const header = container.querySelector('header'); const headerOffset = header ? header.offsetHeight : 0; const containerRect = container.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const top = targetRect.top - containerRect.top + container.scrollTop - headerOffset - 12; // scroll container.scrollTo({ top: Math.max(top, 0), behavior: 'auto' }); calendar.dataset.autoscrolled = '1'; } /** * * calendar sidebar expand toggle */ document.addEventListener('click', (event) => { const toggle = event.target.closest(SELECTORS.calendarExpandToggle); if (!toggle) return; event.preventDefault(); const main = toggle.closest('main'); if (!main) return; const isExpanded = main.classList.toggle('expanded'); toggle.setAttribute('aria-pressed', isExpanded ? 'true' : 'false'); }); /** * * color picker component * native + hex + random palette) */ function initColorPickers(root = document) { const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test((v || '').trim()); const normalize = (v) => { let s = (v || '').trim(); if (!s) return null; if (!s.startsWith('#')) s = '#' + s; if (!isHex(s)) return null; return s.toUpperCase(); }; const pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)]; const wire = (el) => { // avoid double-binding when htmx swaps if (el.__colorpickerWired) return; el.__colorpickerWired = true; const color = el.querySelector(SELECTORS.colorPickerColor); const hex = el.querySelector(SELECTORS.colorPickerHex); const btn = el.querySelector(SELECTORS.colorPickerRandom); if (!color || !hex) return; let palette = []; try { palette = JSON.parse(el.getAttribute('data-palette') || '[]'); } catch { palette = []; } const setValue = (val) => { const n = normalize(val); if (!n) return false; color.value = n; hex.value = n; // bubble input/change for any listeners (htmx, previews, etc.) color.dispatchEvent(new Event('input', { bubbles: true })); color.dispatchEvent(new Event('change', { bubbles: true })); return true; }; // init sync from native input hex.value = normalize(color.value) || '#000000'; // native picker -> hex field color.addEventListener('input', () => { const n = normalize(color.value); if (n) hex.value = n; }); // hex typing -> native picker (on blur + Enter) const commitHex = () => { const ok = setValue(hex.value); if (!ok) hex.value = normalize(color.value) || hex.value; }; hex.addEventListener('blur', commitHex); hex.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commitHex(); } }); // random button if (btn && palette.length) { btn.addEventListener('click', (e) => { e.preventDefault(); // defensive: never submit, never navigate e.stopPropagation(); let next = pickRandom(palette); if (palette.length > 1) { const current = normalize(color.value); // avoid re-rolling the same number if possible while (normalize(next) === current) next = pickRandom(palette); } setValue(next); }); } }; root.querySelectorAll(SELECTORS.colorPicker).forEach(wire); } /** * * month view overflow handling (progressive enhancement) */ function initMonthOverflow(root = document) { const days = root.querySelectorAll(SELECTORS.monthDay); days.forEach((day) => updateMonthOverflow(day)); } function ensureDayMoreButton(dayEl) { let wrapper = dayEl.querySelector(SELECTORS.monthDayMoreWrap); if (!wrapper) { wrapper = document.createElement('div'); wrapper.className = 'more-events'; dayEl.appendChild(wrapper); } let button = wrapper.querySelector(SELECTORS.monthDayMore); if (!button) { button = document.createElement('button'); button.type = 'button'; button.className = 'day-more hidden'; button.setAttribute('data-day-more', ''); wrapper.appendChild(button); } return button; } function formatMoreLabel(dayEl, count) { const template = dayEl.getAttribute('data-more-label') || ':count more'; return template.replace(':count', count); } function lessLabel(dayEl) { return dayEl.getAttribute('data-less-label') || 'Show less'; } function updateMonthOverflow(dayEl) { if (!dayEl) return; const events = Array.from(dayEl.querySelectorAll(SELECTORS.monthDayEvent)) .filter((el) => !el.classList.contains('hidden')); const moreButton = ensureDayMoreButton(dayEl); if (!events.length) { moreButton.textContent = ''; moreButton.classList.add('hidden'); moreButton.removeAttribute('aria-expanded'); dayEl.classList.remove('day--event-overflow'); dayEl.setAttribute('data-event-visible', '0'); return; } if (dayEl.classList.contains('is-expanded')) { moreButton.textContent = lessLabel(dayEl); moreButton.classList.remove('hidden'); moreButton.setAttribute('aria-expanded', 'true'); dayEl.classList.remove('day--event-overflow'); dayEl.setAttribute('data-event-visible', String(events.length)); return; } const wrapper = moreButton.closest(SELECTORS.monthDayMoreWrap); let wrapperHeight = wrapper ? wrapper.getBoundingClientRect().height : 0; if (wrapperHeight === 0 && wrapper) { const wasHidden = moreButton.classList.contains('hidden'); const prevVisibility = moreButton.style.visibility; if (wasHidden) { moreButton.classList.remove('hidden'); moreButton.style.visibility = 'hidden'; } wrapperHeight = wrapper.getBoundingClientRect().height || 0; if (wasHidden) { moreButton.classList.add('hidden'); moreButton.style.visibility = prevVisibility; } } const prevVisibility = dayEl.style.visibility; dayEl.style.visibility = 'hidden'; dayEl.removeAttribute('data-event-visible'); dayEl.classList.remove('day--event-overflow'); const availableHeight = dayEl.clientHeight - wrapperHeight; let hiddenCount = 0; events.forEach((eventEl) => { const bottom = eventEl.offsetTop + eventEl.offsetHeight; if (bottom > availableHeight + 0.5) { hiddenCount += 1; } }); dayEl.style.visibility = prevVisibility; const visibleCount = Math.max(0, events.length - hiddenCount); dayEl.setAttribute('data-event-visible', String(visibleCount)); if (hiddenCount > 0) { moreButton.textContent = formatMoreLabel(dayEl, hiddenCount); moreButton.classList.remove('hidden'); moreButton.setAttribute('aria-expanded', 'false'); dayEl.classList.add('day--event-overflow'); } else { moreButton.textContent = ''; moreButton.classList.add('hidden'); moreButton.removeAttribute('aria-expanded'); dayEl.classList.remove('day--event-overflow'); } } // show more events in a month calendar day when some are hidden document.addEventListener('click', (event) => { const button = event.target.closest(SELECTORS.monthDayMore); if (!button) return; const dayEl = button.closest(SELECTORS.monthDay); if (!dayEl) return; dayEl.classList.toggle('is-expanded'); updateMonthOverflow(dayEl); }); // month day resizer let monthResizeTimer; window.addEventListener('resize', () => { if (!document.querySelector(SELECTORS.monthDay)) return; window.clearTimeout(monthResizeTimer); monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100); }); /** * * initialization */ function initUI() { initColorPickers(); initEventAllDayToggles(); initRecurrenceControls(); initModalHandlers(); initTabs(); initTimeViewAutoScroll(); initMonthOverflow(); } // initial bind document.addEventListener('DOMContentLoaded', initUI); // rebind in htmx for swapped content document.addEventListener('htmx:afterSwap', (e) => { const target = e.detail?.target || e.target; if (target && target.id === 'modal') { syncModalRootClass(target); target.closest('dialog')?.showModal(); } initColorPickers(e.target); initEventAllDayToggles(e.target); initRecurrenceControls(e.target); initModalHandlers(e.target); initTabs(e.target); initTimeViewAutoScroll(e.target); initMonthOverflow(e.target); });