Mindestbewertung-Filter: echte Lila-Babyblau-Verlaufsherzen statt System-Emoji

Im Filter "Mindestbewertung" wurden bisher schlichte 🩵-Emojis angezeigt. Grund:
das war ein natives <select>, und in der browsereigenen Auswahlliste lassen
sich weder Farbverlaeufe noch eigene Grafiken darstellen - der Browser rendert
dort nur das System-Emoji in seiner festen hellblauen Farbe.

Ersetzt durch dasselbe eigene Dropdown, das schon der Kategorie-Filter nutzt.
Dadurch erscheinen jetzt die echten HeartRating-Herzen mit dem Marken-Verlauf
von Lila (--c-purple-glow) nach Babyblau (--c-accent) - auch im geschlossenen
Knopf, sobald eine Bewertung gewaehlt ist. Der Verlauf bekam zusaetzlich einen
weichen Zwischenton, damit er im kleinen Herz-Format nicht hart umschlaegt,
sondern wirklich wie eine Mischung beider Farben wirkt.

Die Dropdown-Logik ist dabei fuer BEIDE Filter zu einem gemeinsamen Code
zusammengefasst worden (statt zweimal fast dasselbe): beide verhalten sich
identisch, oeffnen sich gegenseitig zu, schliessen bei Klick daneben und bei
Escape - nur noch eine Stelle zum Pflegen.

