kithkin/resources/js/app.js

376 lines
11 KiB
JavaScript

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]',
monthDay: '.calendar.month .day',
monthDayEvent: 'a.event',
monthDayMore: '[data-day-more]',
monthDayMoreWrap: '.more-events',
};
/**
*
* 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
})
/**
*
* 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();
});
/**
*
* 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 <input type="color"> + 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();
initMonthOverflow();
}
// initial bind
document.addEventListener('DOMContentLoaded', initUI);
// rebind in htmx for swapped content
document.addEventListener('htmx:afterSwap', (e) => {
initColorPickers(e.target);
initMonthOverflow(e.target);
});