262 lines
9.2 KiB
JavaScript
262 lines
9.2 KiB
JavaScript
// ==========================================================================
|
||
// HTTP helpers
|
||
// ==========================================================================
|
||
async function _post(formObj, target, func) {
|
||
try {
|
||
const body = await createFormData(formObj, target, func);
|
||
const res = await fetch('/rss/php/handler.php', { method: 'POST', body });
|
||
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return await res.json();
|
||
} catch (err) {
|
||
console.error('Post failed', err);
|
||
return { ok: false, error: String(err?.message || err) };
|
||
}
|
||
}
|
||
|
||
async function createFormData(data = {}, target, func) {
|
||
const fd = new FormData();
|
||
try {
|
||
Object.entries(data || {}).forEach(([k, v]) => {
|
||
if (Array.isArray(v)) v.forEach((item) => fd.append(`${k}[]`, item));
|
||
else if (v !== undefined && v !== null) fd.append(k, v);
|
||
});
|
||
if (target !== undefined) fd.append('target', target);
|
||
if (func !== undefined) fd.append('function', func);
|
||
} catch (err) {
|
||
console.error('Incorrect use of object', err);
|
||
}
|
||
return fd;
|
||
}
|
||
|
||
// ==========================================================================
|
||
// DOM helpers
|
||
// ==========================================================================
|
||
function createEl(tag, classArr = null, text = null, dataAttrs = null) {
|
||
const el = document.createElement(tag);
|
||
if (text !== null && text !== undefined) el.append(document.createTextNode(String(text)));
|
||
if (classArr && classArr.length) el.classList.add(...classArr);
|
||
if (dataAttrs && Object.keys(dataAttrs).length) {
|
||
for (const [k, v] of Object.entries(dataAttrs)) el.dataset[k] = v;
|
||
}
|
||
return el;
|
||
}
|
||
|
||
function getRandomID() {
|
||
if (crypto?.randomUUID) return crypto.randomUUID();
|
||
return Math.random().toString(36).slice(2);
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Alerts (aligned with .alert + .alert-{name})
|
||
// ==========================================================================
|
||
/*
|
||
generateAlert('success', 'Saved!') → <div class="alert alert-success" ...>Saved!</div>
|
||
Options: { dismissAfter: ms, withClose: boolean, role: 'status'|'alert' }
|
||
*/
|
||
async function generateAlert(type = 'info', text = '', opts = {}) {
|
||
const { dismissAfter = 0, withClose = false, role = 'status' } = opts;
|
||
const alert = createEl('div', ['alert', `alert-${type}`]);
|
||
alert.setAttribute('role', role); // status (polite) or alert (assertive)
|
||
alert.setAttribute('aria-live', role === 'alert' ? 'assertive' : 'polite');
|
||
alert.append(document.createTextNode(text));
|
||
|
||
if (withClose) {
|
||
const btn = createEl('button', ['btn', 'btn-sm', 'btn-ghost-dark'], '×', { dismiss: 'alert' });
|
||
btn.setAttribute('aria-label', 'Close alert');
|
||
btn.style.marginLeft = '0.5rem';
|
||
btn.addEventListener('click', () => alert.remove());
|
||
alert.append(btn);
|
||
}
|
||
|
||
if (dismissAfter > 0) setTimeout(() => alert.remove(), dismissAfter);
|
||
return alert;
|
||
}
|
||
|
||
async function removeAlert(scope = document) {
|
||
scope.querySelectorAll('.alert').forEach((a) => a.remove());
|
||
}
|
||
|
||
// ==========================================================================
|
||
// On load
|
||
// ==========================================================================
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Menu toggler (pointerdown → click for better a11y; add ARIA state)
|
||
const toggler = document.getElementById('menu-toggler');
|
||
const menu = document.getElementById('mainMenu');
|
||
|
||
if (toggler && menu) {
|
||
toggler.setAttribute('aria-expanded', 'false');
|
||
toggler.setAttribute('aria-controls', 'mainMenu');
|
||
|
||
const toggleMenu = () => {
|
||
const isOpen = menu.classList.toggle('open');
|
||
toggler.setAttribute('aria-expanded', String(isOpen));
|
||
};
|
||
|
||
toggler.addEventListener('click', toggleMenu);
|
||
toggler.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleMenu(); }
|
||
});
|
||
}
|
||
|
||
// Section reveal
|
||
const sections = document.querySelectorAll('section');
|
||
const observer = new IntersectionObserver(
|
||
(entries, obs) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.classList.add('visible');
|
||
obs.unobserve(entry.target);
|
||
}
|
||
});
|
||
},
|
||
{ threshold: 0.05, rootMargin: '0px 0px -50px 0px' }
|
||
);
|
||
|
||
sections.forEach((section) => {
|
||
section.classList.add('section-animation');
|
||
observer.observe(section);
|
||
});
|
||
|
||
(() => {
|
||
function resolveTarget(el) {
|
||
const viaData = el.getAttribute('data-target');
|
||
const viaHref = el.getAttribute('href');
|
||
if (viaData) return document.querySelector(viaData);
|
||
if (viaHref && viaHref.startsWith('#')) return document.querySelector(viaHref);
|
||
// Fallback: if only one modal exists and no selector provided
|
||
const all = document.querySelectorAll('.modal');
|
||
return all.length === 1 ? all[0] : null;
|
||
}
|
||
|
||
function trapFocus(modal, e) {
|
||
const focusables = modal.querySelectorAll('a,button,input,textarea,select,[tabindex]:not([tabindex="-1"])');
|
||
if (!focusables.length) return;
|
||
const first = focusables[0], last = focusables[focusables.length - 1];
|
||
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
|
||
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
|
||
}
|
||
|
||
let lastActive = null;
|
||
function openModal(modal) {
|
||
if (!modal) return;
|
||
lastActive = document.activeElement;
|
||
modal.classList.add('open');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
(modal.querySelector('.modal__dialog') || modal).focus({ preventScroll: true });
|
||
modal.addEventListener('keydown', onKeyDown);
|
||
}
|
||
function closeModal(modal) {
|
||
if (!modal) return;
|
||
modal.classList.remove('open');
|
||
modal.setAttribute('aria-hidden', 'true');
|
||
modal.removeEventListener('keydown', onKeyDown);
|
||
if (lastActive) lastActive.focus({ preventScroll: true });
|
||
}
|
||
function onKeyDown(e) {
|
||
const modal = e.currentTarget;
|
||
if (e.key === 'Escape') closeModal(modal);
|
||
if (e.key === 'Tab') trapFocus(modal, e);
|
||
}
|
||
|
||
// Openers
|
||
document.querySelectorAll('.modal-open').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
const modal = resolveTarget(btn);
|
||
if (modal) openModal(modal);
|
||
e.preventDefault();
|
||
});
|
||
btn.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
|
||
});
|
||
});
|
||
|
||
// Closers (overlay or close button)
|
||
document.addEventListener('click', (e) => {
|
||
const overlay = e.target.closest('.modal__overlay');
|
||
const closeBtn = e.target.closest('.modal__close');
|
||
if (!overlay && !closeBtn) return;
|
||
const modal = e.target.closest('.modal');
|
||
if (modal) closeModal(modal);
|
||
});
|
||
})();
|
||
|
||
(() => {
|
||
function getTargetId(tab) {
|
||
const href = tab.getAttribute('href');
|
||
if (href && href.startsWith('#')) return href.slice(1);
|
||
const aria = tab.getAttribute('aria-controls');
|
||
if (aria) return aria;
|
||
return null;
|
||
}
|
||
|
||
document.querySelectorAll('.tabs').forEach(tabs => {
|
||
const tabEls = tabs.querySelectorAll('.tab');
|
||
if (!tabEls.length) return;
|
||
|
||
// If panels are used, they should be siblings elsewhere with .tab-panel
|
||
function activateTab(tab) {
|
||
// Toggle active tab
|
||
tabEls.forEach(t => t.classList.toggle('active', t === tab));
|
||
|
||
// Panels (optional)
|
||
const targetId = getTargetId(tab);
|
||
const panels = document.querySelectorAll('.tab-panel');
|
||
if (panels.length) {
|
||
panels.forEach(p => {
|
||
const match = p.id && targetId && p.id === targetId;
|
||
p.hidden = !match;
|
||
});
|
||
}
|
||
}
|
||
|
||
// Initial state: keep existing .active or default to first
|
||
const current = tabs.querySelector('.tab.active') || tabEls[0];
|
||
if (current) activateTab(current);
|
||
|
||
tabEls.forEach(tab => {
|
||
tab.setAttribute('role', 'tab');
|
||
tab.addEventListener('click', (e) => {
|
||
activateTab(tab);
|
||
if (tab.hasAttribute('href')) e.preventDefault(); // prevent jump for href="#id"
|
||
});
|
||
tab.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tab.click(); }
|
||
// Optional arrow key UX within the same .tabs group
|
||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
const arr = Array.from(tabEls);
|
||
const i = arr.indexOf(tab);
|
||
const next = e.key === 'ArrowRight' ? (i + 1) % arr.length : (i - 1 + arr.length) % arr.length;
|
||
arr[next].focus();
|
||
}
|
||
});
|
||
});
|
||
});
|
||
})();
|
||
|
||
});
|
||
|
||
document.querySelectorAll('.accordion').forEach(acc => {
|
||
const single = acc.classList.contains('accordion--single');
|
||
|
||
acc.querySelectorAll('.accordion__header').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const item = btn.closest('.accordion__item');
|
||
const isOpen = item.classList.contains('is-open');
|
||
|
||
if (single) {
|
||
acc.querySelectorAll('.accordion__item.is-open').forEach(other => {
|
||
if (other !== item) other.classList.remove('is-open');
|
||
});
|
||
}
|
||
item.classList.toggle('is-open', !isOpen);
|
||
});
|
||
|
||
btn.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); }
|
||
});
|
||
});
|
||
});
|