Variant pills – Shopware 6 Varianten-Pills (Free)
Dieses Snippet erzeugt auf Produktseiten automatisch kompakte Varianten-Pills (z. B. Farbe, Nikotin, Ohm, Modell) und blendet bei vielen Optionen einen barrierearmen „Mehr anzeigen“-Toggle ein. Ohne Plugin, ohne Core-Eingriff.
Was es macht
- liest verfügbare Varianten aus den Konfigurator-Gruppen (Selects/Chips) aus
- zeigt „Weitere …“-Pills je Gruppe (Farbe, Modelle/Ausführungen, Widerstände, Nikotinstärken, Geschmacksrichtungen)
- vermeidet Dubletten (Unique-Listen) und sortiert sinnvoll (numerisch bei Ohm/Nikotin)
- stellt bei langen Listen automatisch einen Toggle „Mehr anzeigen / Weniger anzeigen“ bereit
- läuft updatefreundlich im Frontend (kein Core-Override nötig)
Viele Shops zeigen Varianten nur im Konfigurator. Pills bringen Struktur direkt in den sichtbaren Bereich der Produktseite und reduzieren Rücksprünge im Kaufprozess.
Voraussetzungen
- Shopware 6 Produktdetailseite mit Varianten-Konfigurator (Select/Chips)
- Ein Platz zum Einfügen (z. B. „HTML am Ende der Seite“ im Theme oder eigener CMS-Block / Custom HTML)
- keine externen Libraries nötig
Das Snippet ist bewusst generisch gehalten und ändert keine Cross-Selling-Blöcke. Ziel ist maximale Theme-Kompatibilität ohne unerwartete Seiteneffekte.
Einbau
1) Wo einfügen?
Optimal: JavaScript am Ende der Seite (Theme-Konfiguration) und CSS im Head. Wenn du nur ein Feld hast, kannst du beides auch zusammen einfügen.
2) Wo wird der Block platziert?
Der Block wird nach einem robusten Anker eingefügt (EAN → Buybox → Preis → Tabs → Fallback), damit er in unterschiedlichen Shopware-6-Themes funktioniert.
3) Was kannst du anpassen?
- Design: Farben/Abstände im CSS
- Limits: ab wie vielen Pills „Mehr anzeigen“ erscheint
- Einfügepunkt: bei Bedarf einen fixen DOM-Anker setzen
Keine Core-Dateien. Bei Theme-Updates können DOM-Klassen variieren – danach kurz testen (Checkliste unten).
Snippet-Code (copy & paste)
Hinweis: Free-Version (generisch) – ohne hardcodierte Shop-Links. Prefix vp6x- verhindert Kollisionen in Shopware-Themes. Standardmäßig siehst du einen Code-Ausschnitt; per Toggle kannst du den kompletten Code einblenden.
<style>
/* =========================================================
Variant pills (FREE) – Shopware 6 (kollisionssicher)
- Varianten-Pills + A11y Toggle "Mehr/Weniger"
- Ohne Cross-Selling Disable
- Eindeutiger Prefix: vp6x- (verhindert Layout-Kollisionen)
========================================================= */
.vp6x-seo-variants{
display:block;
margin-top:10px;
padding:10px 12px;
border:1px solid rgba(17,24,39,.10);
border-radius:12px;
background:#fff;
box-shadow:0 8px 18px rgba(17,24,39,.05);
font-size:13px;
line-height:1.35;
color:#111827;
}
/* Rows */
.vp6x-seo-variants .vp6x-row{ margin:10px 0 0; }
.vp6x-seo-variants .vp6x-row:first-child{ margin-top:0; }
.vp6x-seo-variants .vp6x-k{
display:block;
margin:0 0 6px;
font-weight:700;
letter-spacing:.12px;
font-size:12px;
color:rgba(17,24,39,.92);
}
.vp6x-seo-variants .vp6x-v{
display:flex;
flex-wrap:wrap;
gap:6px 7px;
color:rgba(17,24,39,.70);
font-weight:600;
font-size:12px;
}
/* Pills (a/span) */
.vp6x-seo-variants .vp6x-v > a,
.vp6x-seo-variants .vp6x-v > span{
display:inline-flex;
align-items:center;
padding:3px 8px;
border-radius:999px;
border:1px solid rgba(17,24,39,.12);
background:rgba(17,24,39,.018);
box-shadow:0 1px 0 rgba(17,24,39,.02);
color:rgba(17,24,39,.76);
text-decoration:none;
line-height:1.1;
font-size:11.5px;
font-weight:600;
max-width:100%;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
transition: background .15s ease, border-color .15s ease;
}
/* Link states neutral (keine lila visited) */
.vp6x-seo-variants .vp6x-v > a,
.vp6x-seo-variants .vp6x-v > a:visited,
.vp6x-seo-variants .vp6x-v > a:hover,
.vp6x-seo-variants .vp6x-v > a:active{
color:rgba(17,24,39,.76) !important;
-webkit-text-fill-color:rgba(17,24,39,.76) !important;
opacity:1 !important;
text-decoration:none !important;
}
.vp6x-seo-variants .vp6x-v > a:hover{
background:rgba(17,24,39,.035);
border-color:rgba(17,24,39,.16);
}
/* A11y focus */
.vp6x-seo-variants .vp6x-v > a:focus-visible{
outline:2px solid rgba(17,24,39,.55);
outline-offset:2px;
border-radius:10px;
}
/* ===== Toggle: Mehr/Weniger anzeigen ===== */
.vp6x-chip-hidden{ display:none !important; }
.vp6x-chip-toggle{
display:inline-flex;
align-items:center;
padding:4px 9px;
border-radius:999px;
border:1px dashed rgba(17,24,39,.18);
background:rgba(17,24,39,.018);
color:rgba(17,24,39,.78);
font-weight:700;
font-size:11.5px;
line-height:1.12;
cursor:pointer;
user-select:none;
-webkit-tap-highlight-color: transparent;
}
.vp6x-chip-toggle:hover{
background:rgba(17,24,39,.035);
border-color:rgba(17,24,39,.24);
}
.vp6x-chip-toggle:focus-visible{
outline:2px solid rgba(17,24,39,.55);
outline-offset:3px;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce){
.vp6x-seo-variants .vp6x-v > a,
.vp6x-seo-variants .vp6x-v > span{
transition:none !important;
}
}
/* Mobile */
@media (max-width:820px){
.vp6x-seo-variants{ padding:10px 10px; }
.vp6x-seo-variants .vp6x-v > a,
.vp6x-seo-variants .vp6x-v > span{
padding:4px 9px;
font-size:11.5px;
}
}
/* =========================================================
Kontrast-Fix (final override) – neutralisiert Theme-Opacity
========================================================= */
.vp6x-seo-variants{ color:#111827 !important; }
.vp6x-seo-variants .vp6x-k{ color:#111827 !important; }
.vp6x-seo-variants .vp6x-v{ color:#111827 !important; }
.vp6x-seo-variants .vp6x-v > a,
.vp6x-seo-variants .vp6x-v > span{
color:#111827 !important;
-webkit-text-fill-color:#111827 !important;
background:rgba(17,24,39,.06) !important;
border-color:rgba(17,24,39,.22) !important;
opacity:1 !important;
}
.vp6x-seo-variants .vp6x-v > a,
.vp6x-seo-variants .vp6x-v > a:visited,
.vp6x-seo-variants .vp6x-v > a:hover,
.vp6x-seo-variants .vp6x-v > a:active{
color:#111827 !important;
-webkit-text-fill-color:#111827 !important;
opacity:1 !important;
}
.vp6x-seo-variants .vp6x-v > a:hover{
background:rgba(17,24,39,.10) !important;
border-color:rgba(17,24,39,.28) !important;
}
</style>
<script>
(() => {
"use strict";
/* =========================================================
Shopware-Kapselung:
- nur Produktdetailseiten (PDP)
- verhindert Doppel-Init
========================================================= */
if (window.__vp6x_variant_pills_init) return;
window.__vp6x_variant_pills_init = true;
const isPdp =
document.body?.classList?.contains("is-ctl-product") ||
document.body?.classList?.contains("is-act-detail") ||
!!document.querySelector(".product-detail-main");
if (!isPdp) return;
const BLOCK_ID_MAIN = "vp6x-seo-variants";
const $ = (s, r=document)=>r.querySelector(s);
const $$ = (s, r=document)=>Array.from(r.querySelectorAll(s));
const clean = s => (s || "").replace(/\s+/g, " ").trim();
function escapeHtml(str){
return String(str)
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
function isUnavailableText(t){
return /(nicht\s*verfügbar|zur\s*zeit.*nicht|ausverkauft|out\s*of\s*stock|unavailable)/i.test(String(t||""));
}
function stripAvailabilitySuffix(t){
t = clean(String(t || ""));
t = t.replace(/\((?:[^)]*nicht\s*verfügbar[^)]*|[^)]*ausverkauft[^)]*|[^)]*unavailable[^)]*|[^)]*out\s*of\s*stock[^)]*)\)/ig, "");
t = t.replace(/\b(?:nicht\s*verfügbar|ausverkauft|unavailable|out\s*of\s*stock)\b/ig, "");
return clean(t);
}
function stripParensEverywhere(t){
return clean(String(t||"").replace(/\s*\([^)]*\)\s*/g, " "));
}
function findInsertAnchor(){
return (
document.querySelector(".twt-product-detail-ean") ||
document.querySelector(".twt-product-detail-ean-label") ||
document.querySelector(".product-detail-buy") ||
document.querySelector(".product-detail-price") ||
document.querySelector(".product-detail-ordernumber") ||
document.querySelector(".product-detail-tabs") ||
document.querySelector(".product-detail-content") ||
document.querySelector(".product-detail-main") ||
null
);
}
function ensureBox(afterEl){
const parent = afterEl?.parentNode;
if (!parent) return null;
let box = document.getElementById(BLOCK_ID_MAIN);
if (!box){
box = document.createElement("div");
box.id = BLOCK_ID_MAIN;
box.className = "vp6x-seo-variants";
box.setAttribute("data-vp6x", "variant-pills");
box.style.display = "none";
}
if (box.parentNode !== parent){
if (box.parentNode) box.parentNode.removeChild(box);
parent.insertBefore(box, afterEl.nextSibling);
} else if (box.previousSibling !== afterEl){
parent.insertBefore(box, afterEl.nextSibling);
}
return box;
}
function getProductNameRaw(){
return clean(document.querySelector("h1.product-detail-name, h1")?.textContent || "");
}
function isPackText(v){
const t = clean(String(v)).toLowerCase();
if (!t) return false;
return /\b\d+\s*er\b/.test(t) || /\b\d+\s*(stück|stk)\b/.test(t) || /\bpack(ung)?\b/.test(t) || /\bsingle\b/.test(t);
}
function normalizeOhm(t){
t = clean(t);
const m = t.match(/(\d+(?:[.,]\d+)?)\s*(?:ohm|Ω)\b/i);
if (!m) return "";
return `${m[1].replace(",", ".")} Ohm`;
}
function normalizeNic(t){
t = clean(t);
const m = t.match(/(\d+(?:[.,]\d+)?)\s*(mg\/?ml|mg)\b/i);
if (m){
const val = m[1].replace(",", ".");
const unit = m[2].toLowerCase().includes("ml") ? "mg/ml" : "mg";
return `${val} ${unit}`;
}
const n = t.match(/(\d+(?:[.,]\d+)?)/);
if (n && /(nikotin|nic|salt|salz|strength)/i.test(t)){
return `${n[1].replace(",", ".")} mg/ml`;
}
return "";
}
function normalizeFlavor(t){
t = clean(t).replace(/\s*\([^)]*\)\s*/g, " ").replace(/\s+/g," ").trim();
if (!t) return "";
if (/bitte wählen|auswählen|wählen/i.test(t)) return "";
if (/(\d+(?:[.,]\d+)?)\s*(mg\/?ml|mg)\b/i.test(t)) return "";
if (/(\d+(?:[.,]\d+)?)\s*(ohm|Ω)\b/i.test(t)) return "";
if (isPackText(t)) return "";
return t;
}
function findVariantGroups(){
const labelNodes = $$(
".product-detail-configurator-group-title," +
"label.product-detail-configurator-group-title," +
".product-variant-group-label," +
".product-variant-group__label," +
".sw-product-variant__label"
);
const groups = [];
for (const lab of labelNodes){
const labelText = clean(lab.textContent || "");
if (!labelText) continue;
const group =
lab.closest(".product-detail-configurator-group, .product-variant-group, .sw-product-variants__group") ||
lab.parentElement;
if (!group) continue;
groups.push({ label: labelText, el: group });
}
return groups;
}
function getGroupValues(groupEl){
const values = [];
const selects = $$("select", groupEl);
for (const sel of selects){
for (const opt of Array.from(sel.options || [])){
const raw = clean(opt.textContent || "");
if (!raw || /bitte wählen|auswählen|wählen/i.test(raw)) continue;
values.push(raw);
}
}
const optionEls = $$(
".product-detail-configurator-option," +
".sw-product-variants__option," +
"label[for]," +
"button," +
"a",
groupEl
);
for (const el of optionEls){
const raw = clean(
el.getAttribute("title") ||
el.getAttribute("aria-label") ||
el.getAttribute("data-original-title") ||
el.textContent ||
""
);
if (!raw) continue;
const looksLikeOption =
el.classList.contains("product-detail-configurator-option") ||
el.classList.contains("sw-product-variants__option") ||
!!el.closest(".product-detail-configurator-options, .sw-product-variants__options, .product-variant-group-options");
if (!looksLikeOption) continue;
values.push(raw);
}
return values;
}
function getSelectedValue(groupEl){
const sel = $("select", groupEl);
if (sel && sel.selectedOptions && sel.selectedOptions.length){
const raw = clean(sel.selectedOptions[0].textContent || "");
if (raw && !/bitte wählen|auswählen|wählen/i.test(raw)) return raw;
}
const checked = $("input[type='radio']:checked, input[type='checkbox']:checked", groupEl);
if (checked){
const lab = checked.id ? document.querySelector(`label[for="${checked.id}"]`) : null;
const raw = clean(lab?.getAttribute("title") || lab?.getAttribute("aria-label") || lab?.textContent || checked.value || "");
if (raw && !/bitte wählen|auswählen|wählen/i.test(raw)) return raw;
}
const candidates = $$(
".product-detail-configurator-option[aria-checked='true']," +
".product-detail-configurator-option[aria-selected='true']," +
".product-detail-configurator-option.is-active," +
".product-detail-configurator-option.active," +
".product-detail-configurator-option.selected," +
".sw-product-variants__option[aria-checked='true']," +
".sw-product-variants__option[aria-selected='true']," +
".sw-product-variants__option.is-active," +
".sw-product-variants__option.active," +
".sw-product-variants__option.selected",
groupEl
);
for (const el of candidates){
const raw = clean(el.getAttribute("title") || el.getAttribute("aria-label") || el.textContent || "");
if (raw && !/bitte wählen|auswählen|wählen/i.test(raw)) return raw;
}
return "";
}
function collectAll(){
const groups = findVariantGroups();
const colors = [];
const ohms = [];
const nics = [];
const flavors = [];
const models = [];
let selectedColorRaw = "";
let selectedColorWasUnavailable = false;
let selectedNicRaw = "";
let selectedOhmRaw = "";
let selectedFlavorRaw = "";
let selectedModelRaw = "";
let selectedNicWasUnavailable = false;
let selectedOhmWasUnavailable = false;
let selectedFlavorWasUnavailable = false;
let selectedModelWasUnavailable = false;
for (const g of groups){
const L = g.label.toLowerCase();
const vals = getGroupValues(g.el);
const isColorGroup = /(farbe|color|colour|couleur|colore|kleur)/i.test(L);
const isFlavorGroup = /(geschmack|sorte|aroma|flavour|flavor|taste)/i.test(L);
const looksNicByValues = vals.some(v => /(\d+(?:[.,]\d+)?)\s*(mg\/?ml|mg)\b/i.test(String(v||"")));
const isNicGroup = /(nikotin|nicotine|nikotinstärke|stärke|strength)/i.test(L) || looksNicByValues;
const isOhmGroup = /(widerstand|ohm|Ω|resistance)/i.test(L) || vals.some(v => /(\d+(?:[.,]\d+)?)\s*(ohm|Ω)\b/i.test(String(v||"")));
const isModelGroup =
/(modell|model|tank|füllmenge|volumen|capacity|kapazität|inhalt|ausführung|version|größe|groesse)/i.test(L);
if (isColorGroup){
const pickedRaw0 = getSelectedValue(g.el);
if (pickedRaw0){
selectedColorWasUnavailable = isUnavailableText(pickedRaw0);
const pickedRaw = stripParensEverywhere(stripAvailabilitySuffix(pickedRaw0));
if (pickedRaw && !isPackText(pickedRaw)) selectedColorRaw = pickedRaw;
}
}
if (isNicGroup){
const pickedRaw0 = getSelectedValue(g.el);
if (pickedRaw0){
selectedNicWasUnavailable = isUnavailableText(pickedRaw0);
const picked0 = stripAvailabilitySuffix(pickedRaw0);
const picked = normalizeNic(picked0) || picked0;
if (picked) selectedNicRaw = stripParensEverywhere(picked);
}
}
if (isOhmGroup){
const pickedRaw0 = getSelectedValue(g.el);
if (pickedRaw0){
selectedOhmWasUnavailable = isUnavailableText(pickedRaw0);
const picked0 = stripAvailabilitySuffix(pickedRaw0);
const picked = normalizeOhm(picked0) || picked0;
if (picked) selectedOhmRaw = stripParensEverywhere(picked);
}
}
if (isFlavorGroup){
const pickedRaw0 = getSelectedValue(g.el);
if (pickedRaw0){
selectedFlavorWasUnavailable = isUnavailableText(pickedRaw0);
const picked0 = stripAvailabilitySuffix(pickedRaw0);
const picked = normalizeFlavor(picked0) || picked0;
if (picked) selectedFlavorRaw = stripParensEverywhere(picked);
}
}
if (isModelGroup){
const pickedRaw0 = getSelectedValue(g.el);
if (pickedRaw0){
selectedModelWasUnavailable = isUnavailableText(pickedRaw0);
const picked0 = stripAvailabilitySuffix(pickedRaw0);
const picked = stripParensEverywhere(picked0);
if (picked && !isPackText(picked)) selectedModelRaw = picked;
}
}
for (const raw0 of vals){
const raw = clean(raw0);
if (!raw) continue;
if (isUnavailableText(raw)) continue;
if (isPackText(raw)) continue;
if (isColorGroup){
const c = stripParensEverywhere(stripAvailabilitySuffix(raw));
if (c) colors.push(c);
continue;
}
if (isFlavorGroup){
const f = stripParensEverywhere(normalizeFlavor(raw));
if (f) flavors.push(f);
continue;
}
if (isOhmGroup){
const o = stripParensEverywhere(normalizeOhm(raw) || raw);
if (o) ohms.push(o);
continue;
}
if (isNicGroup || /(\d+(?:[.,]\d+)?)\s*(mg\/?ml|mg)\b/i.test(raw)){
const n = stripParensEverywhere(normalizeNic(raw) || raw);
if (n) nics.push(n);
continue;
}
if (isModelGroup){
const m = stripParensEverywhere(stripAvailabilitySuffix(raw));
if (m) models.push(m);
continue;
}
}
}
const uniq = arr => [...new Set(arr.map(clean).filter(Boolean))];
const sortText = arr => arr.slice().sort((a,b)=> String(a).localeCompare(String(b), "de"));
const sortNum = arr => arr.slice().sort((a,b)=>{
const na = parseFloat(String(a).replace(",", "."));
const nb = parseFloat(String(b).replace(",", "."));
if (isFinite(na) && isFinite(nb)) return na - nb;
return String(a).localeCompare(String(b), "de");
});
const rawName = getProductNameRaw();
return {
productNameRaw: rawName,
selectedColorRaw: clean(selectedColorRaw),
selectedColorWasUnavailable: !!selectedColorWasUnavailable,
selectedNicRaw: clean(selectedNicRaw),
selectedOhmRaw: clean(selectedOhmRaw),
selectedFlavorRaw: clean(selectedFlavorRaw),
selectedModelRaw: clean(selectedModelRaw),
selectedNicWasUnavailable: !!selectedNicWasUnavailable,
selectedOhmWasUnavailable: !!selectedOhmWasUnavailable,
selectedFlavorWasUnavailable: !!selectedFlavorWasUnavailable,
selectedModelWasUnavailable: !!selectedModelWasUnavailable,
colors: sortText(uniq(colors)),
flavors: sortText(uniq(flavors)),
ohms: sortNum(uniq(ohms)),
nics: sortNum(uniq(nics)),
models: sortText(uniq(models))
};
}
function diffList(all, selected, normalizer){
const norm = normalizer || (x => clean(x).toLowerCase());
const sel = norm(selected || "");
if (!sel) return all.slice();
return all.filter(x => norm(x) !== sel);
}
function pillsFromTextArray(arr){
return (arr || [])
.map(x => `<span>${escapeHtml(stripParensEverywhere(x))}</span>`)
.join("");
}
function renderMain(main, data){
const pname = clean(data.productNameRaw);
const suf = pname ? (" – " + escapeHtml(pname)) : "";
const lines = [];
const colorLabel = data.selectedColorWasUnavailable ? "Verfügbare Farben" : "Weitere Farben";
const colors = diffList(data.colors || [], data.selectedColorRaw).map(stripParensEverywhere);
if (colors.length){
lines.push(
`<div class="vp6x-row">
<div class="vp6x-k">${escapeHtml(colorLabel)}${suf}</div>
<div class="vp6x-v">${pillsFromTextArray(colors)}</div>
</div>`
);
}
const modelLabel = data.selectedModelWasUnavailable ? "Verfügbare Modelle/Ausführungen" : "Weitere Modelle/Ausführungen";
const models = diffList(data.models || [], data.selectedModelRaw).map(stripParensEverywhere);
if (models.length){
lines.push(
`<div class="vp6x-row">
<div class="vp6x-k">${escapeHtml(modelLabel)}${suf}</div>
<div class="vp6x-v">${pillsFromTextArray(models)}</div>
</div>`
);
}
const ohmLabel = data.selectedOhmWasUnavailable ? "Verfügbare Widerstände" : "Weitere Widerstände";
const ohmNorm = x => clean(String(x||"")).toLowerCase().replace(",", ".").replace(/\s+/g," ").trim();
const ohms = diffList(data.ohms || [], data.selectedOhmRaw, ohmNorm).map(stripParensEverywhere);
if (ohms.length){
lines.push(
`<div class="vp6x-row">
<div class="vp6x-k">${escapeHtml(ohmLabel)}${suf}</div>
<div class="vp6x-v">${pillsFromTextArray(ohms)}</div>
</div>`
);
}
const nicLabel = data.selectedNicWasUnavailable ? "Verfügbare Nikotinstärken" : "Weitere Nikotinstärken";
const nicNorm = x => clean(String(x||"")).toLowerCase().replace(",", ".").replace(/\s+/g," ").trim();
const nics = diffList(data.nics || [], data.selectedNicRaw, nicNorm).map(stripParensEverywhere);
if (nics.length){
lines.push(
`<div class="vp6x-row">
<div class="vp6x-k">${escapeHtml(nicLabel)}${suf}</div>
<div class="vp6x-v">${pillsFromTextArray(nics)}</div>
</div>`
);
}
const flavorLabel = data.selectedFlavorWasUnavailable ? "Verfügbare Geschmacksrichtungen" : "Weitere Geschmacksrichtungen";
const flavors = diffList(data.flavors || [], data.selectedFlavorRaw).map(stripParensEverywhere);
if (flavors.length){
lines.push(
`<div class="vp6x-row">
<div class="vp6x-k">${escapeHtml(flavorLabel)}${suf}</div>
<div class="vp6x-v">${pillsFromTextArray(flavors)}</div>
</div>`
);
}
const nextHTML = lines.join("");
if (!nextHTML){
main.__vp6x_lastHTML = "";
main.innerHTML = "";
main.style.display = "none";
return;
}
if (main.__vp6x_lastHTML !== nextHTML){
main.__vp6x_lastHTML = nextHTML;
main.innerHTML = nextHTML;
}
main.style.display = "block";
}
function getLimitForRowLabel(labelText){
const t = clean(labelText).toLowerCase();
if (t.includes("geschmacks")) return 10;
if (t.includes("farben")) return 12;
if (t.includes("modelle") || t.includes("ausführungen")) return 12;
if (t.includes("widerstände")) return 14;
if (t.includes("nikotinstärken")) return 14;
return 12;
}
function ensureId(el){
if (el.id) return el.id;
el.id = "vp6x-chiplist-" + Math.random().toString(36).slice(2, 10);
return el.id;
}
function applyToggleToV(v){
if (!v) return;
const items = Array.from(v.querySelectorAll(":scope > a, :scope > span"));
if (!items.length) return;
const existingBtn = v.querySelector(":scope > button.vp6x-chip-toggle");
if (existingBtn) existingBtn.remove();
items.forEach(it => it.classList.remove("vp6x-chip-hidden"));
const row = v.closest(".vp6x-row");
const label = row ? (row.querySelector(".vp6x-k")?.textContent || "") : "";
const limit = getLimitForRowLabel(label);
if (items.length <= limit) return;
const listId = ensureId(v);
const hidden = items.slice(limit);
hidden.forEach(it => it.classList.add("vp6x-chip-hidden"));
const btn = document.createElement("button");
btn.type = "button";
btn.className = "vp6x-chip-toggle";
btn.setAttribute("aria-controls", listId);
btn.setAttribute("aria-expanded", "false");
btn.textContent = `Mehr anzeigen (${items.length - limit})`;
btn.addEventListener("click", () => {
const expanded = btn.getAttribute("aria-expanded") === "true";
const next = !expanded;
btn.setAttribute("aria-expanded", String(next));
hidden.forEach(it => it.classList.toggle("vp6x-chip-hidden", !next));
btn.textContent = next ? "Weniger anzeigen" : `Mehr anzeigen (${items.length - limit})`;
});
v.appendChild(btn);
}
function runToggleOnRoot(root){
if (!root) return;
root.querySelectorAll(".vp6x-v").forEach(applyToggleToV);
}
let raf = 0;
let isRendering = false;
function scheduleRender(){
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
raf = 0;
if (isRendering) return;
isRendering = true;
try{
const data = collectAll();
const anchor = findInsertAnchor();
if (!anchor) return;
const main = ensureBox(anchor);
if (!main) return;
renderMain(main, data);
runToggleOnRoot(main);
} finally {
setTimeout(() => { isRendering = false; }, 0);
}
});
}
scheduleRender();
/* =========================================================
WICHTIG: NICHT document.body beobachten (sicherer)
========================================================= */
const watchRoot =
document.querySelector(".product-detail-configurator") ||
document.querySelector(".sw-product-variants") ||
document.querySelector(".sw-product-variants-container") ||
document.querySelector(".product-variants") ||
document.querySelector(".product-detail-main") ||
null;
if (!watchRoot) return;
new MutationObserver(scheduleRender).observe(watchRoot, {
childList:true,
subtree:true,
attributes:true,
attributeFilter:["class","aria-checked","aria-selected","aria-pressed","value","checked","selected","title","aria-label"]
});
watchRoot.addEventListener("change", scheduleRender, { passive:true });
watchRoot.addEventListener("click", scheduleRender, { passive:true });
})();
</script>
Test-Checkliste (2 Minuten)
- Produktseite öffnen, auf der Varianten vorhanden sind (Farbe/Nikotin/Ohm etc.).
- Prüfen, ob der Block sichtbar ist und mindestens eine Zeile (z. B. „Weitere Farben“) zeigt.
- Variante wechseln (z. B. Farbe) → Block aktualisiert sich ohne Doppelteinträge.
- Bei vielen Optionen: „Mehr anzeigen“ klickbar, danach „Weniger anzeigen“.
- Tab-Navigation: Toggle und Links bekommen einen sichtbaren Focus-Rand.
- Mobile (≤ 820px): Pills umbrechen sauber, kein Layout-Overflow.
Wenn bei deinem Theme keine Varianten-Gruppen erkannt werden, liegt es fast immer an abweichenden CSS-Klassen. Dann setzt man die Selektoren im Snippet gezielt auf deine DOM-Struktur.
Hinweise
Bei Shopware-6-Themes können DOM-Klassen abweichen. Wenn das Snippet keine Gruppen erkennt, müssen die Selektoren
in findVariantGroups() und ggf. in der Insert-Anchor-Logik angepasst werden.
Wenn du eine exakt definierte Platzierung willst (z. B. direkt unter Preis oder unter Buybox), setze in
findInsertAnchor() einen Theme-spezifischen Selektor an erste Stelle.
FAQ
Warum zeigt das Snippet manchmal nichts an?
Wenn auf der Produktseite keine erkennbaren Varianten-Gruppen vorhanden sind oder dein Theme andere Klassen nutzt, können keine Werte extrahiert werden. Dann müssen die Gruppen-Selektoren angepasst werden.
Kann ich die Reihenfolge oder Labels ändern?
Ja. Die Labels („Weitere Farben“, „Weitere Widerstände“ usw.) sind im Render-Teil definiert und können angepasst werden. Ebenso die Limits für „Mehr anzeigen“.
Beeinflusst das SEO?
Primär ist es ein UX-Snippet. Es kann indirekt helfen (bessere Orientierung, weniger Absprünge), ersetzt aber keine saubere Varianten-/URL-Strategie.
Ist das update-sicher?
Es verändert keine Core-Dateien. Updates können Theme-Markup ändern. Daher: nach Theme-Update kurz testen.