192 lines
7.1 KiB
JavaScript
192 lines
7.1 KiB
JavaScript
/* ============================================================
|
||
FRAMEWORK.JS — CSS-Framework JavaScript API
|
||
Version: 2.1.0
|
||
|
||
Zwei gleichwertige Wege:
|
||
1. Deklarativ über data-action (CSP-fest, empfohlen):
|
||
<button data-action="modal-open" data-target="m">Öffnen</button>
|
||
Ein einziger Listener am document wertet die Attribute aus. Das
|
||
funktioniert auch für Inhalte, die HTMX später nachlädt.
|
||
2. Globale Funktionen (wie bisher), z. B. openModal('m'). Inline-Handler
|
||
wie onclick="…" verlangen aber 'unsafe-inline' in der CSP.
|
||
|
||
Einbinden: <script src="/static/js/framework.js" defer></script>
|
||
============================================================ */
|
||
|
||
// ---- SIDEBAR (Mobile) ----
|
||
function toggleSidebar() { document.body.classList.add('sidebar-open'); }
|
||
function closeSidebar() { document.body.classList.remove('sidebar-open'); }
|
||
|
||
// ---- MODAL ----
|
||
function openModal(id) {
|
||
document.getElementById(id).classList.add('open');
|
||
document.body.classList.add('modal-open');
|
||
}
|
||
|
||
function closeModal(id) {
|
||
document.getElementById(id).classList.remove('open');
|
||
if (!document.querySelector('.modal-backdrop.open')) document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
function closeOnBackdrop(e, id) {
|
||
if (e.target === e.currentTarget) closeModal(id);
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
document.querySelectorAll('.modal-backdrop.open').forEach(m => m.classList.remove('open'));
|
||
document.body.classList.remove('modal-open');
|
||
}
|
||
});
|
||
|
||
// ---- TABS ----
|
||
function switchTab(btn, panelId) {
|
||
const tabList = btn.closest('.tab-list');
|
||
tabList.querySelectorAll('button').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
|
||
const tabs = btn.closest('.tabs');
|
||
tabs.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(panelId).classList.add('active');
|
||
}
|
||
|
||
// ---- TOPNAV DROPDOWN ----
|
||
function toggleNavDd(btn) {
|
||
const li = btn.closest('li');
|
||
const isOpen = li.classList.contains('open');
|
||
document.querySelectorAll('.topnav-links li.open').forEach(l => l.classList.remove('open'));
|
||
if (!isOpen) li.classList.add('open');
|
||
}
|
||
|
||
// ---- DROPDOWN ----
|
||
function toggleDropdown(id) {
|
||
const dd = document.getElementById(id);
|
||
const isOpen = dd.classList.contains('open');
|
||
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
|
||
if (!isOpen) dd.classList.add('open');
|
||
}
|
||
|
||
// ---- DISMISSIBLE ALERT ----
|
||
function dismissAlert(btn) {
|
||
const alert = btn.closest('.alert');
|
||
alert.classList.add('is-dismissing');
|
||
alert.addEventListener('transitionend', () => alert.remove(), { once: true });
|
||
}
|
||
|
||
// ---- SORTIERBARE TABELLEN ----
|
||
function sortTable(th) {
|
||
const table = th.closest('table');
|
||
const col = [...th.parentElement.children].indexOf(th);
|
||
const dir = th.dataset.sort === 'asc' ? 'desc' : 'asc';
|
||
|
||
table.querySelectorAll('th[data-sort]').forEach(t => {
|
||
if (t !== th) t.dataset.sort = '';
|
||
});
|
||
th.dataset.sort = dir;
|
||
|
||
const tbody = table.querySelector('tbody');
|
||
[...tbody.querySelectorAll('tr')]
|
||
.sort((a, b) => {
|
||
const aVal = a.cells[col].textContent.trim();
|
||
const bVal = b.cells[col].textContent.trim();
|
||
const aNum = parseFloat(aVal.replace(/[^0-9.-]/g, ''));
|
||
const bNum = parseFloat(bVal.replace(/[^0-9.-]/g, ''));
|
||
if (!isNaN(aNum) && !isNaN(bNum))
|
||
return dir === 'asc' ? aNum - bNum : bNum - aNum;
|
||
return dir === 'asc'
|
||
? aVal.localeCompare(bVal, 'de')
|
||
: bVal.localeCompare(aVal, 'de');
|
||
})
|
||
.forEach(r => tbody.appendChild(r));
|
||
}
|
||
|
||
// ---- TOAST ----
|
||
// Baut den Toast über das DOM: kein innerHTML, kein style-Attribut. Die Meldung
|
||
// wird als Text gesetzt, HTML darin wird also nicht ausgeführt.
|
||
function showToast(message, type = 'info') {
|
||
const icons = { success: '✓', warning: '⚠', danger: '✕', info: 'ℹ' };
|
||
if (!(type in icons)) type = 'info';
|
||
const id = 'toast-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast';
|
||
toast.id = id;
|
||
toast.setAttribute('role', 'status');
|
||
|
||
const icon = document.createElement('span');
|
||
icon.className = 'toast-icon toast-icon-' + type;
|
||
icon.textContent = icons[type];
|
||
|
||
const content = document.createElement('div');
|
||
content.className = 'toast-content';
|
||
const title = document.createElement('p');
|
||
title.className = 'toast-title';
|
||
title.textContent = message;
|
||
content.appendChild(title);
|
||
|
||
const close = document.createElement('button');
|
||
close.className = 'toast-close';
|
||
close.type = 'button';
|
||
close.setAttribute('aria-label', 'Schließen');
|
||
close.dataset.action = 'toast-close';
|
||
close.textContent = '×';
|
||
|
||
toast.append(icon, content, close);
|
||
document.getElementById('toast-container').appendChild(toast);
|
||
setTimeout(() => removeToast(id), 4000);
|
||
}
|
||
|
||
function removeToast(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.classList.add('is-leaving');
|
||
setTimeout(() => el.remove(), 200);
|
||
}
|
||
}
|
||
|
||
// ---- FORTSCHRITT ----
|
||
// <div class="progress-bar" data-progress="75"></div>
|
||
// Variable Breiten lassen sich nicht als Klasse ausdrücken. Über das DOM
|
||
// gesetzte Styles erlaubt die CSP, style-Attribute im HTML nicht.
|
||
function applyProgress(root) {
|
||
(root || document).querySelectorAll('[data-progress]').forEach(el => {
|
||
const wert = Math.max(0, Math.min(100, parseFloat(el.dataset.progress) || 0));
|
||
el.style.width = wert + '%';
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => applyProgress());
|
||
// Nach jedem Austausch durch HTMX (Ereignis htmx:load) auch neue Inhalte
|
||
document.addEventListener('htmx:load', e => applyProgress(e.target));
|
||
|
||
// ---- DATA-ACTION ----
|
||
// data-action Aktion (siehe Tabelle in README.md, „JavaScript-API“)
|
||
// data-target ID des Ziels, z. B. Modal, Dropdown oder Tab-Panel
|
||
// data-class Klasse für toggle-class
|
||
// data-message Text für toast, data-type für dessen Art
|
||
const frameworkActions = {
|
||
'sidebar-open': () => toggleSidebar(),
|
||
'sidebar-close': () => closeSidebar(),
|
||
'modal-open': el => openModal(el.dataset.target),
|
||
'modal-close': el => closeModal(el.dataset.target || el.closest('.modal-backdrop').id),
|
||
'modal-backdrop': (el, e) => { if (e.target === el) closeModal(el.id); },
|
||
'tab': el => switchTab(el, el.dataset.target),
|
||
'nav-dropdown': el => toggleNavDd(el),
|
||
'dropdown': el => toggleDropdown(el.dataset.target || el.closest('.dropdown').id),
|
||
'alert-dismiss': el => dismissAlert(el),
|
||
'sort': el => sortTable(el),
|
||
'toggle-class': el => el.classList.toggle(el.dataset.class),
|
||
'toast': el => showToast(el.dataset.message, el.dataset.type),
|
||
'toast-close': el => removeToast(el.closest('.toast').id),
|
||
};
|
||
|
||
document.addEventListener('click', e => {
|
||
const el = e.target.closest('[data-action]');
|
||
const action = el && frameworkActions[el.dataset.action];
|
||
if (action) action(el, e);
|
||
|
||
if (!e.target.closest('.topnav-links li'))
|
||
document.querySelectorAll('.topnav-links li.open').forEach(l => l.classList.remove('open'));
|
||
if (!e.target.closest('.dropdown'))
|
||
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
|
||
});
|