Im Browser funktional geprueft: 5 Optionen, oeffnet/schliesst korrekt, 10
Verlaufsherzen in der Liste, Auswahl setzt den Filterwert und uebernimmt die
Herzen in den Knopf, gegenseitiges Schliessen funktioniert. Gilt fuer alle 4
Sprachen.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 03:35:31 +02:00
co-authored by Claude Sonnet 5
parent 0a819b6a4d
commit d27a858d8f
6 changed files with 326 additions and 191 deletions
+5
View File
@@ -28,8 +28,13 @@ const HERZ_PFAD =
<span class="heart-rating" role="img" aria-label={`${bewertung.toFixed(1)} von 5 Herzen${anzahlLabel ? `, ${anzahlLabel}` : ""}`}> <span class="heart-rating" role="img" aria-label={`${bewertung.toFixed(1)} von 5 Herzen${anzahlLabel ? `, ${anzahlLabel}` : ""}`}>
<svg width="0" height="0" style="position:absolute;" aria-hidden="true"> <svg width="0" height="0" style="position:absolute;" aria-hidden="true">
<defs> <defs>
{/* Lila → Babyblau als echte Mischung beider Marken-Farben: oben links das Violett
(--c-purple-glow), unten rechts das Babyblau (--c-accent), dazwischen ein weicher
Zwischenton, damit der Verlauf im kleinen Herz-Format nicht hart umschlägt, sondern
wirklich wie eine Mischung wirkt. */}
<linearGradient id={gradId} x1="0" y1="0" x2="1" y2="1"> <linearGradient id={gradId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--c-purple-glow)"></stop> <stop offset="0%" stop-color="var(--c-purple-glow)"></stop>
<stop offset="55%" stop-color="#8aa8e6"></stop>
<stop offset="100%" stop-color="var(--c-accent)"></stop> <stop offset="100%" stop-color="var(--c-accent)"></stop>
</linearGradient> </linearGradient>
</defs> </defs>
+73 -42
View File
@@ -1,6 +1,7 @@
--- ---
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro"; import ProductCard from "../../../components/ProductCard.astro";
import HeartRating from "../../../components/HeartRating.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories"; import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products, durchschnittsbewertung } from "../../../data/products"; import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
@@ -26,10 +27,10 @@ const t = useTranslations(lang);
<h3>{t.shop.filters}</h3> <h3>{t.shop.filters}</h3>
<div class="filter-group"> <div class="filter-group">
<span id="f-kategorie-label">{t.shop.categoryLabel}</span> <span id="f-kategorie-label">{t.shop.categoryLabel}</span>
<div class="cat-filter" id="cat-filter"> <div class="cat-filter" id="cat-filter" data-dropdown>
<input type="hidden" id="f-kategorie" value="" /> <input type="hidden" id="f-kategorie" value="" />
<button type="button" class="cat-filter-btn" id="cat-filter-btn" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label"> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label">
<span id="cat-filter-current">{t.shop.allCategories}</span> <span data-dropdown-current>{t.shop.allCategories}</span>
<span class="chevron" aria-hidden="true">▼</span> <span class="chevron" aria-hidden="true">▼</span>
</button> </button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label"> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label">
@@ -60,14 +61,23 @@ const t = useTranslations(lang);
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label> <span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
<select id="f-bewertung"> <div class="cat-filter" id="rating-filter" data-dropdown>
<option value="0">{t.shop.minRatingAny}</option> <input type="hidden" id="f-bewertung" value="0" />
<option value="4">🩵🩵🩵🩵 +</option> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-bewertung-label">
<option value="3">🩵🩵🩵 +</option> <span data-dropdown-current>{t.shop.minRatingAny}</span>
<option value="2">🩵🩵 +</option> <span class="chevron" aria-hidden="true">▼</span>
<option value="1">🩵 +</option> </button>
</select> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-bewertung-label">
<button type="button" class="cat-filter-option" data-value="0" data-label={t.shop.minRatingAny} role="option" aria-selected="true">{t.shop.minRatingAny}</button>
{[4, 3, 2, 1].map((n) => (
<button type="button" class="cat-filter-option rating-option" data-value={String(n)} role="option" aria-selected="false">
<HeartRating bewertung={n} size={15} />
<span class="rating-plus">+</span>
</button>
))}
</div>
</div>
</div> </div>
<p class="small">{t.shop.filterNote}</p> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -160,49 +170,70 @@ const t = useTranslations(lang);
sorted.forEach((el) => grid.appendChild(el)); sorted.forEach((el) => grid.appendChild(el));
} }
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply)); [preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
const catFilter = document.getElementById("cat-filter"); // Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
const catFilterBtn = document.getElementById("cat-filter-btn"); // Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
const catFilterCurrent = document.getElementById("cat-filter-current"); // eine eigene, gestaltbare Liste. Ein gemeinsamer Code für BEIDE Dropdowns (Kategorie und
const catFilterOptions = Array.from(catFilter.querySelectorAll(".cat-filter-option")); // Mindestbewertung), damit sich beide identisch verhalten und es nur eine Stelle zum Pflegen
// gibt. Verhalten wie beim Sprach-Umschalter im Header.
const dropdowns = Array.from(document.querySelectorAll("[data-dropdown]")).map((root) => {
const btn = root.querySelector("[data-dropdown-btn]");
const current = root.querySelector("[data-dropdown-current]");
const hidden = root.querySelector("input[type=hidden]");
const options = Array.from(root.querySelectorAll(".cat-filter-option"));
function selectKategorie(value, label) { function select(value) {
kategorieSel.value = value; const opt = options.find((o) => o.dataset.value === value);
catFilterCurrent.textContent = label; if (!opt) return;
catFilterOptions.forEach((opt) => opt.setAttribute("aria-selected", String(opt.dataset.value === value))); hidden.value = value;
// Optionen ohne eigenes data-label (z.B. die Herzen-Zeilen) übernehmen ihren kompletten
// Inhalt in den Knopf — so stehen dort dieselben Verlaufs-Herzen wie in der Liste.
if (opt.dataset.label !== undefined) current.textContent = opt.dataset.label;
else current.innerHTML = opt.innerHTML;
options.forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
apply(); apply();
} }
catFilterBtn.addEventListener("click", (e) => { function close() {
root.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const open = catFilter.classList.toggle("open"); const willOpen = !root.classList.contains("open");
catFilterBtn.setAttribute("aria-expanded", open ? "true" : "false"); // Immer erst alle anderen schließen, damit nie zwei Listen gleichzeitig offen sind.
document.querySelectorAll("[data-dropdown].open").forEach((other) => {
other.classList.remove("open");
other.querySelector("[data-dropdown-btn]").setAttribute("aria-expanded", "false");
}); });
catFilterOptions.forEach((opt) => { if (willOpen) {
opt.addEventListener("click", () => { root.classList.add("open");
selectKategorie(opt.dataset.value, opt.dataset.label); btn.setAttribute("aria-expanded", "true");
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
});
document.addEventListener("click", () => {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
} }
}); });
options.forEach((opt) => {
opt.addEventListener("click", () => {
select(opt.dataset.value);
close();
});
});
return { root, close, select, options };
});
document.addEventListener("click", () => dropdowns.forEach((d) => d.close()));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") dropdowns.forEach((d) => d.close());
});
// Voreinstellung über die Adresszeile (z.B. /shop/?kategorie=diy-plushies), damit Links aus
// der Navigation weiterhin direkt mit gesetztem Filter landen.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const preset = params.get("kategorie"); const preset = params.get("kategorie");
if (preset) { if (preset) dropdowns.forEach((d) => d.select(preset));
const match = catFilterOptions.find((opt) => opt.dataset.value === preset);
if (match) selectKategorie(preset, match.dataset.label);
}
apply(); apply();
</script> </script>
+73 -42
View File
@@ -1,6 +1,7 @@
--- ---
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro"; import ProductCard from "../../../components/ProductCard.astro";
import HeartRating from "../../../components/HeartRating.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories"; import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products, durchschnittsbewertung } from "../../../data/products"; import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
@@ -26,10 +27,10 @@ const t = useTranslations(lang);
<h3>{t.shop.filters}</h3> <h3>{t.shop.filters}</h3>
<div class="filter-group"> <div class="filter-group">
<span id="f-kategorie-label">{t.shop.categoryLabel}</span> <span id="f-kategorie-label">{t.shop.categoryLabel}</span>
<div class="cat-filter" id="cat-filter"> <div class="cat-filter" id="cat-filter" data-dropdown>
<input type="hidden" id="f-kategorie" value="" /> <input type="hidden" id="f-kategorie" value="" />
<button type="button" class="cat-filter-btn" id="cat-filter-btn" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label"> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label">
<span id="cat-filter-current">{t.shop.allCategories}</span> <span data-dropdown-current>{t.shop.allCategories}</span>
<span class="chevron" aria-hidden="true">▼</span> <span class="chevron" aria-hidden="true">▼</span>
</button> </button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label"> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label">
@@ -60,14 +61,23 @@ const t = useTranslations(lang);
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label> <span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
<select id="f-bewertung"> <div class="cat-filter" id="rating-filter" data-dropdown>
<option value="0">{t.shop.minRatingAny}</option> <input type="hidden" id="f-bewertung" value="0" />
<option value="4">🩵🩵🩵🩵 +</option> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-bewertung-label">
<option value="3">🩵🩵🩵 +</option> <span data-dropdown-current>{t.shop.minRatingAny}</span>
<option value="2">🩵🩵 +</option> <span class="chevron" aria-hidden="true">▼</span>
<option value="1">🩵 +</option> </button>
</select> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-bewertung-label">
<button type="button" class="cat-filter-option" data-value="0" data-label={t.shop.minRatingAny} role="option" aria-selected="true">{t.shop.minRatingAny}</button>
{[4, 3, 2, 1].map((n) => (
<button type="button" class="cat-filter-option rating-option" data-value={String(n)} role="option" aria-selected="false">
<HeartRating bewertung={n} size={15} />
<span class="rating-plus">+</span>
</button>
))}
</div>
</div>
</div> </div>
<p class="small">{t.shop.filterNote}</p> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -160,49 +170,70 @@ const t = useTranslations(lang);
sorted.forEach((el) => grid.appendChild(el)); sorted.forEach((el) => grid.appendChild(el));
} }
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply)); [preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
const catFilter = document.getElementById("cat-filter"); // Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
const catFilterBtn = document.getElementById("cat-filter-btn"); // Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
const catFilterCurrent = document.getElementById("cat-filter-current"); // eine eigene, gestaltbare Liste. Ein gemeinsamer Code für BEIDE Dropdowns (Kategorie und
const catFilterOptions = Array.from(catFilter.querySelectorAll(".cat-filter-option")); // Mindestbewertung), damit sich beide identisch verhalten und es nur eine Stelle zum Pflegen
// gibt. Verhalten wie beim Sprach-Umschalter im Header.
const dropdowns = Array.from(document.querySelectorAll("[data-dropdown]")).map((root) => {
const btn = root.querySelector("[data-dropdown-btn]");
const current = root.querySelector("[data-dropdown-current]");
const hidden = root.querySelector("input[type=hidden]");
const options = Array.from(root.querySelectorAll(".cat-filter-option"));
function selectKategorie(value, label) { function select(value) {
kategorieSel.value = value; const opt = options.find((o) => o.dataset.value === value);
catFilterCurrent.textContent = label; if (!opt) return;
catFilterOptions.forEach((opt) => opt.setAttribute("aria-selected", String(opt.dataset.value === value))); hidden.value = value;
// Optionen ohne eigenes data-label (z.B. die Herzen-Zeilen) übernehmen ihren kompletten
// Inhalt in den Knopf — so stehen dort dieselben Verlaufs-Herzen wie in der Liste.
if (opt.dataset.label !== undefined) current.textContent = opt.dataset.label;
else current.innerHTML = opt.innerHTML;
options.forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
apply(); apply();
} }
catFilterBtn.addEventListener("click", (e) => { function close() {
root.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const open = catFilter.classList.toggle("open"); const willOpen = !root.classList.contains("open");
catFilterBtn.setAttribute("aria-expanded", open ? "true" : "false"); // Immer erst alle anderen schließen, damit nie zwei Listen gleichzeitig offen sind.
document.querySelectorAll("[data-dropdown].open").forEach((other) => {
other.classList.remove("open");
other.querySelector("[data-dropdown-btn]").setAttribute("aria-expanded", "false");
}); });
catFilterOptions.forEach((opt) => { if (willOpen) {
opt.addEventListener("click", () => { root.classList.add("open");
selectKategorie(opt.dataset.value, opt.dataset.label); btn.setAttribute("aria-expanded", "true");
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
});
document.addEventListener("click", () => {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
} }
}); });
options.forEach((opt) => {
opt.addEventListener("click", () => {
select(opt.dataset.value);
close();
});
});
return { root, close, select, options };
});
document.addEventListener("click", () => dropdowns.forEach((d) => d.close()));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") dropdowns.forEach((d) => d.close());
});
// Voreinstellung über die Adresszeile (z.B. /shop/?kategorie=diy-plushies), damit Links aus
// der Navigation weiterhin direkt mit gesetztem Filter landen.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const preset = params.get("kategorie"); const preset = params.get("kategorie");
if (preset) { if (preset) dropdowns.forEach((d) => d.select(preset));
const match = catFilterOptions.find((opt) => opt.dataset.value === preset);
if (match) selectKategorie(preset, match.dataset.label);
}
apply(); apply();
</script> </script>
+73 -42
View File
@@ -1,6 +1,7 @@
--- ---
import Layout from "../../../layouts/Layout.astro"; import Layout from "../../../layouts/Layout.astro";
import ProductCard from "../../../components/ProductCard.astro"; import ProductCard from "../../../components/ProductCard.astro";
import HeartRating from "../../../components/HeartRating.astro";
import { categories, subcategoryChipLabel } from "../../../data/categories"; import { categories, subcategoryChipLabel } from "../../../data/categories";
import { products, durchschnittsbewertung } from "../../../data/products"; import { products, durchschnittsbewertung } from "../../../data/products";
import type { Locale } from "../../../i18n/config"; import type { Locale } from "../../../i18n/config";
@@ -26,10 +27,10 @@ const t = useTranslations(lang);
<h3>{t.shop.filters}</h3> <h3>{t.shop.filters}</h3>
<div class="filter-group"> <div class="filter-group">
<span id="f-kategorie-label">{t.shop.categoryLabel}</span> <span id="f-kategorie-label">{t.shop.categoryLabel}</span>
<div class="cat-filter" id="cat-filter"> <div class="cat-filter" id="cat-filter" data-dropdown>
<input type="hidden" id="f-kategorie" value="" /> <input type="hidden" id="f-kategorie" value="" />
<button type="button" class="cat-filter-btn" id="cat-filter-btn" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label"> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label">
<span id="cat-filter-current">{t.shop.allCategories}</span> <span data-dropdown-current>{t.shop.allCategories}</span>
<span class="chevron" aria-hidden="true">▼</span> <span class="chevron" aria-hidden="true">▼</span>
</button> </button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label"> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label">
@@ -60,14 +61,23 @@ const t = useTranslations(lang);
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label> <span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
<select id="f-bewertung"> <div class="cat-filter" id="rating-filter" data-dropdown>
<option value="0">{t.shop.minRatingAny}</option> <input type="hidden" id="f-bewertung" value="0" />
<option value="4">🩵🩵🩵🩵 +</option> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-bewertung-label">
<option value="3">🩵🩵🩵 +</option> <span data-dropdown-current>{t.shop.minRatingAny}</span>
<option value="2">🩵🩵 +</option> <span class="chevron" aria-hidden="true">▼</span>
<option value="1">🩵 +</option> </button>
</select> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-bewertung-label">
<button type="button" class="cat-filter-option" data-value="0" data-label={t.shop.minRatingAny} role="option" aria-selected="true">{t.shop.minRatingAny}</button>
{[4, 3, 2, 1].map((n) => (
<button type="button" class="cat-filter-option rating-option" data-value={String(n)} role="option" aria-selected="false">
<HeartRating bewertung={n} size={15} />
<span class="rating-plus">+</span>
</button>
))}
</div>
</div>
</div> </div>
<p class="small">{t.shop.filterNote}</p> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -160,49 +170,70 @@ const t = useTranslations(lang);
sorted.forEach((el) => grid.appendChild(el)); sorted.forEach((el) => grid.appendChild(el));
} }
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply)); [preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
const catFilter = document.getElementById("cat-filter"); // Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
const catFilterBtn = document.getElementById("cat-filter-btn"); // Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
const catFilterCurrent = document.getElementById("cat-filter-current"); // eine eigene, gestaltbare Liste. Ein gemeinsamer Code für BEIDE Dropdowns (Kategorie und
const catFilterOptions = Array.from(catFilter.querySelectorAll(".cat-filter-option")); // Mindestbewertung), damit sich beide identisch verhalten und es nur eine Stelle zum Pflegen
// gibt. Verhalten wie beim Sprach-Umschalter im Header.
const dropdowns = Array.from(document.querySelectorAll("[data-dropdown]")).map((root) => {
const btn = root.querySelector("[data-dropdown-btn]");
const current = root.querySelector("[data-dropdown-current]");
const hidden = root.querySelector("input[type=hidden]");
const options = Array.from(root.querySelectorAll(".cat-filter-option"));
function selectKategorie(value, label) { function select(value) {
kategorieSel.value = value; const opt = options.find((o) => o.dataset.value === value);
catFilterCurrent.textContent = label; if (!opt) return;
catFilterOptions.forEach((opt) => opt.setAttribute("aria-selected", String(opt.dataset.value === value))); hidden.value = value;
// Optionen ohne eigenes data-label (z.B. die Herzen-Zeilen) übernehmen ihren kompletten
// Inhalt in den Knopf — so stehen dort dieselben Verlaufs-Herzen wie in der Liste.
if (opt.dataset.label !== undefined) current.textContent = opt.dataset.label;
else current.innerHTML = opt.innerHTML;
options.forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
apply(); apply();
} }
catFilterBtn.addEventListener("click", (e) => { function close() {
root.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const open = catFilter.classList.toggle("open"); const willOpen = !root.classList.contains("open");
catFilterBtn.setAttribute("aria-expanded", open ? "true" : "false"); // Immer erst alle anderen schließen, damit nie zwei Listen gleichzeitig offen sind.
document.querySelectorAll("[data-dropdown].open").forEach((other) => {
other.classList.remove("open");
other.querySelector("[data-dropdown-btn]").setAttribute("aria-expanded", "false");
}); });
catFilterOptions.forEach((opt) => { if (willOpen) {
opt.addEventListener("click", () => { root.classList.add("open");
selectKategorie(opt.dataset.value, opt.dataset.label); btn.setAttribute("aria-expanded", "true");
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
});
document.addEventListener("click", () => {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
} }
}); });
options.forEach((opt) => {
opt.addEventListener("click", () => {
select(opt.dataset.value);
close();
});
});
return { root, close, select, options };
});
document.addEventListener("click", () => dropdowns.forEach((d) => d.close()));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") dropdowns.forEach((d) => d.close());
});
// Voreinstellung über die Adresszeile (z.B. /shop/?kategorie=diy-plushies), damit Links aus
// der Navigation weiterhin direkt mit gesetztem Filter landen.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const preset = params.get("kategorie"); const preset = params.get("kategorie");
if (preset) { if (preset) dropdowns.forEach((d) => d.select(preset));
const match = catFilterOptions.find((opt) => opt.dataset.value === preset);
if (match) selectKategorie(preset, match.dataset.label);
}
apply(); apply();
</script> </script>
+77 -45
View File
@@ -1,6 +1,7 @@
--- ---
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import ProductCard from "../../components/ProductCard.astro"; import ProductCard from "../../components/ProductCard.astro";
import HeartRating from "../../components/HeartRating.astro";
import { categories, subcategoryChipLabel } from "../../data/categories"; import { categories, subcategoryChipLabel } from "../../data/categories";
import { products, durchschnittsbewertung } from "../../data/products"; import { products, durchschnittsbewertung } from "../../data/products";
import type { Locale } from "../../i18n/config"; import type { Locale } from "../../i18n/config";
@@ -31,10 +32,10 @@ const t = useTranslations(lang);
zwar technisch da, aber in der schmucklosen Browser-eigenen Liste kaum zu erkennen. zwar technisch da, aber in der schmucklosen Browser-eigenen Liste kaum zu erkennen.
Hier stehen sie als eigene, klar lesbare Zeilen mit demselben Namen wie auf den Hier stehen sie als eigene, klar lesbare Zeilen mit demselben Namen wie auf den
Kategorie-Seiten ("Kuh „Frieda"" usw.), damit man sie auf einen Blick sieht. */} Kategorie-Seiten ("Kuh „Frieda"" usw.), damit man sie auf einen Blick sieht. */}
<div class="cat-filter" id="cat-filter"> <div class="cat-filter" id="cat-filter" data-dropdown>
<input type="hidden" id="f-kategorie" value="" /> <input type="hidden" id="f-kategorie" value="" />
<button type="button" class="cat-filter-btn" id="cat-filter-btn" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label"> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-kategorie-label">
<span id="cat-filter-current">{t.shop.allCategories}</span> <span data-dropdown-current>{t.shop.allCategories}</span>
<span class="chevron" aria-hidden="true">▼</span> <span class="chevron" aria-hidden="true">▼</span>
</button> </button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label"> <div class="cat-filter-menu" role="listbox" aria-labelledby="f-kategorie-label">
@@ -65,14 +66,27 @@ const t = useTranslations(lang);
<input id="f-verfuegbar" type="checkbox" /> <input id="f-verfuegbar" type="checkbox" />
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label for="f-bewertung">{t.shop.minRatingLabel}</label> <span id="f-bewertung-label">{t.shop.minRatingLabel}</span>
<select id="f-bewertung"> {/* Eigenes Dropdown statt eines nativen <select>: In der Browser-eigenen Auswahlliste
<option value="0">{t.shop.minRatingAny}</option> lassen sich keine Farbverläufe darstellen — dort erschien nur das schlichte
<option value="4">🩵🩵🩵🩵 +</option> System-Emoji 🩵. Hier werden dieselben echten Verlaufs-Herzen (lila → babyblau,
<option value="3">🩵🩵🩵 +</option> siehe HeartRating.astro) verwendet wie überall sonst auf der Seite. */}
<option value="2">🩵🩵 +</option> <div class="cat-filter" id="rating-filter" data-dropdown>
<option value="1">🩵 +</option> <input type="hidden" id="f-bewertung" value="0" />
</select> <button type="button" class="cat-filter-btn" data-dropdown-btn aria-haspopup="listbox" aria-expanded="false" aria-labelledby="f-bewertung-label">
<span data-dropdown-current>{t.shop.minRatingAny}</span>
<span class="chevron" aria-hidden="true">▼</span>
</button>
<div class="cat-filter-menu" role="listbox" aria-labelledby="f-bewertung-label">
<button type="button" class="cat-filter-option" data-value="0" data-label={t.shop.minRatingAny} role="option" aria-selected="true">{t.shop.minRatingAny}</button>
{[4, 3, 2, 1].map((n) => (
<button type="button" class="cat-filter-option rating-option" data-value={String(n)} role="option" aria-selected="false">
<HeartRating bewertung={n} size={15} />
<span class="rating-plus">+</span>
</button>
))}
</div>
</div>
</div> </div>
<p class="small">{t.shop.filterNote}</p> <p class="small">{t.shop.filterNote}</p>
</aside> </aside>
@@ -165,52 +179,70 @@ const t = useTranslations(lang);
sorted.forEach((el) => grid.appendChild(el)); sorted.forEach((el) => grid.appendChild(el));
} }
[preisRange, verfuegbarChk, bewertungSel, sortSel].forEach((el) => el.addEventListener("input", apply)); [preisRange, verfuegbarChk, sortSel].forEach((el) => el.addEventListener("input", apply));
// Eigenes Kategorie-Dropdown (statt nativem <select>): Klick auf den Button öffnet/schließt // Eigene Dropdowns (statt nativer <select>-Felder): Der Browser kann in seiner eigenen
// die Liste, Klick auf eine Zeile setzt den Filter, aktualisiert die Anzeige-Beschriftung und // Auswahlliste weder unsere Kategorie-Struktur noch Verlaufs-Farben darstellen — deshalb hier
// schließt die Liste wieder — gleiches Verhalten wie der Sprach-Umschalter im Header. // eine eigene, gestaltbare Liste. Ein gemeinsamer Code für BEIDE Dropdowns (Kategorie und
const catFilter = document.getElementById("cat-filter"); // Mindestbewertung), damit sich beide identisch verhalten und es nur eine Stelle zum Pflegen
const catFilterBtn = document.getElementById("cat-filter-btn"); // gibt. Verhalten wie beim Sprach-Umschalter im Header.
const catFilterCurrent = document.getElementById("cat-filter-current"); const dropdowns = Array.from(document.querySelectorAll("[data-dropdown]")).map((root) => {
const catFilterOptions = Array.from(catFilter.querySelectorAll(".cat-filter-option")); const btn = root.querySelector("[data-dropdown-btn]");
const current = root.querySelector("[data-dropdown-current]");
const hidden = root.querySelector("input[type=hidden]");
const options = Array.from(root.querySelectorAll(".cat-filter-option"));
function selectKategorie(value, label) { function select(value) {
kategorieSel.value = value; const opt = options.find((o) => o.dataset.value === value);
catFilterCurrent.textContent = label; if (!opt) return;
catFilterOptions.forEach((opt) => opt.setAttribute("aria-selected", String(opt.dataset.value === value))); hidden.value = value;
// Optionen ohne eigenes data-label (z.B. die Herzen-Zeilen) übernehmen ihren kompletten
// Inhalt in den Knopf — so stehen dort dieselben Verlaufs-Herzen wie in der Liste.
if (opt.dataset.label !== undefined) current.textContent = opt.dataset.label;
else current.innerHTML = opt.innerHTML;
options.forEach((o) => o.setAttribute("aria-selected", String(o === opt)));
apply(); apply();
} }
catFilterBtn.addEventListener("click", (e) => { function close() {
root.classList.remove("open");
btn.setAttribute("aria-expanded", "false");
}
btn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const open = catFilter.classList.toggle("open"); const willOpen = !root.classList.contains("open");
catFilterBtn.setAttribute("aria-expanded", open ? "true" : "false"); // Immer erst alle anderen schließen, damit nie zwei Listen gleichzeitig offen sind.
document.querySelectorAll("[data-dropdown].open").forEach((other) => {
other.classList.remove("open");
other.querySelector("[data-dropdown-btn]").setAttribute("aria-expanded", "false");
}); });
catFilterOptions.forEach((opt) => { if (willOpen) {
opt.addEventListener("click", () => { root.classList.add("open");
selectKategorie(opt.dataset.value, opt.dataset.label); btn.setAttribute("aria-expanded", "true");
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
});
document.addEventListener("click", () => {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
catFilter.classList.remove("open");
catFilterBtn.setAttribute("aria-expanded", "false");
} }
}); });
options.forEach((opt) => {
opt.addEventListener("click", () => {
select(opt.dataset.value);
close();
});
});
return { root, close, select, options };
});
document.addEventListener("click", () => dropdowns.forEach((d) => d.close()));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") dropdowns.forEach((d) => d.close());
});
// Voreinstellung über die Adresszeile (z.B. /shop/?kategorie=diy-plushies), damit Links aus
// der Navigation weiterhin direkt mit gesetztem Filter landen.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const preset = params.get("kategorie"); const preset = params.get("kategorie");
if (preset) { if (preset) dropdowns.forEach((d) => d.select(preset));
const match = catFilterOptions.find((opt) => opt.dataset.value === preset);
if (match) selectKategorie(preset, match.dataset.label);
}
apply(); apply();
</script> </script>
+5
View File
@@ -916,6 +916,11 @@ a:focus-visible, button:focus-visible {
.cat-filter-option[aria-selected="true"] { background: color-mix(in srgb, var(--c-accent) 20%, transparent); font-weight: 700; } .cat-filter-option[aria-selected="true"] { background: color-mix(in srgb, var(--c-accent) 20%, transparent); font-weight: 700; }
.cat-filter-option.cat-filter-sub { padding-left: 1.4em; font-size: 0.88rem; opacity: 0.92; } .cat-filter-option.cat-filter-sub { padding-left: 1.4em; font-size: 0.88rem; opacity: 0.92; }
.cat-filter-option.cat-filter-sub::before { content: "— "; opacity: 0.6; } .cat-filter-option.cat-filter-sub::before { content: "— "; opacity: 0.6; }
/* Zeilen des Mindestbewertung-Filters: Herzen und das "+" nebeneinander auf einer Linie. */
.cat-filter-option.rating-option { display: flex; align-items: center; gap: 0.45em; }
.rating-plus { color: var(--c-text-muted); font-weight: 700; }
/* Auch im geschlossenen Knopf sollen die Herzen sauber neben dem Pfeil sitzen. */
.cat-filter-btn .heart-rating { vertical-align: middle; }
.shop-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.2rem; } .shop-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.2rem; }
.legal-hint { margin-top: 2rem; } .legal-hint { margin-top: 2rem; }
.breadcrumb { margin-bottom: 0.6rem; font-size: 0.95rem; } .breadcrumb { margin-bottom: 0.6rem; font-size: 0.95rem; }