/*! * FPU List Filter — CMS-Listen filtern über Attribute, ohne Code. * FlowPowerUser Attribute, Schema: fpu-filter-* * Version: 1.1.0 * * Rollen (fpu-filter-element="…"): * list die zu filternde Liste (.w-dyn-list oder beliebiger Container) * filters Container der Filter-UI (Webflow-Form ok, Submit wird unterdrückt) * count zeigt die sichtbare Anzahl; Template per "{n}" im Text ("{n} Ergebnisse") * empty wird gezeigt, sobald 0 Items sichtbar sind * reset setzt per Klick alle Filter zurück * tags Container für aktive Filter-Chips * tag-template Chip-Vorlage innerhalb von tags; optional darin tag-text * (Wert-Text) und tag-remove (Klickziel). Ohne tag-text wird der * Klon selbst zum Text und komplett klickbar. * * Filter-Controls (innerhalb von filters, fpu-filter-field="kategorie"): * Checkboxen/Radios value zählt (Webflow-Default "on" wird ignoriert → * Fallback fpu-filter-value bzw. Label-Text); * Radio mit leerem value wirkt als "Alle" * Selects ausgewählte Option(en); leeres value = kein Filter * Buttons/Elemente Toggle (Klasse fpu-filter-active, umstellbar per * fpu-filter-activeclass); Wert aus fpu-filter-value, * value-Attribut oder Text; * leerer Wert = "Alle"-Button (leert das Feld) * Textsuche input/textarea; field="*" durchsucht das ganze Item, * Feldname nur dieses Feld (Substring, 200ms Debounce) * Nur echte Textfelder (text/search/email/tel/url/ * password, textarea, input ohne type). Ein number-, * date- oder range-Input ohne fpu-filter-range wird * NICHT als Textsuche gedeutet, sondern gemeldet. * Von-Bis-Bereich fpu-filter-range="min" bzw. "max" auf zwei Inputs mit * demselben field; wirkt auf Zahlen und Daten. Der Typ * wird aus den Item-Werten erkannt und ist per * fpu-filter-fieldtype="number|date" erzwingbar. * Eine Grenze allein genügt ("ab 100", "bis 31.12."). * Bei Daten ohne Uhrzeit schließt die Obergrenze den * ganzen Tag ein. * * Item-Daten (Quelle der Wahrheit pro .w-dyn-item): * fpu-filter-{feld}="a, b" Attribut am Item, Komma = Multi-Wert * Kinder mit fpu-filter-field="…" Textinhalt zählt, mehrere = Multi-Wert * * Matching: case- und diakritik-insensitiv. Felder untereinander UND, Werte * eines Feldes ODER — pro Feld umschaltbar via fpu-filter-match="any|all" * (auf dem Control oder einem Eltern-Container). Ein Item passt auf einen * Bereich, sobald EINER seiner Werte hineinfällt. Versteckt wird per Klasse * fpu-filter-hidden (display:none !important, injiziert). * * Optionen: * fpu-filter-sync="false" URL-Query-Sync aus; Default an * (?kategorie=a,b — Suche "*" → ?search=…, * Bereiche → ?preis_min=…&preis_max=…) * fpu-filter-highlight="true" auf dem Such-Input: Treffer in sichtbaren * Items mit * fpu-filter-activeclass="is-active" Klasse, die aktive Toggle-Filter * tragen; Default fpu-filter-active. * Auf list oder filters setzen. * fpu-filter-instance="n" verknüpft Rollen-Elemente über Wrapper-Grenzen * fpu-filter-logging="true" eine Init-Zeile in der Konsole * * Globale Defaults (optional, Attribut gewinnt immer): * window.fpuListFilterConfig = { sync, highlight, debounce, logging, activeClass } * * API: window.fpuListFilter = { instances, init, destroyAll } * Reagiert auf fpu:items-changed (Items neu einsammeln + Filter reapplizieren). */ (() => { 'use strict'; const VERSION = '1.1.0'; const PREFIX = '[FPU List Filter]'; const ATTR = 'fpu-filter-'; const STYLE_ID = 'fpu-filter-styles'; const HIDDEN_CLASS = 'fpu-filter-hidden'; const ACTIVE_CLASS = 'fpu-filter-active'; // Default; pro Instanz via fpu-filter-activeclass ersetzbar const MARK_CLASS = 'fpu-filter-mark'; const DEBOUNCE_MS = 200; const ROLES = ['list', 'filters', 'count', 'empty', 'reset', 'tags']; // Suffixe, die beim Scannen der Item-Attribute KEINE Feldnamen sind const RESERVED = new Set(['element', 'field', 'instance', 'match', 'value', 'highlight', 'logging', 'sync', 'debounce', 'range', 'fieldtype', 'activeclass', 'tag-field', 'tag-value', 'tag-kind']); const DIACRITICS_RE = /[\u0300-\u036f]/g; // Input-Typen, die als Textsuche durchgehen. Alles andere \u2014 number, date, // range, color \u2026 \u2014 waere als Substring-Vergleich schlicht falsch: die // Eingabe "5" liesse ein Item mit "1500" stehen. const SEARCH_TYPES = new Set(['', 'text', 'search', 'email', 'tel', 'url', 'password']); const DAY_MS = 86400000; let GLOBAL = {}; // wird in initAll() gelesen — Config darf nach dem Script stehen const instances = []; // ── Helfer ────────────────────────────────────────────────────────────────── // Vergleichsbasis: NFD-zerlegt, diakritikfrei, lowercase, Bindestriche als // Leerzeichen, Whitespace kollabiert. Der Bindestrich faellt weg, weil // Filter-Beschriftung und CMS-Feld sich regelmaessig genau darin // unterscheiden ("Baden Württemberg" im Menue, "Baden-Württemberg" im // Datensatz) — zwei Werte, die sich NUR im Bindestrich unterscheiden und // wirklich verschiedenes meinen, gibt es praktisch nicht. const normalize = (value) => String(value == null ? '' : value) .normalize('NFD').replace(DIACRITICS_RE, '').toLowerCase() .replace(/[-‐‑‒–—]/g, ' ') .replace(/\s+/g, ' ').trim(); const paramName = (field) => (field === '*' ? 'search' : field); const safeDecode = (part) => { try { return decodeURIComponent(part.replace(/\+/g, ' ')); } catch { return part; } }; // location.search roh parsen: Werte bleiben kodiert, damit Kommas IN Werten // (%2C) von den Komma-Trennern zwischen Werten unterscheidbar bleiben. function readRawParams() { const map = new Map(); const qs = location.search.slice(1); if (!qs) return map; for (const pair of qs.split('&')) { if (!pair) continue; const eq = pair.indexOf('='); const key = safeDecode(eq === -1 ? pair : pair.slice(0, eq)); if (!map.has(key)) map.set(key, eq === -1 ? '' : pair.slice(eq + 1)); } return map; } // Der Wert einer // wuerde nach dem Wort "alle" filtern statt neutral zu sein. const optValue = (opt) => (opt.hasAttribute('value') ? opt.value : opt.textContent).trim(); // Deutsch-sichere Zahlen: "1.234,56 €" → 1234.56, "19,99" → 19.99, // "1.234.567" → 1234567, "150.000 €" → 150000. // // Der einzelne Punkt ist der heikle Fall, weil "150.000" im Deutschen // einhundertfuenfzigtausend meint und im Englischen einhundertfuenfzig. // Entschieden wird an der Stellenzahl: genau drei Ziffern dahinter → immer // Tausendertrenner, sonst Dezimaltrenner ("12.5" bleibt 12,5). Das ist die // uebliche Konvention und passt zum uebrigen Modul, das durchweg deutsch // rechnet. Ein englisch geschriebenes "1.500" fuer eineinhalb wird damit // bewusst als 1500 gelesen — wer das braucht, schreibt "1,5". function parseNumberLoose(raw) { if (raw == null || raw === '') return NaN; let s = String(raw).replace(/[^0-9.,+\-]/g, ''); if (!s) return NaN; const lastComma = s.lastIndexOf(','); const lastDot = s.lastIndexOf('.'); if (lastComma > -1 && lastDot > -1) { // Beide vorhanden: das hintere Zeichen ist das Dezimaltrennzeichen if (lastComma > lastDot) s = s.replace(/\./g, '').replace(',', '.'); else s = s.replace(/,/g, ''); } else if (lastComma > -1) { const parts = s.split(','); s = parts.length === 2 ? parts.join('.') : parts.join(''); } else if (lastDot > -1) { const parts = s.split('.'); if (parts.length > 2) s = parts.join(''); // "1.234.567" // "150.000" → Tausender. Der Teil davor muss dafuer eine echte // Tausendergruppe sein: ein bis drei Ziffern ohne fuehrende Null — // "0.750" bleibt so eine Dezimalzahl. else if (/^[-+]?[1-9]\d{0,2}$/.test(parts[0]) && /^\d{3}$/.test(parts[1])) s = parts.join(''); } const n = parseFloat(s); return Number.isFinite(n) ? n : NaN; } // Deutsche Daten ("24.12.2025", "1.3.25 18:30"), ISO und alles, was // Date.parse kennt. ISO-Datum bewusst als LOKALE Mitternacht — Date.parse // liest "2025-12-24" als UTC, und ein input[type=date] liefert genau dieses // Format. Ohne den Sonderfall vergleicht man Ortszeit gegen UTC und die // Grenze verschiebt sich je nach Zeitzone um einen Tag. function parseDateLoose(raw) { if (!raw) return NaN; const s = String(raw).trim(); const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); if (iso) return new Date(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])).getTime(); const de = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})(?:\D+(\d{1,2}):(\d{2}))?$/); if (de) { let year = Number(de[3]); if (year < 100) year += year < 70 ? 2000 : 1900; const month = Number(de[2]) - 1; const day = Number(de[1]); if (month < 0 || month > 11 || day < 1 || day > 31) return NaN; return new Date(year, month, day, Number(de[4] || 0), Number(de[5] || 0)).getTime(); } return Date.parse(s); } // Enthaelt der Rohwert eine Uhrzeit? Entscheidet, ob eine Obergrenze den // ganzen Tag einschliesst ("bis 31.12." meint den 31. mit). const hasTimePart = (raw) => /\d{1,2}:\d{2}/.test(String(raw)); function isNumericLike(raw) { const s = String(raw).replace(/[€$£%\s']/g, ''); // Deutsche Daten ("24.12.2025") sehen aus wie gepunktete Zahlen — was als // Datum lesbar ist, nie dem Zahlentyp zuschlagen. if (/^\d{1,2}\.\d{1,2}\.\d{2,4}$/.test(s) && !Number.isNaN(parseDateLoose(raw))) return false; return /^[-+]?[0-9.,]+$/.test(s) && Number.isFinite(parseNumberLoose(raw)); } const isDateLike = (raw) => /\d/.test(String(raw)) && !Number.isNaN(parseDateLoose(raw)); // Ein input[type=date] fuehrt intern ISO ("2025-12-31"), zeigt dem Nutzer // aber sein Landesformat. Im Chip solll dasselbe stehen wie im Feld, nicht // die Maschinenschreibweise. function displayValue(raw) { const iso = String(raw).match(/^(\d{4})-(\d{2})-(\d{2})$/); return iso ? `${iso[3]}.${iso[2]}.${iso[1]}` : raw; } // Chip-Beschriftung eines Bereichs — die Eingabe des Nutzers, nicht der // geparste Wert: wer "1.500" tippt, will "ab 1.500" lesen. function rangeLabel(r) { const min = displayValue(r.minRaw); const max = displayValue(r.maxRaw); if (r.minRaw && r.maxRaw) return `${min} – ${max}`; return r.minRaw ? `ab ${min}` : `bis ${max}`; } // Automatik: Zahl vor Datum — "2023" ist eine Jahreszahl, kein Datum. function detectFieldType(rawValues) { const nonEmpty = rawValues.filter((v) => v !== '' && v != null); if (!nonEmpty.length) return null; if (nonEmpty.every(isNumericLike)) return 'number'; if (nonEmpty.every(isDateLike)) return 'date'; return null; } function injectStyles() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = `.${HIDDEN_CLASS} { display: none !important; }`; document.head.appendChild(style); } // Control-Typ bestimmen — Wrapper (z. B. Webflow-Checkbox-Label) werden auf // den inneren Input aufgelöst, alles Nicht-Formulare wird zum Toggle-Button. function classify(el) { if (el.matches('select')) return { kind: 'select', el }; if (el.matches('input[type="checkbox"], input[type="radio"]')) return { kind: 'check', el, input: el }; if (el.matches('input, textarea')) { const bound = el.getAttribute(`${ATTR}range`); if (bound !== null) { const side = normalize(bound) === 'max' ? 'max' : 'min'; return { kind: 'range', el, input: el, side }; } const type = el.matches('textarea') ? 'text' : normalize(el.getAttribute('type') || ''); if (SEARCH_TYPES.has(type)) return { kind: 'search', el, input: el }; // number/date/range ohne fpu-filter-range: als Textsuche waere es still // falsch, deshalb lieber gar nicht filtern und einmal melden. return { kind: 'unsupported', el, input: el, type }; } const inner = el.querySelector('input[type="checkbox"], input[type="radio"]'); if (inner) return { kind: 'check', el, input: inner }; return { kind: 'button', el }; } function labelTextFor(input) { const label = input.closest('label'); if (label) { const span = label.querySelector('.w-form-label'); return (span ? span.textContent : label.textContent).trim(); } if (input.id) { const ext = document.querySelector(`label[for="${CSS.escape(input.id)}"]`); if (ext) return ext.textContent.trim(); } return ''; } function resolveCheckValue(control) { const explicit = control.el.getAttribute(`${ATTR}value`) ?? (control.input !== control.el ? control.input.getAttribute(`${ATTR}value`) : null); if (explicit !== null) return explicit.trim(); // auch "" → bewusst leer ("Alle") if (control.input.getAttribute('value') === '') return ''; // Radio mit leerem value = "Alle" const v = (control.input.value || '').trim(); if (v && v !== 'on') return v; // "on" = Browser-Default ohne value-Attribut return labelTextFor(control.input); } function resolveButtonValue(el) { const explicit = el.getAttribute(`${ATTR}value`); if (explicit !== null) return explicit.trim(); const attr = el.getAttribute('value'); if (attr) return attr.trim(); return (el.textContent || '').trim(); } // Normalisierte Sicht auf einen Text + Index-Map zurück ins Original — // damit trotz Diakritik/Case exakt die Originalzeichen umschließt. function buildNormMap(text) { let norm = ''; const map = []; for (let i = 0; i < text.length; i++) { let nc = text[i].normalize('NFD').replace(DIACRITICS_RE, '').toLowerCase(); if (/\s/.test(text[i])) nc = (!norm || norm.endsWith(' ')) ? '' : ' '; for (const ch of nc) { norm += ch; map.push(i); } } return { norm, map }; } // ── Instanz ───────────────────────────────────────────────────────────────── class FpuListFilter { constructor(parts, id) { this.id = id; this.listEls = parts.lists; this.filterRoots = parts.filters; this.resetEls = parts.resets; this.tagsEl = parts.tags[0] || null; if (parts.tags.length > 1) { console.warn(`${PREFIX} Instanz #${id}: mehrere tags-Container — nur der erste wird genutzt.`); } const optionHosts = [this.listEls[0], this.filterRoots[0]]; this.sync = this.readBool(optionHosts, `${ATTR}sync`, GLOBAL.sync, true); this.logging = this.readBool(optionHosts, `${ATTR}logging`, GLOBAL.logging, false); this.debounceMs = Number.isFinite(GLOBAL.debounce) && GLOBAL.debounce >= 0 ? GLOBAL.debounce : DEBOUNCE_MS; this.rafId = 0; this.debounceTimer = 0; this.tagClones = []; this.ariaLiveAdded = []; this.preppedButtons = []; this.typeCache = new Map(); // Feld → 'number' | 'date' (aus den Items erschlossen) this.warnedFields = new Set(); // je Control nur einmal meckern // Aktiv-Klasse der Toggle-Filter. Webflow-Projekte bringen dafuer meist // ihre eigene Combo-Klasse mit (Client-First: "is-active") — die soll // im Designer gestaltbar bleiben, statt dass hier eine zweite Klasse // danebensteht, die niemand sieht. const cls = (this.listEls[0]?.getAttribute(`${ATTR}activeclass`) ?? this.filterRoots[0]?.getAttribute(`${ATTR}activeclass`) ?? GLOBAL.activeClass ?? '').trim(); this.activeClass = cls || ACTIVE_CLASS; // count: Template mit "{n}" aus dem Originaltext übernehmen this.counts = parts.counts.map((el) => ({ el, original: el.textContent, template: el.textContent.includes('{n}') ? el.textContent : null, })); for (const { el } of this.counts) { if (!el.hasAttribute('aria-live')) { el.setAttribute('aria-live', 'polite'); this.ariaLiveAdded.push(el); } } // empty: startet versteckt, wird nur bei 0 Treffern gezeigt this.empties = parts.empties; for (const el of this.empties) el.classList.add(HIDDEN_CLASS); this.tagTemplate = this.tagsEl ? this.tagsEl.querySelector(`[${ATTR}element="tag-template"]`) : null; if (this.tagsEl && !this.tagTemplate) { console.warn(`${PREFIX} Instanz #${id}: tags ohne tag-template — Chips deaktiviert.`); } if (this.tagTemplate) this.tagTemplate.classList.add(HIDDEN_CLASS); this.controller = new AbortController(); this.prepButtons(); this.bindEvents(); if (this.sync) this.readURL(); // Deep-Link: ?kategorie=a,b in die Controls spiegeln this.items = this.collectItems(); this.scheduleApply(); if (this.logging) { console.info(`${PREFIX} Instanz #${id}: ${this.items.length} Items, ${this.controls().length} Controls, sync=${this.sync}`); } } readBool(hosts, name, globalValue, fallback) { for (const el of hosts) { if (!el) continue; const v = el.getAttribute(name); if (v !== null) return v === 'true'; } if (typeof globalValue === 'boolean') return globalValue; return fallback; } // ── Items einsammeln ────────────────────────────────────────────────────── collectItems() { const items = []; for (const list of this.listEls) { const dynItems = list.querySelectorAll('.w-dyn-item'); const els = dynItems.length ? Array.from(dynItems) : Array.from((list.querySelector('.w-dyn-items') || list).children) // Rollen-Elemente (z. B. innenliegendes element="empty") sind keine Items .filter((el) => !el.matches(`.w-dyn-empty, .w-pagination-wrapper, [${ATTR}element]`)); for (const el of els) items.push(this.buildItem(el)); } return items; } buildItem(el) { const fields = new Map(); const entry = (field) => { let e = fields.get(field); // raws behaelt die ungetrimmte Schreibweise — "1.234,56 €" laesst sich // nur aus dem Original als Zahl lesen, nicht aus der Normalform. if (!e) { e = { norms: new Set(), raws: [] }; fields.set(field, e); } return e; }; const addValue = (e, raw) => { const n = normalize(raw); if (!n || e.norms.has(n)) return; e.norms.add(n); e.raws.push(String(raw).trim()); }; // Quelle 1: fpu-filter-{feld}="a, b" direkt am Item for (const attr of el.attributes) { if (!attr.name.startsWith(ATTR)) continue; const suffix = attr.name.slice(ATTR.length); if (!suffix || RESERVED.has(suffix)) continue; const e = entry(normalize(suffix)); for (const part of attr.value.split(',')) addValue(e, part); } // Quelle 2: Kinder mit fpu-filter-field — Textinhalt zählt for (const child of el.querySelectorAll(`[${ATTR}field]`)) { const field = normalize(child.getAttribute(`${ATTR}field`)); if (!field || field === '*') continue; addValue(entry(field), child.textContent); } // Zeilenumbruch als Trenner verhindert falsche Substring-Treffer über Wertgrenzen for (const e of fields.values()) e.joined = Array.from(e.norms).join('\n'); return { el, fields, searchAll: normalize(el.textContent) }; } // Zahlen bzw. Zeitstempel eines Feldes, einmal geparst und am Feldeintrag // gecacht. buildItem legt die Eintraege neu an, refreshItems wirft sie also // mit den Items zusammen weg. static numericValues(data, type) { const key = type === 'date' ? 'dates' : 'numbers'; if (!data[key]) { const parse = type === 'date' ? parseDateLoose : parseNumberLoose; data[key] = data.raws.map(parse).filter(Number.isFinite); } return data[key]; } // Typ eines Bereichsfeldes: explizit per fpu-filter-fieldtype, sonst aus // den Item-Werten erschlossen. Pro Instanz gecacht. fieldTypeFor(field, explicit) { if (explicit === 'number' || explicit === 'date') return explicit; if (this.typeCache.has(field)) return this.typeCache.get(field); const raws = []; for (const item of this.items || []) { const data = item.fields.get(field); if (data) raws.push(...data.raws); } const type = detectFieldType(raws) || 'number'; this.typeCache.set(field, type); return type; } // ── Filter-State aus der UI lesen ───────────────────────────────────────── controls() { const result = []; for (const root of this.filterRoots) { for (const el of root.querySelectorAll(`[${ATTR}field]`)) { // Item-Daten-Marker ausschließen, falls filters die Liste umschließt if (this.listEls.some((list) => list.contains(el))) continue; result.push(el); } } return result; } readState() { const discrete = new Map(); const searches = new Map(); const ranges = new Map(); for (const el of this.controls()) { const field = normalize(el.getAttribute(`${ATTR}field`)); if (!field) continue; const c = classify(el); if (c.kind === 'unsupported') { if (!this.warnedFields.has(el)) { this.warnedFields.add(el); console.warn( `${PREFIX} Feld "${field}": input[type="${c.type}"] wird nicht als Textsuche gefiltert. ` + `Fuer einen Von-Bis-Bereich fpu-filter-range="min" bzw. "max" setzen.`, ); } continue; } if (c.kind === 'range') { if (c.input.disabled) continue; let r = ranges.get(field); if (!r) { const explicit = normalize(el.getAttribute(`${ATTR}fieldtype`) || ''); r = { type: this.fieldTypeFor(field, explicit), min: null, max: null, minRaw: '', maxRaw: '' }; ranges.set(field, r); } const raw = c.input.value.trim(); if (!raw) continue; const value = r.type === 'date' ? parseDateLoose(raw) : parseNumberLoose(raw); if (!Number.isFinite(value)) continue; // Datum ohne Uhrzeit: die Obergrenze meint den ganzen Tag, sonst // faellt der 31.12. aus "bis 31.12." heraus. const bound = (r.type === 'date' && c.side === 'max' && !hasTimePart(raw)) ? value + DAY_MS - 1 : value; r[c.side] = bound; r[`${c.side}Raw`] = raw; continue; } if (c.kind === 'search') { if (c.input.disabled) continue; const raw = c.input.value.trim(); const highlight = this.readBool([el], `${ATTR}highlight`, GLOBAL.highlight, false); searches.set(field, { raw, norm: normalize(raw), highlight }); continue; } let sel = discrete.get(field); if (!sel) { sel = { norms: new Set(), raws: [], mode: 'any' }; discrete.set(field, sel); } const matchHost = el.closest(`[${ATTR}match]`); if (matchHost && matchHost.getAttribute(`${ATTR}match`) === 'all') sel.mode = 'all'; const add = (raw) => { const n = normalize(raw); if (!n || sel.norms.has(n)) return; sel.norms.add(n); sel.raws.push(raw.trim()); }; if (c.kind === 'select') { if (c.el.disabled) continue; for (const opt of c.el.selectedOptions) add(optValue(opt)); } else if (c.kind === 'check') { if (c.input.checked && !c.input.disabled) add(resolveCheckValue(c)); } else if (el.classList.contains(this.activeClass)) { add(resolveButtonValue(el)); } } for (const [field, sel] of discrete) { if (!sel.norms.size) discrete.delete(field); } // Bereiche ohne gesetzte Grenze schraenken nichts ein for (const [field, r] of ranges) { if (r.min === null && r.max === null) ranges.delete(field); } return { discrete, searches, ranges }; } matches(item, state) { // Felder untereinander UND for (const [field, sel] of state.discrete) { const data = item.fields.get(field); if (!data) return false; if (sel.mode === 'all') { for (const v of sel.norms) if (!data.norms.has(v)) return false; } else { let hit = false; for (const v of sel.norms) if (data.norms.has(v)) { hit = true; break; } if (!hit) return false; } } // Von-Bis: ein Item passt, sobald EINER seiner Werte hineinfaellt for (const [field, r] of state.ranges) { const data = item.fields.get(field); if (!data) return false; const values = FpuListFilter.numericValues(data, r.type); if (!values.length) return false; // unlesbarer Wert faellt aus dem Bereich let hit = false; for (const v of values) { if (r.min !== null && v < r.min) continue; if (r.max !== null && v > r.max) continue; hit = true; break; } if (!hit) return false; } // Textsuche: Substring auf normalisiertem Inhalt for (const [field, search] of state.searches) { if (!search.norm) continue; if (field === '*') { if (!item.searchAll.includes(search.norm)) return false; } else { const data = item.fields.get(field); if (!data || !data.joined.includes(search.norm)) return false; } } return true; } // ── Anwenden (visuelle Updates nur im rAF) ──────────────────────────────── scheduleApply() { if (this.rafId) return; this.rafId = requestAnimationFrame(() => { this.rafId = 0; try { this.applyNow(); } catch (err) { console.error(`${PREFIX} Anwenden fehlgeschlagen:`, err); } }); } applyNow() { const state = this.readState(); let visible = 0; for (const item of this.items) { const show = this.matches(item, state); item.el.classList.toggle(HIDDEN_CLASS, !show); item.visible = show; if (show) visible++; } this.updateCount(visible); for (const el of this.empties) el.classList.toggle(HIDDEN_CLASS, visible > 0); this.updateTags(state); this.updateHighlights(state); this.writeURL(state); } updateCount(visible) { for (const { el, template } of this.counts) { el.textContent = template ? template.replace(/\{n\}/g, String(visible)) : String(visible); } } // ── Aktive Filter-Chips ─────────────────────────────────────────────────── updateTags(state) { if (!this.tagsEl || !this.tagTemplate) return; for (const clone of this.tagClones) clone.remove(); this.tagClones = []; const add = (field, raw, kind) => { const clone = this.tagTemplate.cloneNode(true); clone.classList.remove(HIDDEN_CLASS); clone.setAttribute(`${ATTR}element`, 'tag'); clone.setAttribute(`${ATTR}tag-field`, field); clone.setAttribute(`${ATTR}tag-value`, raw); clone.setAttribute(`${ATTR}tag-kind`, kind); const textEl = clone.querySelector(`[${ATTR}element="tag-text"]`); const innerRemove = clone.querySelector(`[${ATTR}element="tag-remove"]`); if (textEl) { textEl.textContent = raw; } else if (innerRemove) { // tag-remove erhalten: nur die übrigen Knoten durch den Wert-Text ersetzen for (const node of Array.from(clone.childNodes)) { if (node !== innerRemove && !(node.nodeType === 1 && node.contains(innerRemove))) node.remove(); } clone.insertBefore(document.createTextNode(raw), clone.firstChild); } else { clone.textContent = raw; // ohne tag-text: Klon selbst trägt den Text } const removeEl = innerRemove || clone; if (!removeEl.matches('button, a[href]')) { removeEl.setAttribute('role', 'button'); removeEl.tabIndex = 0; } removeEl.setAttribute('aria-label', `Filter "${raw}" entfernen`); this.tagsEl.appendChild(clone); this.tagClones.push(clone); }; for (const [field, sel] of state.discrete) { for (const raw of sel.raws) add(field, raw, 'value'); } for (const [field, r] of state.ranges) { add(field, rangeLabel(r), 'range'); } for (const [field, search] of state.searches) { if (search.raw) add(field, search.raw, 'search'); } } handleTagActivate(e) { const chip = e.target.closest(`[${ATTR}tag-field]`); if (!chip || !this.tagsEl.contains(chip)) return; const removeEl = chip.querySelector(`[${ATTR}element="tag-remove"]`); if (removeEl && removeEl !== e.target && !removeEl.contains(e.target)) return; e.preventDefault(); this.removeTagFilter( chip.getAttribute(`${ATTR}tag-field`), chip.getAttribute(`${ATTR}tag-value`), chip.getAttribute(`${ATTR}tag-kind`), ); } removeTagFilter(field, raw, kind) { const norm = normalize(raw); for (const el of this.controls()) { if (normalize(el.getAttribute(`${ATTR}field`)) !== field) continue; const c = classify(el); if (kind === 'range') { // Ein Chip steht fuer beide Grenzen zusammen — also beide leeren if (c.kind === 'range') c.input.value = ''; continue; } if (kind === 'search') { if (c.kind === 'search' && normalize(c.input.value) === norm) c.input.value = ''; continue; } if (c.kind === 'check') { if (normalize(resolveCheckValue(c)) === norm) this.setChecked(c.input, false); } else if (c.kind === 'button') { if (normalize(resolveButtonValue(el)) === norm) this.setActive(el, false); } else if (c.kind === 'select') { this.deselectOption(c.el, norm); } } this.scheduleApply(); } deselectOption(select, norm) { let changed = false; for (const opt of select.options) { if (!opt.selected || normalize(optValue(opt)) !== norm) continue; if (select.multiple) { opt.selected = false; } else { const emptyOpt = Array.from(select.options).find((o) => !normalize(optValue(o))); if (emptyOpt) emptyOpt.selected = true; else select.selectedIndex = -1; } changed = true; } if (changed) select.dispatchEvent(new Event('change', { bubbles: true })); } // ── Suchtreffer-Highlighting ────────────────────────────────────────────── updateHighlights(state) { for (const item of this.items) this.unhighlight(item.el); const active = Array.from(state.searches.entries()) .filter(([, s]) => s.highlight && s.norm); if (!active.length) return; for (const item of this.items) { if (!item.visible) continue; for (const [field, search] of active) { if (field === '*') { this.highlightElement(item.el, search.norm); } else { for (const child of item.el.querySelectorAll(`[${ATTR}field]`)) { if (normalize(child.getAttribute(`${ATTR}field`)) === field) { this.highlightElement(child, search.norm); } } } } } } highlightElement(root, normTerm) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; let node; while ((node = walker.nextNode())) { const parent = node.parentElement; if (!parent || parent.closest('script, style, textarea, select')) continue; nodes.push(node); } for (const textNode of nodes) { const text = textNode.data; const { norm, map } = buildNormMap(text); const ranges = []; let from = 0; let pos; while ((pos = norm.indexOf(normTerm, from)) !== -1) { ranges.push([map[pos], map[pos + normTerm.length - 1] + 1]); from = pos + normTerm.length; } if (!ranges.length) continue; const frag = document.createDocumentFragment(); let last = 0; for (const [start, end] of ranges) { if (start > last) frag.appendChild(document.createTextNode(text.slice(last, start))); const mark = document.createElement('mark'); mark.className = MARK_CLASS; mark.textContent = text.slice(start, end); frag.appendChild(mark); last = end; } if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last))); textNode.replaceWith(frag); } } unhighlight(root) { const marks = root.querySelectorAll(`mark.${MARK_CLASS}`); if (!marks.length) return; const parents = new Set(); for (const mark of marks) { parents.add(mark.parentNode); mark.replaceWith(document.createTextNode(mark.textContent)); } for (const parent of parents) if (parent) parent.normalize(); } // ── URL-Query-Sync ──────────────────────────────────────────────────────── managedParamNames() { const names = new Set(); for (const el of this.controls()) { const field = normalize(el.getAttribute(`${ATTR}field`)); if (!field) continue; const c = classify(el); if (c.kind === 'range') names.add(`${paramName(field)}_${c.side}`); else if (c.kind !== 'unsupported') names.add(paramName(field)); } return names; } // clearMissing: Controls ohne Parameter zuruecksetzen. Beim Deep-Link beim // Init NICHT gewuenscht — dort wuerde es im Designer gesetzte Startwerte // loeschen. Beim Zurueck-Button dagegen zwingend, sonst bleibt ein // entfernter Filter stehen. readURL(clearMissing = false) { const params = readRawParams(); for (const el of this.controls()) { const field = normalize(el.getAttribute(`${ATTR}field`)); if (!field) continue; const c = classify(el); if (c.kind === 'unsupported') continue; if (c.kind === 'range') { const encoded = params.get(`${paramName(field)}_${c.side}`); if (encoded === undefined) { if (clearMissing) c.input.value = ''; continue; } c.input.value = safeDecode(encoded); continue; } const encoded = params.get(paramName(field)); if (encoded === undefined && !clearMissing) continue; if (c.kind === 'search') { c.input.value = encoded === undefined ? '' : safeDecode(encoded); continue; } // Erst am unkodierten Komma splitten, dann pro Wert dekodieren — // so überleben Kommas in Werten (%2C) den Roundtrip. const wanted = new Set( encoded === undefined ? [] : encoded.split(',').map((p) => normalize(safeDecode(p))).filter(Boolean), ); if (c.kind === 'check') { this.setChecked(c.input, wanted.has(normalize(resolveCheckValue(c)))); } else if (c.kind === 'button') { this.setActive(el, wanted.has(normalize(resolveButtonValue(el)))); } else { let changed = false; let single = null; for (const opt of c.el.options) { const on = wanted.has(normalize(optValue(opt))); if (c.el.multiple) { if (opt.selected !== on) { opt.selected = on; changed = true; } } else if (on && single === null) { single = opt; } } if (!c.el.multiple) { // Kein Treffer und wir raeumen auf → zurueck auf die leere Option const target = single ?? (wanted.size === 0 && clearMissing ? Array.from(c.el.options).find((o) => !normalize(optValue(o))) : null); if (target && !target.selected) { target.selected = true; changed = true; } } if (changed) c.el.dispatchEvent(new Event('change', { bubbles: true })); } } } writeURL(state) { if (!this.sync) return; const url = new URL(location.href); for (const name of this.managedParamNames()) url.searchParams.delete(name); // Eigene Parameter manuell serialisieren: Werte einzeln kodiert, Trenner // bleibt ein echtes Komma — lesbar/teilbar (?kategorie=a,b) und Kommas // IN Werten bleiben als %2C eindeutig. const own = []; for (const [field, sel] of state.discrete) { if (sel.raws.length) { own.push(`${encodeURIComponent(paramName(field))}=${sel.raws.map(encodeURIComponent).join(',')}`); } } for (const [field, r] of state.ranges) { const name = encodeURIComponent(paramName(field)); if (r.minRaw) own.push(`${name}_min=${encodeURIComponent(r.minRaw)}`); if (r.maxRaw) own.push(`${name}_max=${encodeURIComponent(r.maxRaw)}`); } for (const [field, search] of state.searches) { if (search.raw) own.push(`${encodeURIComponent(paramName(field))}=${encodeURIComponent(search.raw)}`); } const rest = url.searchParams.toString(); url.search = rest && own.length ? `${rest}&${own.join('&')}` : rest || own.join('&'); if (url.href !== location.href) history.replaceState(history.state, '', url); } // ── Events ──────────────────────────────────────────────────────────────── prepButtons() { for (const el of this.controls()) { if (classify(el).kind !== 'button') continue; const prep = { el, addedRole: false, addedTabindex: false }; if (el.matches('button') && !el.hasAttribute('type')) { el.setAttribute('type', 'button'); // verhindert implizites Form-Submit } if (!el.matches('button, a[href], input')) { if (!el.hasAttribute('role')) { el.setAttribute('role', 'button'); prep.addedRole = true; } if (!el.hasAttribute('tabindex')) { el.tabIndex = 0; prep.addedTabindex = true; } } el.setAttribute('aria-pressed', String(el.classList.contains(this.activeClass))); this.preppedButtons.push(prep); } } bindEvents() { const { signal } = this.controller; for (const root of this.filterRoots) { // Webflow-Form: Submit komplett unterdrücken (auch Enter im Suchfeld) root.addEventListener('submit', (e) => { e.preventDefault(); e.stopImmediatePropagation(); }, { capture: true, signal }); root.addEventListener('change', (e) => { if (e.target.closest(`[${ATTR}field]`)) this.scheduleApply(); }, { signal }); root.addEventListener('input', (e) => { const host = e.target.closest(`[${ATTR}field]`); if (!host) return; // Bereiche wie die Suche entprellen: beim Tippen von "1500" sonst // vier Durchlaeufe, von denen drei am falschen Wert filtern. const kind = classify(host).kind; if (kind === 'search' || kind === 'range') this.debouncedApply(); }, { passive: true, signal }); root.addEventListener('click', (e) => this.handleFilterClick(e), { signal }); root.addEventListener('keydown', (e) => this.handleFilterKeydown(e), { signal }); } for (const el of this.resetEls) { el.addEventListener('click', (e) => { e.preventDefault(); this.clearControls(null); }, { signal }); } if (this.tagsEl) { this.tagsEl.addEventListener('click', (e) => this.handleTagActivate(e), { signal }); this.tagsEl.addEventListener('keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; this.handleTagActivate(e); }, { signal }); } // Zurueck-/Vorwaerts-Button: die Query ist die Wahrheit, die Controls // ziehen nach. Ohne das aendert sich die Adresse, die Liste aber nicht. if (this.sync) { window.addEventListener('popstate', () => { this.readURL(true); this.scheduleApply(); }, { signal }); } // Andere FPU-Lösungen (z. B. Load-More) haben die Liste verändert → // Items neu einsammeln und Filter reapplizieren. document.addEventListener('fpu:items-changed', (e) => { const list = e.detail && e.detail.list; if (list instanceof Node) { const concernsUs = this.listEls.some((l) => l === list || l.contains(list) || list.contains(l)); if (!concernsUs) return; } this.refreshItems(); }, { signal }); } handleFilterClick(e) { const host = e.target.closest(`[${ATTR}field]`); if (!host) return; const c = classify(host); if (c.kind !== 'button') return; e.preventDefault(); const raw = resolveButtonValue(host); if (!normalize(raw)) { // "Alle"-Button: leert das eigene Feld this.clearControls(normalize(host.getAttribute(`${ATTR}field`))); return; } this.setActive(host, !host.classList.contains(this.activeClass)); this.scheduleApply(); } handleFilterKeydown(e) { if (e.key !== 'Enter' && e.key !== ' ') return; const host = e.target.closest(`[${ATTR}field]`); if (!host || classify(host).kind !== 'button') return; if (host.matches('button, input, select, textarea')) return; // native Aktivierung if (host.matches('a[href]') && e.key === 'Enter') return; e.preventDefault(); host.click(); } // field === null → alles zurücksetzen, sonst nur die Controls dieses Feldes clearControls(field) { for (const el of this.controls()) { if (field !== null && normalize(el.getAttribute(`${ATTR}field`)) !== field) continue; const c = classify(el); if (c.kind === 'check') { this.setChecked(c.input, false); } else if (c.kind === 'button') { this.setActive(el, false); } else if (c.kind === 'search' || c.kind === 'range') { c.input.value = ''; } else if (c.kind === 'unsupported') { continue; } else { let changed = false; for (const opt of c.el.options) { if (opt.selected && normalize(optValue(opt))) changed = true; } if (changed) { if (c.el.multiple) { for (const opt of c.el.options) opt.selected = false; } else { const emptyOpt = Array.from(c.el.options).find((o) => !normalize(optValue(o))); if (emptyOpt) emptyOpt.selected = true; else c.el.selectedIndex = -1; } c.el.dispatchEvent(new Event('change', { bubbles: true })); } } } this.scheduleApply(); } // change-Event mitschicken, damit Webflows Custom-Checkbox-Styling // (w--redirected-checked) bei programmatischen Änderungen nachzieht setChecked(input, checked) { if (input.checked === checked) return; input.checked = checked; input.dispatchEvent(new Event('change', { bubbles: true })); } setActive(el, active) { el.classList.toggle(this.activeClass, active); el.setAttribute('aria-pressed', String(active)); } debouncedApply() { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => this.scheduleApply(), this.debounceMs); } refreshItems() { for (const item of this.items) this.unhighlight(item.el); this.items = this.collectItems(); // Nachgeladene Items koennen den Feldtyp kippen — eine Spalte, die auf // Seite 1 durchweg Zahlen enthielt, bekommt auf Seite 2 ein "auf Anfrage". this.typeCache.clear(); this.scheduleApply(); } destroy() { this.controller.abort(); if (this.rafId) cancelAnimationFrame(this.rafId); clearTimeout(this.debounceTimer); for (const item of this.items) { item.el.classList.remove(HIDDEN_CLASS); this.unhighlight(item.el); } for (const { el, addedRole, addedTabindex } of this.preppedButtons) { el.classList.remove(this.activeClass); el.removeAttribute('aria-pressed'); if (addedRole) el.removeAttribute('role'); if (addedTabindex) el.removeAttribute('tabindex'); } for (const { el, original } of this.counts) el.textContent = original; for (const el of this.ariaLiveAdded) el.removeAttribute('aria-live'); for (const el of this.empties) el.classList.remove(HIDDEN_CLASS); for (const clone of this.tagClones) clone.remove(); this.tagClones = []; if (this.tagTemplate) this.tagTemplate.classList.remove(HIDDEN_CLASS); } } // ── Bootstrap ────────────────────────────────────────────────────────────── let initialized = false; function initAll() { if (initialized) return; initialized = true; GLOBAL = window.fpuListFilterConfig || {}; injectStyles(); // Rollen-Elemente per fpu-filter-instance gruppieren ('' = Default-Gruppe) const groups = new Map(); const groupFor = (key) => { let group = groups.get(key); if (!group) { group = { lists: [], filters: [], counts: [], empties: [], resets: [], tags: [] }; groups.set(key, group); } return group; }; for (const el of document.querySelectorAll(`[${ATTR}element]`)) { const role = el.getAttribute(`${ATTR}element`); if (!ROLES.includes(role)) continue; // tag-template/-text/-remove werden relativ aufgelöst const group = groupFor(el.getAttribute(`${ATTR}instance`) || ''); if (role === 'list') group.lists.push(el); else if (role === 'filters') group.filters.push(el); else if (role === 'count') group.counts.push(el); else if (role === 'empty') group.empties.push(el); else if (role === 'reset') group.resets.push(el); else group.tags.push(el); } let id = 0; for (const [key, group] of groups) { const label = key || 'default'; if (!group.lists.length) { console.warn(`${PREFIX} Instanz "${label}": element="list" fehlt — übersprungen.`); continue; } if (!group.filters.length) { console.warn(`${PREFIX} Instanz "${label}": element="filters" fehlt — übersprungen.`); continue; } try { instances.push(new FpuListFilter(group, id++)); } catch (err) { console.error(`${PREFIX} Init fehlgeschlagen:`, err); } } // Beim gemeinsamen Dach melden — daraufhin laufen die Rueckrufe, die // jemand ueber window.FlowPowerUser.push() angemeldet hat. Defensiv // aufgerufen: ohne Kern (Selbsttest) passiert hier einfach nichts. window.__fpu?.anmelden?.('list-filter', { version: VERSION, api: window.fpuListFilter, starten: initAll, abraeumen: () => window.fpuListFilter.destroyAll(), }); } window.fpuListFilter = { version: VERSION, instances, init: initAll, destroyAll() { for (const instance of instances) instance.destroy(); instances.length = 0; initialized = false; }, // Reine Funktionen fuer den Selbsttest — ohne DOM pruefbar. __pure: { normalize, parseNumberLoose, parseDateLoose, isNumericLike, isDateLike, detectFieldType, rangeLabel, hasTimePart, }, }; // Primärpfad: Webflow-Queue. webflow.js adoptiert ein vorab existierendes // Array und führt die Queue nach dem Build seiner Module aus. (window.Webflow = window.Webflow || []).push(initAll); const initWithoutWebflow = () => { if (initialized) return; const hasWebflowScript = !!document.querySelector('script[src*="webflow"]'); if (!hasWebflowScript && Array.isArray(window.Webflow)) initAll(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initWithoutWebflow, { once: true }); } else { setTimeout(initWithoutWebflow, 0); } window.addEventListener('load', () => { if (!initialized && Array.isArray(window.Webflow)) initAll(); }, { once: true }); })();