Files

257 lines
11 KiB
JavaScript

// Kleine, abhängigkeitsfreie Sortier-/Such-/Filterfunktion für Tabellen
// (GUI-Umbau 10./11.09.2026) - reines Vanilla-JS, kein Framework im Einsatz
// (Pico bringt keins mit, und für ein paar Dutzend Zeilen braucht es keins).
//
// Sortierung: Klick auf eine <th data-sort> togglet aufsteigend/absteigend,
// erkennt Zahlen automatisch (numerische statt alphabetische Sortierung).
//
// Suche: ein <input data-table-search="#tabellen-id"> filtert Zeilen per
// Volltextsuche über alle Zellen.
//
// Spalten-Filter (11.09.2026, Nutzer-Wunsch "wie Excels Autofilter"): ein
// <th data-filter> bekommt automatisch ein kleines Trichter-Symbol - Klick
// öffnet ein Dropdown mit Checkboxen für jeden in dieser Spalte tatsächlich
// vorkommenden Wert (kein Server-Code nötig, rein aus den gerenderten
// Zellen ermittelt). Ersetzt das ältere, serverseitig befüllte
// data-table-category-filter-Muster vollständig - ein Attribut auf dem
// <th> statt eines eigenen Werte-Blocks im Template. Wie bei Excel hat
// konsequent JEDE sortierbare Spalte auch einen Filter, unabhängig davon,
// wie sinnvoll das im Einzelfall ist (z.B. eine eindeutige Hostname-
// Spalte) - Konsistenz war der explizite Nutzer-Wunsch.
//
// Suche und Spalten-Filter kombinieren sich (alle müssen "durchlassen",
// damit eine Zeile sichtbar bleibt) - dafür trägt jede Zeile pro Filterart
// einen eigenen "versteckt"-Grund in ihrem dataset, row.hidden ist das
// Oder aller Gründe, nicht der zuletzt gesetzte Filter allein.
//
// Aktiver Filter (11.09.2026, Nutzer-Wunsch): solange ein Spalten-Filter
// tatsächlich einschränkt (nicht mehr alle Werte angehakt sind), bekommt
// das <th> die Klasse .spalten-gefiltert (sanfte Akzentfarbe, siehe
// base.html) - Trichter-Icon UND Spaltentitel färben sich mit
// (fill="currentColor" im SVG übernimmt automatisch dieselbe Farbe).
// Filter-Menü (22.10.2026, Nutzer-Wunsch): oben „Aufsteigend“/„Absteigend“
// sortieren und „Alle an“/„Alle aus“. Das Menü wird beim Öffnen fest am
// Fenster ausgerichtet wie die Aktionen-Menüs (dropdown-platz.js): als
// absolut positioniertes Kind der Kopfzelle schnitt es eine seitlich
// scrollende Tabelle ab (z. B. Merkmale bearbeiten, Spalte Key).
const FILTER_RAND = 8;
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("table").forEach((table) => {
const headerZeile = table.querySelector("thead tr");
if (!headerZeile) return;
Array.from(headerZeile.children).forEach((th, spaltenIndex) => {
if ("sort" in th.dataset) {
th.style.cursor = "pointer";
th.addEventListener("click", (e) => {
if (e.target.closest(".spalten-filter, .spalten-filter-menu")) return;
sortiereTabelle(table, th, spaltenIndex);
});
}
if ("filter" in th.dataset) {
baueSpaltenFilter(table, th, spaltenIndex);
}
});
});
document.querySelectorAll("[data-table-search]").forEach((input) => {
const table = document.querySelector(input.dataset.tableSearch);
if (!table) return;
input.addEventListener("input", () => filtereTabelle(table, input.value));
});
// Offene Filter-Dropdowns bei Klick ausserhalb schliessen.
document.addEventListener("click", (e) => {
if (e.target.closest(".spalten-filter, .spalten-filter-menu")) return;
document.querySelectorAll(".spalten-filter-menu").forEach((menu) => (menu.hidden = true));
});
// Offenes Menü zieht beim Scrollen und bei geänderter Fenstergröße mit;
// Scrollen im Menü selbst löst nichts aus.
const offeneMenues = () => document.querySelectorAll(".spalten-filter-menu:not([hidden])");
document.addEventListener("scroll", (e) => {
offeneMenues().forEach((menu) => {
if (e.target instanceof Node && menu.contains(e.target)) return;
richteFilterMenuAus(menu);
});
}, true);
window.addEventListener("resize", () => offeneMenues().forEach(richteFilterMenuAus));
});
function sortiereTabelle(table, th, spaltenIndex, richtung) {
const tbody = table.querySelector("tbody");
const zeilen = Array.from(tbody.querySelectorAll("tr"));
// Ohne Richtung (Klick auf den Spaltentitel) wird umgeschaltet.
const aufsteigend = richtung ? richtung === "asc" : th.dataset.sortDir !== "asc";
table.querySelectorAll("th[data-sort]").forEach((h) => delete h.dataset.sortDir);
th.dataset.sortDir = aufsteigend ? "asc" : "desc";
zeilen.sort((a, b) => {
// GUI-Runde 09.10.2026: Zellen mit data-sort-wert (z. B. Datum als
// ISO-Zeitstempel hinter "09.10.2026, 11:20") sortieren nach diesem
// Wert statt nach dem angezeigten Text.
const as = sortWert(a, spaltenIndex);
const bs = sortWert(b, spaltenIndex);
if (as !== undefined && bs !== undefined) {
const v = as < bs ? -1 : as > bs ? 1 : 0;
return aufsteigend ? v : -v;
}
const av = zellText(a, spaltenIndex);
const bv = zellText(b, spaltenIndex);
const an = parseFloat(av.replace(",", "."));
const bn = parseFloat(bv.replace(",", "."));
let vergleich;
if (!isNaN(an) && !isNaN(bn) && av.trim() !== "" && bv.trim() !== "") {
vergleich = an - bn;
} else {
vergleich = av.localeCompare(bv, "de");
}
return aufsteigend ? vergleich : -vergleich;
});
zeilen.forEach((row) => tbody.appendChild(row));
}
function sortWert(row, index) {
const zelle = row.children[index];
return zelle && "sortWert" in zelle.dataset ? zelle.dataset.sortWert : undefined;
}
function zellText(row, index) {
const zelle = row.children[index];
return zelle ? zelle.textContent.trim() : "";
}
function setzeVersteckt(row, grund, versteckt) {
row.dataset["versteckt" + grund] = versteckt ? "1" : "0";
const gruende = Object.keys(row.dataset).filter((k) => k.startsWith("versteckt"));
row.hidden = gruende.some((k) => row.dataset[k] === "1");
}
function filtereTabelle(table, suchtext) {
const nadel = suchtext.trim().toLowerCase();
table.querySelectorAll("tbody tr").forEach((row) => {
setzeVersteckt(row, "Suche", nadel.length > 0 && !row.textContent.toLowerCase().includes(nadel));
});
}
function baueSpaltenFilter(table, th, spaltenIndex) {
const werte = new Set();
table.querySelectorAll("tbody tr").forEach((row) => {
werte.add(zellText(row, spaltenIndex) || "—");
});
// Nutzer-Feedback 11.09.2026 (zweimal jetzt, siehe auch den Bugfix
// vom Vortag zum gleichen Thema): das Icon soll IMMER erscheinen,
// unabhängig davon, wie viele unterschiedliche Werte aktuell
// vorkommen - nicht nur ab 2 Werten. Konsistenz/Verlässlichkeit
// schlägt hier den (kleinen) Nutzen, ein ungenutztes Icon bei nur
// einem Wert auszublenden.
if (werte.size === 0) return;
const knopf = document.createElement("button");
knopf.type = "button";
knopf.className = "spalten-filter";
knopf.setAttribute("aria-label", "Nach " + th.textContent.trim() + " filtern");
// Nutzer-Feedback 11.09.2026: ein Dreieck (▾) sieht der Sortier-
// Funktion zu ähnlich, leicht verwechselbar. Eigenes kleines
// Trichter-Symbol statt Emoji (rendert konsistent über Schriftarten/
// Betriebssysteme hinweg, keine externe Icon-Bibliothek nötig).
knopf.innerHTML =
'<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">' +
'<path d="M1 2h14l-5.5 6.5v5L6.5 15v-6.5z"/></svg>';
const menu = document.createElement("div");
menu.className = "spalten-filter-menu";
menu.hidden = true;
const werkzeuge = document.createElement("div");
werkzeuge.className = "spalten-filter-werkzeuge";
const werkzeug = (text, aktion) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = text;
b.addEventListener("click", (e) => {
e.stopPropagation();
aktion();
});
werkzeuge.appendChild(b);
};
if ("sort" in th.dataset) {
werkzeug("↑ Aufsteigend", () => sortiereTabelle(table, th, spaltenIndex, "asc"));
werkzeug("↓ Absteigend", () => sortiereTabelle(table, th, spaltenIndex, "desc"));
}
const alleSetzen = (an) => {
menu.querySelectorAll("label input").forEach((c) => (c.checked = an));
wendeSpaltenFilterAn(table, spaltenIndex, menu, th);
};
werkzeug("Alle an", () => alleSetzen(true));
werkzeug("Alle aus", () => alleSetzen(false));
menu.appendChild(werkzeuge);
Array.from(werte).sort((a, b) => a.localeCompare(b, "de")).forEach((wert) => {
const label = document.createElement("label");
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = true;
checkbox.value = wert;
checkbox.addEventListener("change", () => wendeSpaltenFilterAn(table, spaltenIndex, menu, th));
label.appendChild(checkbox);
label.append(" " + wert);
menu.appendChild(label);
});
knopf.addEventListener("click", (e) => {
e.stopPropagation();
const warOffen = !menu.hidden;
document.querySelectorAll(".spalten-filter-menu").forEach((m) => (m.hidden = true));
menu.hidden = warOffen;
if (!menu.hidden) richteFilterMenuAus(menu);
});
th.appendChild(knopf);
th.appendChild(menu);
}
// Fest am Fenster, linksbündig unter dem Trichter; nie über den
// Fensterrand hinaus, bei Platzmangel nach oben.
function richteFilterMenuAus(menu) {
const knopf = menu.parentElement.querySelector(".spalten-filter");
if (!knopf) return;
const k = knopf.getBoundingClientRect();
const breite = menu.offsetWidth;
const hoehe = menu.offsetHeight;
let links = Math.min(k.left, window.innerWidth - breite - FILTER_RAND);
links = Math.max(FILTER_RAND, links);
const platzUnten = window.innerHeight - k.bottom - FILTER_RAND;
const platzOben = k.top - FILTER_RAND;
let oben = k.bottom;
if (hoehe > platzUnten && platzOben > platzUnten) oben = k.top - hoehe;
oben = Math.max(FILTER_RAND, Math.min(oben, window.innerHeight - hoehe - FILTER_RAND));
menu.style.left = `${links}px`;
menu.style.top = `${oben}px`;
}
function wendeSpaltenFilterAn(table, spaltenIndex, menu, th) {
const alle = menu.querySelectorAll("label input");
const aktiv = new Set(
Array.from(alle).filter((c) => c.checked).map((c) => c.value)
);
table.querySelectorAll("tbody tr").forEach((row) => {
const zelle = zellText(row, spaltenIndex) || "—";
setzeVersteckt(row, "Filter" + spaltenIndex, !aktiv.has(zelle));
});
// Nutzer-Wunsch 11.09.2026: die Titelzeile "sanft farbig" markieren,
// solange der Filter tatsächlich einschränkt (nicht alle Werte
// angehakt) - das Trichter-Icon nutzt fill="currentColor", übernimmt
// die Akzentfarbe also automatisch mit.
th.classList.toggle("spalten-gefiltert", aktiv.size < alle.length);
}