Warenkorb als Seitenpanel
Mini-Warenkorb als natives dialog-Panel von rechts: Mengenwahl, Entfernen mit Rückgängig, Versandschwelle als Fortschritt, Leerzustand und Summe inkl. MwSt.
Ein Mini-Warenkorb, der als Panel von rechts hereinfährt, statt auf eine eigene Seite zu wechseln. Er ist ein modaler <dialog>: Der Fokus geht hinein, Esc und ein Klick daneben schließen, danach steht der Fokus wieder am Auslöser. Im Panel stehen die Artikel mit Bild, Sorte, Grundpreis, Mengenwahl und „Entfernen“ mit Rückgängig. Oben zeigt ein Fortschritt den Weg zur Versandkostengrenze („Noch 8,40 € bis versandkostenfrei“), unten stehen Zwischensumme, Versand, Summe inklusive Mehrwertsteuer und der Weg zur Kasse. Ist der Korb leer, zeigt er einen Leerzustand mit zwei Vorschlägen.
Wer etwas in den Korb legt, bleibt auf der Seite und sieht sofort, was drin ist und was es kostet. Die Versandgrenze gibt einen klaren, ehrlichen Anreiz. Rückgängig nimmt die Angst vor dem falschen Klick.
Wann einsetzen
Kleine Shops von Rösterei, Hofladen, Weingut, Manufaktur, Mode- und D2C-Marken. Immer wenn Kunden mehrere Artikel kombinieren. Die Variante „Hinweis am Symbol“, wenn Kunden meist weiter stöbern und das Panel stören würde.
Wann nicht
Bei Einzelkäufen mit Konfiguration (Möbel nach Maß, Reisen), auf die direkt der Checkout folgt. Wenn das Shopsystem einen eigenen Warenkorb erzwingt, der sich nicht anpassen lässt.
Varianten
- Seitenpanel (Drawer) mit Mengenwahl, Versandschwelle, Vorschlägen und Summe
- Hinweis am Warenkorb-Symbol nach dem Hinzufügen, mit Weg ins Panel oder zur Kasse
- Eigene Warenkorb-Seite für große Körbe
- Bottom-Sheet auf dem Telefon
Umsetzung
Panel: <dialog class="korb" aria-labelledby="korb-titel" closedby="any">, öffnen mit showModal(), Fokus auf die Überschrift (tabindex=-1). CSS: .korb { inset: 0 0 0 auto; margin: 0; width: min(440px, 100vw); height: 100dvh; translate: 100% 0; transition: translate .4s var(--kurve), overlay .4s allow-discrete, display .4s allow-discrete } .korb[open] { translate: 0 } @starting-style { .korb[open] { translate: 100% 0 } }; ::backdrop genauso mit Hintergrundfarbe. Ohne closedby den Klick mit e.target === dialog abfangen. html:has(.korb[open]) { overflow: hidden }.
Positionen als Liste mit stabilen Schlüsseln. Menge und Summen an Ort und Stelle aktualisieren, nicht neu aufbauen, damit der Fokus bleibt. Entfernen: Zeile per grid-template-rows 1fr → 0fr zuklappen, an ihre Stelle eine Zeile „… entfernt. Rückgängig“ setzen, Fokus darauf, nach 6 s endgültig. Änderungen über eine aria-live-Region ansagen.
Versandschwelle: Rest = Grenze − Zwischensumme; Text immer, Balken nur zur Unterstützung. „Passt dazu“ nur unter der Grenze zeigen.
Preise mit Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }), Grundpreis je kg, MwSt. je Steuersatz aus den Bruttopreisen; Versandkosten anteilig. Im Shop nach jeder Änderung den Korb vom Server lesen (z. B. Shopify /cart/change.js) und daraus zeichnen. Preisangaben, Grundpreise und Steuersätze vom Kunden prüfen lassen.
Variante Hinweis: <div popover="manual"> am Warenkorb-Symbol, per Anchor Positioning (position-anchor, top: anchor(bottom)) mit festem Fallback; schließt nach 6 s, nicht bei Zeiger oder Fokus darin.
Robust umsetzen
Telefon. Panel in voller Breite, Summe und „Zur Kasse“ unten fest im Panel, Mengenknöpfe 44px. Der Hintergrund scrollt nicht mit.
Ohne Bewegung. Panel und Hinweis blenden nur ein (150 ms Deckkraft). Balken, Zeilen und Zähler springen in den Endzustand.
Barrierefrei. Natives <dialog> mit showModal(): Fokus bleibt im Panel, Hintergrund inert, Esc schließt, Fokus kehrt zum Auslöser zurück. Überschrift per aria-labelledby. Mengenknöpfe mit Namen („Menge erhöhen, Festungsberg“), Minus bei 1 deaktiviert. Hinzufügen, Mengen, Entfernen und Versandschwelle per aria-live ansagen; nach dem Entfernen Fokus auf „Rückgängig“, bei leerem Korb auf die Überschrift. Warenkorb-Symbol mit Anzahl im Namen. Schwelle als Text, Balken aria-hidden.
Code
<!DOCTYPE html>
<!--
Warenkorb als Seitenpanel
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/warenkorb-drawer/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warenkorb als Seitenpanel · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Commissioner'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/commissioner:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept gastro-warm-appetitlich: warmes Papier, warme dunkle Schrift, Salbei für Flächen, Senf als Akzent */
--grund: #f0ece0; --papier: #fbf8f1; --tinte: #353839; --leise: #5f6260; --linie: #ddd5c2; --linie-stark: #c9bfa6;
--salbei: #7f937a; --gruen: #293e3a; --senf: #eac16e; --rose: #e1a894; --kraft: #c9a77c;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out */
--panel: 400ms; --ui: 200ms; --balken: 500ms;
--kante: clamp(16px, 3.4vw, 48px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Commissioner', system-ui, sans-serif; font-variation-settings: 'FLAR' 0; -webkit-font-smoothing: antialiased; }
html:has(.korb[open]) { overflow: hidden; }
body { min-height: 100svh; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
a { color: inherit; }
.serif { font-family: 'Fraunces', Georgia, serif; font-optical-sizing: auto; font-variation-settings: 'SOFT' 80, 'WONK' 0; }
.label { font: 600 .75rem/1.2 'Commissioner', sans-serif; font-variation-settings: 'FLAR' 80; letter-spacing: .15em; text-transform: uppercase; }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
:focus-visible { outline: 2px solid var(--gruen); outline-offset: 3px; }
/* ---------- Shop im Hintergrund ---------- */
.laden { position: sticky; top: 0; z-index: 10; background: rgba(240, 236, 224, .94); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: space-between; gap: 20px; height: 76px; padding: 0 var(--kante); border-bottom: 1px solid var(--linie-stark); }
.marke { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.marke svg { width: 38px; height: 38px; flex: none; }
.marke b { font: 600 1.5rem/1 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 100, 'WONK' 1; letter-spacing: -.01em; }
.marke small { display: block; margin-top: 4px; color: var(--leise); }
.laden nav ul { display: flex; gap: 28px; list-style: none; }
.laden nav a { text-decoration: none; font-weight: 500; }
.laden nav a:hover { text-decoration: underline; text-underline-offset: 5px; text-decoration-thickness: 1px; }
.korb-knopf { position: relative; display: inline-flex; align-items: center; gap: 10px; height: 46px; padding: 0 16px 0 14px; border: 1px solid var(--tinte); border-radius: 999px; font-weight: 600; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); anchor-name: --korb; }
.korb-knopf svg { width: 20px; height: 20px; }
.korb-knopf:hover { background: var(--tinte); color: var(--papier); }
.zaehler { display: inline-grid; place-items: center; min-width: 24px; height: 24px; padding: 0 6px; border-radius: 99px; background: var(--senf); color: var(--tinte); font-size: .8125rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.zaehler.hupf { animation: hupf 400ms var(--kurve); }
@keyframes hupf { 40% { scale: 1.25; } }
.schaufenster { padding: clamp(28px, 4vw, 56px) var(--kante) 96px; max-width: 1440px; margin: 0 auto; }
.kopfzeile { display: flex; justify-content: space-between; align-items: end; gap: 24px 48px; flex-wrap: wrap; margin-bottom: clamp(22px, 3vw, 40px); }
.kopfzeile h1 { font: 450 clamp(2.5rem, 1.6rem + 4vw, 5rem)/1.02 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 100, 'WONK' 0; letter-spacing: -.01em; max-width: 14ch; }
.kopfzeile h1 em { font-style: italic; font-weight: 400; }
.kopfzeile p { font-size: 1.0625rem; line-height: 1.55; max-width: 36ch; color: var(--leise); }
.kopfzeile p b { color: var(--tinte); font-weight: 600; }
.produkte { list-style: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(14px, 2vw, 28px); }
.produkt { display: flex; flex-direction: column; gap: 12px; }
.produkt figure { aspect-ratio: 4 / 5; border-radius: 4px; display: grid; place-items: center; background: var(--flaeche, #e6dfcc); overflow: hidden; }
.produkt figure svg { width: 62%; height: auto; transition: scale 700ms var(--kurve); }
.produkt:hover figure svg { scale: 1.04; }
.produkt h2 { font: 500 1.375rem/1.15 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 80; }
.produkt .noten { font-size: .9375rem; color: var(--leise); }
.produkt .preis { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-variant-numeric: tabular-nums; }
.produkt .preis b { font-size: 1.125rem; font-weight: 600; }
.produkt .preis small { font-size: .8125rem; color: var(--leise); }
.legen { height: 48px; border-radius: 999px; background: var(--gruen); color: var(--papier); font-weight: 600; display: inline-flex; align-items: center; justify-content: center; gap: 8px; transition: background-color var(--ui) var(--kurve), scale 150ms var(--kurve); }
.legen:hover { background: #1d2d2a; }
.legen:active, .legen.drueckt { scale: .97; }
.legen svg { width: 16px; height: 16px; }
.legen .kurz { display: none; }
/* ---------- Der Warenkorb: dialog als Seitenpanel ---------- */
.korb { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(440px, 100vw); max-width: 100vw; height: 100dvh; max-height: 100dvh; padding: 0; border: 0; background: var(--papier); color: var(--tinte); box-shadow: -30px 0 80px rgba(41, 62, 58, .2);
translate: 100% 0; transition: translate var(--panel) var(--kurve), overlay var(--panel) allow-discrete, display var(--panel) allow-discrete; }
.korb[open] { translate: 0 0; }
@starting-style { .korb[open] { translate: 100% 0; } }
.korb::backdrop { background: rgba(41, 62, 58, 0); transition: background-color var(--panel) var(--kurve), overlay var(--panel) allow-discrete, display var(--panel) allow-discrete; }
.korb[open]::backdrop { background: rgba(41, 62, 58, .42); }
@starting-style { .korb[open]::backdrop { background: rgba(41, 62, 58, 0); } }
.innen { display: flex; flex-direction: column; height: 100%; }
.korb-kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 20px 24px 16px; }
.korb-kopf h2 { font: 500 1.75rem/1 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 100; outline: none; }
.korb-kopf h2 span { font: 500 1rem/1 'Commissioner', sans-serif; color: var(--leise); margin-left: 6px; font-variant-numeric: tabular-nums; }
.zu { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; transition: background-color var(--ui) var(--kurve); }
.zu:hover { background: rgba(53, 56, 57, .08); }
.zu svg { width: 18px; height: 18px; }
/* Versandschwelle */
.schwelle { margin: 0 24px; padding: 14px 16px 15px; border-radius: 6px; background: #f2ecdc; display: grid; gap: 10px; transition: background-color var(--balken) var(--kurve); }
.schwelle p { font-size: .9375rem; line-height: 1.35; display: flex; gap: 10px; align-items: center; }
.schwelle p b { font-weight: 700; font-variant-numeric: tabular-nums; }
.schwelle p svg { width: 20px; height: 20px; flex: none; }
.bahn { position: relative; height: 8px; border-radius: 99px; background: #e2d9c2; overflow: hidden; }
.bahn i { position: absolute; inset: 0 auto 0 0; width: var(--anteil, 0%); border-radius: 99px; background: var(--salbei); transition: width var(--balken) var(--kurve), background-color var(--balken) var(--kurve); }
.bahn::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 2px; background: var(--gruen); opacity: .35; }
.schwelle.geschafft { background: #e3ead8; }
.schwelle.geschafft .bahn i { background: var(--gruen); }
.schwelle.geschafft .bahn::after { display: none; }
.schwelle .lkw { color: var(--salbei); }
.schwelle.geschafft .lkw { color: var(--gruen); }
/* Positionen */
.rumpf { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; margin-top: 4px; }
.positionen { padding: 2px 24px 4px; list-style: none; }
.pos { display: grid; grid-template-rows: 1fr; transition: grid-template-rows var(--panel) var(--kurve), opacity var(--ui) var(--kurve); }
.pos > div { min-height: 0; overflow: hidden; }
.pos.weg { grid-template-rows: 0fr; opacity: 0; }
.pos.neu { animation: herein var(--panel) var(--kurve) both; }
@keyframes herein { from { opacity: 0; translate: 24px 0; } }
.zeile { display: grid; grid-template-columns: 76px minmax(0, 1fr) auto; gap: 4px 16px; padding: 16px 0; border-bottom: 1px solid var(--linie); }
.zeile figure { grid-row: span 3; width: 76px; height: 92px; border-radius: 4px; display: grid; place-items: center; background: var(--flaeche, #e6dfcc); }
.zeile figure svg { width: 64%; }
.zeile h3 { font: 500 1.125rem/1.2 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 80; }
.zeile .art { font-size: .875rem; color: var(--leise); line-height: 1.35; }
.zeile .summe { font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; grid-row: 1; grid-column: 3; }
.zeile .bedien { grid-column: 2 / 4; display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 8px; }
.menge { display: inline-flex; align-items: center; border: 1px solid var(--linie-stark); border-radius: 99px; height: 40px; }
.menge button { width: 40px; height: 38px; display: grid; place-items: center; border-radius: 99px; transition: background-color var(--ui) var(--kurve); }
.menge button:hover:not(:disabled) { background: rgba(53, 56, 57, .07); }
.menge button:disabled { opacity: .3; cursor: not-allowed; }
.menge button svg { width: 14px; height: 14px; }
.menge output { min-width: 2ch; text-align: center; font-weight: 600; font-variant-numeric: tabular-nums; }
.entfernen { font-size: .875rem; text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; color: var(--leise); padding: 8px 0; }
.entfernen:hover { color: var(--tinte); }
.rueck { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 14px 16px; margin: 10px 0; border-radius: 6px; background: #f2ecdc; position: relative; overflow: hidden; font-size: .9375rem; }
.rueck button { font-weight: 700; text-decoration: underline; text-underline-offset: 3px; padding: 6px 2px; }
.rueck::after { content: ''; position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: var(--salbei); transform-origin: left; animation: ablauf 6s linear forwards; }
@keyframes ablauf { to { transform: scaleX(0); } }
/* Passt dazu */
.dazu { padding: 12px 24px 18px; }
.dazu[hidden] { display: none; }
.dazu .label { color: var(--leise); margin-bottom: 10px; }
.dazu ul { list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.mini { display: grid; grid-template-columns: 44px minmax(0, 1fr); gap: 2px 10px; align-items: center; padding: 8px; border: 1px solid var(--linie); border-radius: 6px; text-align: left; width: 100%; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.mini:hover { border-color: var(--tinte); background: #fff; }
.mini figure { grid-row: span 2; width: 44px; height: 52px; border-radius: 3px; display: grid; place-items: center; background: var(--flaeche, #e6dfcc); }
.mini figure svg { width: 66%; }
.mini b { font-weight: 600; font-size: .875rem; line-height: 1.2; }
.mini span { font-size: .8125rem; color: var(--leise); font-variant-numeric: tabular-nums; }
.mini span::after { content: ' · hinzufügen'; color: var(--gruen); font-weight: 600; }
/* Fuß: Summe */
.fuss { padding: 16px 24px max(20px, env(safe-area-inset-bottom)); border-top: 1px solid var(--linie-stark); background: var(--papier); display: grid; gap: 6px; }
.fuss dl { display: grid; grid-template-columns: 1fr auto; gap: 6px 0; font-variant-numeric: tabular-nums; }
.fuss dt { color: var(--leise); }
.fuss dd { text-align: right; }
.fuss .gesamt { font: 500 1.375rem/1.2 'Fraunces', Georgia, serif; color: var(--tinte); padding-top: 8px; border-top: 1px solid var(--linie); margin-top: 4px; }
.fuss .gesamt + dd { padding-top: 8px; border-top: 1px solid var(--linie); margin-top: 4px; }
.fuss .mwst { font-size: .8125rem; color: var(--leise); line-height: 1.45; }
.kasse { margin-top: 10px; height: 54px; border-radius: 999px; background: var(--gruen); color: var(--papier); font-weight: 650; font-size: 1.0625rem; display: flex; align-items: center; justify-content: center; gap: 10px; transition: background-color var(--ui) var(--kurve), scale 150ms var(--kurve); }
.kasse:hover { background: #1d2d2a; }
.kasse svg { width: 18px; height: 18px; }
.weiter { justify-self: center; font-size: .9375rem; text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; padding: 6px; }
/* Leerzustand */
.leer { display: none; flex: 1; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; padding: 24px 32px 40px; }
.korb.ist-leer .leer { display: flex; animation: herein var(--panel) var(--kurve) both; animation-name: aufleer; }
@keyframes aufleer { from { opacity: 0; translate: 0 10px; } }
.korb.ist-leer .rumpf, .korb.ist-leer .schwelle, .korb.ist-leer .fuss { display: none; }
.leer svg { width: 120px; height: 120px; margin-bottom: 6px; }
.leer h3 { font: 500 1.625rem/1.15 'Fraunces', Georgia, serif; font-variation-settings: 'SOFT' 100; }
.leer p { color: var(--leise); line-height: 1.5; max-width: 30ch; }
.leer .tipps { display: grid; gap: 8px; width: 100%; margin-top: 10px; }
/* Variante Hinweis: kurzer Hinweis am Warenkorb-Symbol */
.hinweis { position: fixed; top: 84px; right: 16px; margin: 0; inset: auto; width: min(360px, calc(100vw - 24px)); padding: 16px; border: 1px solid var(--linie-stark); border-radius: 8px; background: var(--papier); color: var(--tinte); box-shadow: 0 20px 50px rgba(41, 62, 58, .2);
opacity: 0; translate: 0 -8px; transition: opacity var(--ui) var(--kurve), translate var(--panel) var(--kurve), overlay var(--panel) allow-discrete, display var(--panel) allow-discrete; }
@supports (position-anchor: --a) { .hinweis { position-anchor: --korb; top: calc(anchor(bottom) + 10px); right: anchor(right); left: auto; bottom: auto; } }
.hinweis:popover-open { opacity: 1; translate: 0 0; }
@starting-style { .hinweis:popover-open { opacity: 0; translate: 0 -8px; } }
.hinweis .oben { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.hinweis .oben .label { display: flex; gap: 8px; align-items: center; color: var(--gruen); }
.hinweis .oben .label svg { width: 16px; height: 16px; }
.hinweis .zu { width: 34px; height: 34px; }
.hinweis .art-zeile { display: grid; grid-template-columns: 56px 1fr; gap: 12px; align-items: center; margin-bottom: 12px; }
.hinweis figure { width: 56px; height: 68px; border-radius: 4px; display: grid; place-items: center; background: var(--flaeche, #e6dfcc); }
.hinweis figure svg { width: 64%; }
.hinweis h3 { font: 500 1.125rem/1.2 'Fraunces', Georgia, serif; }
.hinweis .art { font-size: .875rem; color: var(--leise); }
.hinweis .schwelle { margin: 0 0 12px; }
.hinweis .knoepfe { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.hinweis .knoepfe button { height: 44px; border-radius: 999px; font-weight: 600; }
.hinweis .ansehen { border: 1px solid var(--tinte); }
.hinweis .zurkasse { background: var(--gruen); color: var(--papier); }
/* ---------- Demo-Pult ---------- */
.pult { position: fixed; left: 16px; bottom: 16px; z-index: 5; display: grid; gap: 8px; padding: 12px 14px; background: rgba(251, 248, 241, .95); border: 1px solid var(--linie-stark); border-radius: 8px; font: 600 .6875rem/1.45 'Commissioner', sans-serif; font-variation-settings: 'FLAR' 80; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); max-width: 330px; }
.pult .zeile-p { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; height: 26px; padding: 0 10px; border: 1px solid var(--linie-stark); border-radius: 99px; color: var(--tinte); }
.pult button[aria-pressed="true"] { background: var(--gruen); color: var(--papier); border-color: var(--gruen); }
.pult b { color: var(--tinte); }
.zeiger { position: fixed; left: 0; top: 0; z-index: 40; width: 22px; height: 22px; pointer-events: none; display: none; transition: transform 620ms var(--kurve), scale 150ms var(--kurve); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { scale: .82; }
.zeiger-ebene { position: fixed; inset: 0; z-index: 2147483000; pointer-events: none; }
/* ---------- Reduzierte Bewegung: Panel blendet nur ein ---------- */
.reduziert .korb { translate: 0 0; opacity: 0; transition: opacity 150ms linear, overlay 150ms allow-discrete, display 150ms allow-discrete; }
.reduziert .korb[open] { opacity: 1; }
@starting-style { .reduziert .korb[open] { opacity: 0; } }
.reduziert .korb::backdrop { transition-duration: 150ms; }
.reduziert .pos, .reduziert .bahn i, .reduziert .schwelle, .reduziert .zeiger { transition: none; }
.reduziert .pos.neu, .reduziert .zaehler.hupf, .reduziert .korb.ist-leer .leer { animation: none; }
.reduziert .rueck::after { animation-duration: 6s; }
.reduziert .hinweis { translate: none; transition: opacity 150ms linear, overlay 150ms allow-discrete, display 150ms allow-discrete; }
.reduziert .produkt figure svg { transition: none; }
/* ---------- Touch ---------- */
@media (hover: none) {
.produkt:hover figure svg { scale: 1; }
.korb-knopf:hover { background: none; color: var(--tinte); }
.legen:hover, .kasse:hover { background: var(--gruen); }
.mini:hover { border-color: var(--linie); background: none; }
.menge button { width: 44px; height: 42px; }
.menge { height: 44px; }
}
/* ---------- Schmal ---------- */
@media (max-width: 1100px) { .produkte { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) {
.laden { height: 64px; }
.laden nav, .marke small { display: none; }
.korb-knopf { padding: 0 12px; }
.korb-knopf .text { display: none; }
.korb { width: 100vw; box-shadow: none; }
.korb-kopf, .positionen, .dazu, .fuss { padding-left: 18px; padding-right: 18px; }
.fuss { gap: 4px; }
.schwelle { margin: 0 18px; }
.produkt h2 { font-size: 1.1875rem; }
.produkt .preis { flex-direction: column; gap: 2px; }
.legen { font-size: .9375rem; }
.legen .lang { display: none; }
.legen .kurz { display: inline; }
.dazu ul { grid-template-columns: 1fr; }
}
@media (max-height: 760px) {
.korb-kopf { padding-top: 14px; padding-bottom: 12px; }
.zeile { padding: 12px 0; }
.fuss { padding-top: 12px; gap: 4px; }
.fuss .weiter { display: none; }
.kasse { margin-top: 6px; height: 50px; }
}
/* ---------- Vorschau 960 × 720 ---------- */
.vorschau body { overflow: hidden; }
.vorschau .laden nav ul { gap: 20px; }
.vorschau .kopfzeile p { display: none; }
.vorschau .kopfzeile h1 { font-size: 3.25rem; }
</style>
</head>
<body>
<header class="laden">
<a class="marke" href="#laden" aria-label="Moselbohne, Startseite">
<svg viewBox="0 0 38 38" aria-hidden="true"><circle cx="19" cy="19" r="18" fill="#293e3a"/><path d="M19 8c-6 3-8 8-8 11s2 8 8 11c6-3 8-8 8-11s-2-8-8-11z" fill="#eac16e"/><path d="M19 9c2 5-2 8 0 11s-2 6 0 10" fill="none" stroke="#293e3a" stroke-width="2"/></svg>
<span><b>Moselbohne</b><small class="label">Rösterei & Hofladen · Koblenz-Güls</small></span>
</a>
<nav aria-label="Hauptnavigation"><ul><li><a href="#laden">Kaffee</a></li><li><a href="#laden">Zubehör</a></li><li><a href="#laden">Hofladen</a></li><li><a href="#laden">Rösttage</a></li></ul></nav>
<button class="korb-knopf" type="button" aria-haspopup="dialog" aria-controls="korb"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 6h14l-1.2 11H4.2z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M7 8V5.5a3 3 0 0 1 6 0V8" fill="none" stroke="currentColor" stroke-width="1.6"/></svg><span class="text">Warenkorb</span><span class="zaehler" aria-hidden="true">2</span><span class="sr korb-sr">, 2 Artikel</span></button>
</header>
<main class="schaufenster" id="laden">
<div class="kopfzeile">
<h1>Frisch geröstet <em>an der Mosel</em></h1>
<p>Wir rösten dienstags und freitags in Güls und schicken am selben Tag. <b>Versandkostenfrei ab 39 €.</b></p>
</div>
<ul class="produkte"></ul>
</main>
<dialog class="korb" id="korb" aria-labelledby="korb-titel" closedby="any">
<div class="innen">
<div class="korb-kopf">
<h2 id="korb-titel" tabindex="-1">Warenkorb<span class="anzahl"></span></h2>
<button class="zu" type="button" aria-label="Warenkorb schließen" data-zu><svg viewBox="0 0 18 18" aria-hidden="true"><path d="m3 3 12 12M15 3 3 15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button>
</div>
<div class="schwelle"></div>
<div class="rumpf">
<ul class="positionen" aria-label="Artikel im Warenkorb"></ul>
<div class="dazu"><p class="label">Passt dazu</p><ul></ul></div>
</div>
<div class="fuss">
<dl>
<dt>Zwischensumme</dt><dd class="zwischen"></dd>
<dt>Versand nach Deutschland</dt><dd class="versand"></dd>
<dt class="gesamt">Summe</dt><dd class="gesamt-wert serif" style="font-size:1.375rem;font-weight:500"></dd>
</dl>
<p class="mwst"></p>
<button class="kasse" type="button" data-kasse>Zur Kasse<svg viewBox="0 0 18 18" aria-hidden="true"><path d="M3 9h11M10 4.5 14.5 9 10 13.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
<button class="weiter" type="button" data-zu>Weiter einkaufen</button>
</div>
<div class="leer">
<svg viewBox="0 0 120 120" aria-hidden="true"><circle cx="60" cy="60" r="56" fill="#f2ecdc"/><path d="M34 52h44v18a22 22 0 0 1-44 0z" fill="#fbf8f1" stroke="#353839" stroke-width="2"/><path d="M78 56h6a8 8 0 0 1 0 16h-7" fill="none" stroke="#353839" stroke-width="2"/><path d="M28 92h64" stroke="#353839" stroke-width="2" stroke-linecap="round"/><path d="M48 44c-3-5 3-7 0-12M58 44c-3-5 3-7 0-12M68 44c-3-5 3-7 0-12" fill="none" stroke="#7f937a" stroke-width="2" stroke-linecap="round"/></svg>
<h3>Noch keine Bohne im Korb</h3>
<p>Frisch geröstet wird dienstags und freitags. Unsere zwei Lieblinge zum Anfangen:</p>
<div class="tipps"></div>
<button class="weiter" type="button" data-zu>Alle Kaffees ansehen</button>
</div>
</div>
<p class="sr" aria-live="polite" id="ansage"></p>
</dialog>
<div class="hinweis" popover="manual" id="hinweis" aria-labelledby="hinweis-titel">
<div class="oben"><p class="label" id="hinweis-titel"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m3 8.5 3 3 7-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>Im Warenkorb</p><button class="zu" type="button" aria-label="Hinweis schließen" data-hinweis-zu><svg viewBox="0 0 18 18" aria-hidden="true"><path d="m4 4 10 10M14 4 4 14" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button></div>
<div class="art-zeile"></div>
<div class="schwelle"></div>
<div class="knoepfe"><button class="ansehen" type="button" data-oeffnen>Warenkorb (2)</button><button class="zurkasse" type="button" data-kasse>Zur Kasse</button></div>
</div>
<div class="pult">
<div class="zeile-p">Variante
<button type="button" data-v="drawer" aria-pressed="true">Seitenpanel</button>
<button type="button" data-v="hinweis" aria-pressed="false">Hinweis am Symbol</button>
</div>
<div>expo.out <b>0.16, 1, 0.3, 1</b> · Panel <b>400</b> · UI <b>200</b> · Balken <b>500 ms</b></div>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, el = document) => el.querySelector(s), $$ = (s, el = document) => [...el.querySelectorAll(s)];
const geld = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
const eur = c => geld.format(c / 100);
/* ---------- Sortiment (Preise in Cent, inkl. MwSt.) ---------- */
const tuete = (farbe, name, art) => `<svg viewBox="0 0 120 150" aria-hidden="true"><path d="M20 30h80l6 112c-30 6-62 6-92 0z" fill="#c9a77c"/><path d="M20 30 14 142c8 2 16 3 24 3L34 30z" fill="#000" opacity=".06"/><path d="M20 30l9-16h62l9 16z" fill="#b48f63"/><path d="M29 14h62" stroke="#9c7a52" stroke-width="2"/><circle cx="60" cy="46" r="4.5" fill="none" stroke="#8f6f49" stroke-width="2"/><rect x="25" y="62" width="70" height="56" rx="2" fill="${farbe}"/><text x="60" y="87" text-anchor="middle" font-family="Fraunces, Georgia, serif" font-size="${name.length > 11 ? 9 : 10.5}" font-weight="600" fill="#fbf8f1">${name}</text><path d="M44 94h32" stroke="#fbf8f1" stroke-width=".8" opacity=".6"/><text x="60" y="106" text-anchor="middle" font-family="Commissioner, sans-serif" font-size="${art.length > 14 ? 4.3 : 5.2}" font-weight="600" letter-spacing=".8" fill="#fbf8f1" opacity=".92">${art}</text></svg>`;
const filter = `<svg viewBox="0 0 120 150" aria-hidden="true"><path d="M22 48h76l-18 54H40z" fill="#fbf8f1" stroke="#353839" stroke-width="2.5" stroke-linejoin="round"/><path d="M30 60h60" stroke="#e1a894" stroke-width="5"/><path d="M40 102h40v10H40z" fill="#fbf8f1" stroke="#353839" stroke-width="2.5"/><path d="M14 122h92" stroke="#353839" stroke-width="2.5" stroke-linecap="round"/><path d="M98 54c12 2 12 18 0 22" fill="none" stroke="#353839" stroke-width="2.5"/></svg>`;
const papier = `<svg viewBox="0 0 120 150" aria-hidden="true"><path d="M26 44h68l-14 60H40z" fill="#fbf8f1" stroke="#a89d82" stroke-width="2"/><path d="M30 38h68l-14 60H44z" fill="#f6f1e5" stroke="#a89d82" stroke-width="2"/><path d="M34 32h68l-14 60H48z" fill="#fff" stroke="#a89d82" stroke-width="2"/><path d="M46 86h40" stroke="#c9bfa6" stroke-width="2" stroke-dasharray="3 3"/></svg>`;
const P = {
festungsberg: { name: 'Festungsberg', art: 'Espresso · 250 g · ganze Bohne', noten: 'Kakao, Haselnuss, Rohrzucker', preis: 1390, grund: '55,60 €/kg', mwst: 7, flaeche: '#e6dccd', bild: tuete('#293e3a', 'Festungsberg', 'ESPRESSO · 250 G') },
rheinlauf: { name: 'Rheinlauf', art: 'Filterkaffee · 500 g · ganze Bohne', noten: 'Aprikose, Honig, Schwarztee', preis: 1670, grund: '33,40 €/kg', mwst: 7, flaeche: '#e4e8dc', bild: tuete('#7f937a', 'Rheinlauf', 'FILTER · 500 G') },
moselschleife: { name: 'Moselschleife', art: 'Hausmischung · 1 kg · ganze Bohne', noten: 'Nougat, Malz, wenig Säure', preis: 3200, grund: '32,00 €/kg', mwst: 7, flaeche: '#f1e3c3', bild: tuete('#a8742a', 'Moselschleife', 'HAUSMISCHUNG · 1 KG') },
spaetlese: { name: 'Spätlese', art: 'Entkoffeiniert · 250 g · ganze Bohne', noten: 'Karamell, Pflaume, mild', preis: 1250, grund: '50,00 €/kg', mwst: 7, flaeche: '#f0ddd4', bild: tuete('#9b5b49', 'Spätlese', 'ENTKOFFEINIERT · 250 G') },
papier: { name: 'Filterpapier Gr. 4', art: '100 Stück, ungebleicht', preis: 390, grund: '', mwst: 19, flaeche: '#ece6d6', bild: papier },
handfilter: { name: 'Handfilter Steinzeug', art: 'Westerwälder Ton, für Gr. 4', preis: 2400, grund: '', mwst: 19, flaeche: '#eadfd8', bild: filter },
};
const GRENZE = 3900, VERSAND = 490, MAX = 10;
const SCHAUFENSTER = ['festungsberg', 'rheinlauf', 'moselschleife', 'spaetlese'];
const START = [['festungsberg', 1], ['rheinlauf', 1]]; // 30,60 € → noch 8,40 € bis versandkostenfrei
/* ---------- Schaufenster ---------- */
$('.produkte').innerHTML = SCHAUFENSTER.map(id => { const p = P[id]; return `<li class="produkt" style="--flaeche:${p.flaeche}">
<figure>${p.bild}</figure>
<div><h2>${p.name}</h2><p class="noten">${p.art.split(' · ')[0]} · ${p.noten}</p></div>
<p class="preis"><b>${eur(p.preis)}</b><small>${p.art.split(' · ')[1]} · ${p.grund}</small></p>
<button class="legen" type="button" data-legen="${id}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg><span>In den <span class="lang">Warenkorb</span><span class="kurz">Korb</span></span><span class="sr">: ${p.name}</span></button>
</li>`; }).join('');
/* ---------- Zustand ---------- */
let korb = START.map(([id, menge]) => ({ id, menge }));
const startV = new URLSearchParams(location.search).get('v');
let variante = startV === 'hinweis' ? 'hinweis' : 'drawer';
const dialog = $('#korb'), liste = $('.positionen'), ansage = $('#ansage');
const summe = () => korb.reduce((s, x) => s + P[x.id].preis * x.menge, 0);
const stueck = () => korb.reduce((s, x) => s + x.menge, 0);
let ansageUhr;
const sage = t => { clearTimeout(ansageUhr); ansage.textContent = ''; ansageUhr = setTimeout(() => ansage.textContent = t, 60); };
/* Versandschwelle (Panel und Hinweis nutzen dieselbe Darstellung) */
function schwelle(el) {
const s = summe(), rest = Math.max(0, GRENZE - s), ok = rest === 0;
el.classList.toggle('geschafft', ok);
if (!el.firstChild) el.innerHTML = '<p></p><div class="bahn" aria-hidden="true"><i></i></div>';
$('p', el).innerHTML = (ok
? '<svg class="lkw" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="9" fill="currentColor"/><path d="m6 10.3 2.6 2.5L14 7.4" fill="none" stroke="#fbf8f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span><b>Versandkostenfrei.</b> Geht mit der nächsten Röstung raus.</span>'
: `<svg class="lkw" viewBox="0 0 20 20" aria-hidden="true"><path d="M1.5 5.5h10v8h-10zM11.5 8.5h4l3 3v2h-7z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><circle cx="5" cy="15" r="1.8" fill="currentColor"/><circle cx="15" cy="15" r="1.8" fill="currentColor"/></svg><span>Noch <b>${eur(rest)}</b> bis versandkostenfrei</span>`);
$('.bahn', el).style.setProperty('--anteil', Math.min(100, s / GRENZE * 100).toFixed(1) + '%');
}
/* Positionen */
function zeile(x, neu) {
const p = P[x.id], li = document.createElement('li');
li.className = 'pos' + (neu ? ' neu' : ''); li.dataset.id = x.id; li.style.setProperty('--flaeche', p.flaeche);
li.innerHTML = `<div><div class="zeile">
<figure>${p.bild}</figure>
<h3>${p.name}</h3>
<p class="summe"></p>
<p class="art">${p.art}${p.grund ? `<br>${eur(p.preis)} · ${p.grund}` : `<br>${eur(p.preis)} je Stück`}</p>
<div class="bedien">
<div class="menge" role="group" aria-label="Menge ${p.name}">
<button type="button" data-minus aria-label="Menge verringern, ${p.name}"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M3 7h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button>
<output aria-label="Menge"></output>
<button type="button" data-plus aria-label="Menge erhöhen, ${p.name}"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg></button>
</div>
<button class="entfernen" type="button" data-entfernen aria-label="${p.name} entfernen">Entfernen</button>
</div>
</div></div>`;
return li;
}
function aktualisiereZeile(li) {
const x = korb.find(k => k.id === li.dataset.id); if (!x) return;
$('output', li).textContent = x.menge;
$('.summe', li).textContent = eur(P[x.id].preis * x.menge);
$('[data-minus]', li).disabled = x.menge <= 1;
$('[data-plus]', li).disabled = x.menge >= MAX;
}
function zeichne() {
// fehlende Zeilen anlegen, vorhandene an Ort und Stelle aktualisieren (Fokus bleibt)
korb.forEach(x => { if (!$(`.pos[data-id="${x.id}"]:not(.weg)`, liste)) liste.append(zeile(x, dialog.open)); });
$$('.pos:not(.weg)', liste).forEach(aktualisiereZeile);
const s = summe(), n = stueck(), frei = s >= GRENZE || s === 0, versand = frei ? 0 : VERSAND, gesamt = s + versand;
$('.anzahl').textContent = n ? `(${n})` : '';
$('.zwischen').textContent = eur(s);
$('.versand').textContent = frei ? 'kostenlos' : eur(versand);
$('.gesamt-wert').textContent = eur(gesamt);
// MwSt. je Satz aus den Bruttopreisen; Versand anteilig nach Warenwert
const satz = {}; korb.forEach(x => { satz[P[x.id].mwst] = (satz[P[x.id].mwst] || 0) + P[x.id].preis * x.menge; });
const teile = Object.entries(satz).sort((a, b) => a[0] - b[0]).map(([r, brutto]) => { const anteil = brutto + (s ? versand * brutto / s : 0); return `${r} % MwSt. ${eur(Math.round(anteil - anteil / (1 + r / 100)))}`; });
$('.mwst').textContent = n ? `inkl. ${teile.join(' und ')}${versand ? ' (Versand anteilig)' : ''} · Lieferung in 2 bis 3 Werktagen` : '';
schwelle($('.korb .schwelle'));
dialog.classList.toggle('ist-leer', n === 0);
// Passt dazu: nur unter der Grenze, nur was noch nicht im Korb liegt
const vorschlag = ['papier', 'spaetlese', 'handfilter'].filter(id => !korb.some(k => k.id === id)).slice(0, 2);
$('.dazu').hidden = s >= GRENZE || !vorschlag.length || n === 0;
$('.dazu ul').innerHTML = vorschlag.map(id => `<li style="--flaeche:${P[id].flaeche}"><button class="mini" type="button" data-legen="${id}" data-still><figure>${P[id].bild}</figure><b>${P[id].name}</b><span>${eur(P[id].preis)}</span></button></li>`).join('');
$('.tipps').innerHTML = ['festungsberg', 'rheinlauf'].map(id => `<button class="mini" type="button" data-legen="${id}" data-still style="--flaeche:${P[id].flaeche}"><figure>${P[id].bild}</figure><b>${P[id].name}</b><span>${eur(P[id].preis)}</span></button>`).join('');
// Symbol im Kopf
const z = $('.zaehler'); z.textContent = n; $('.korb-sr').textContent = `, ${n} ${n === 1 ? 'Artikel' : 'Artikel'}`;
$('.hinweis [data-oeffnen]').textContent = `Warenkorb (${n})`;
}
/* Ändern, Hinzufügen, Entfernen mit Rückgängig */
function legen(id, still) {
const x = korb.find(k => k.id === id);
if (x) x.menge = Math.min(MAX, x.menge + 1); else korb.push({ id, menge: 1 });
zeichne();
const z = $('.zaehler'); z.classList.remove('hupf'); z.offsetWidth; z.classList.add('hupf');
sage(`${P[id].name} liegt im Warenkorb. ${schwellenText()}`);
if (still) return;
if (variante === 'hinweis' && !dialog.open) zeigeHinweis(id); else oeffnen();
}
const schwellenText = () => { const r = GRENZE - summe(); return r > 0 ? `Noch ${eur(r)} bis versandkostenfrei.` : 'Versandkostenfrei.'; };
function menge(id, d) {
const x = korb.find(k => k.id === id); if (!x) return;
x.menge = Math.max(1, Math.min(MAX, x.menge + d)); zeichne();
sage(`${P[id].name}: Menge ${x.menge}. Zwischensumme ${eur(summe())}. ${schwellenText()}`);
}
let rueckUhr = null;
function entfernen(id) {
const i = korb.findIndex(k => k.id === id); if (i < 0) return;
const [weg] = korb.splice(i, 1), li = $(`.pos[data-id="${id}"]:not(.weg)`, liste);
$$('.rueck', liste).forEach(r => r.closest('li').remove());
const r = document.createElement('li');
r.innerHTML = `<div class="rueck"><span>${P[id].name} entfernt.</span><button type="button" data-rueck>Rückgängig</button></div>`;
r.dataset.rueck = JSON.stringify({ ...weg, pos: i });
if (li) { li.classList.add('weg'); li.after(r); setTimeout(() => li.remove(), reduziert() ? 0 : 400); } else liste.prepend(r);
clearTimeout(rueckUhr); rueckUhr = setTimeout(() => r.remove(), 6000);
zeichne();
if (korb.length) { $('[data-rueck]', r).focus(); sage(`${P[id].name} entfernt. ${schwellenText()}`); }
else { r.remove(); sage('Der Warenkorb ist leer.'); $('#korb-titel').focus(); }
}
function rueckgaengig(btn) {
const li = btn.closest('li'), d = JSON.parse(li.dataset.rueck);
korb.splice(Math.min(d.pos, korb.length), 0, { id: d.id, menge: d.menge });
const neu = zeile(d, true); li.replaceWith(neu); clearTimeout(rueckUhr);
// Reihenfolge wie im Korb
korb.forEach(x => { const el = $(`.pos[data-id="${x.id}"]:not(.weg)`, liste); if (el) liste.append(el); });
zeichne(); $('[data-entfernen]', neu).focus();
sage(`${P[d.id].name} ist wieder im Warenkorb. ${schwellenText()}`);
}
/* Öffnen und Schließen: natives dialog, Fokus kommt zum Auslöser zurück */
function oeffnen() {
verbergeHinweis();
if (!dialog.open) { dialog.showModal(); $('#korb-titel').focus(); }
}
function schliessen() { if (dialog.open) dialog.close(); }
$('.korb-knopf').addEventListener('click', oeffnen);
dialog.addEventListener('click', e => {
if (e.target === dialog) { schliessen(); return; } // Klick auf den Hintergrund (Fallback ohne closedby)
if (e.target.closest('[data-zu]')) { schliessen(); return; }
const t = e.target;
if (t.closest('[data-plus]')) menge(t.closest('.pos').dataset.id, 1);
else if (t.closest('[data-minus]')) menge(t.closest('.pos').dataset.id, -1);
else if (t.closest('[data-entfernen]')) entfernen(t.closest('.pos').dataset.id);
else if (t.closest('[data-rueck]')) rueckgaengig(t.closest('[data-rueck]'));
else if (t.closest('[data-kasse]')) sage('Im Einsatz geht es hier zur Kasse des Shopsystems (in der Demo nicht verbunden).');
});
document.addEventListener('click', e => {
const b = e.target.closest('[data-legen]'); if (!b) return;
legen(b.dataset.legen, b.hasAttribute('data-still'));
});
/* Variante Hinweis: Popover am Warenkorb-Symbol */
const hinweis = $('#hinweis');
let hinweisUhr;
function zeigeHinweis(id) {
const p = P[id];
$('.art-zeile', hinweis).innerHTML = `<figure style="--flaeche:${p.flaeche}">${p.bild}</figure><div><h3>${p.name}</h3><p class="art">${p.art} · ${eur(p.preis)}</p></div>`;
schwelle($('.schwelle', hinweis));
if (!hinweis.matches(':popover-open')) hinweis.showPopover();
clearTimeout(hinweisUhr); hinweisUhr = setTimeout(verbergeHinweis, 6000);
}
function verbergeHinweis() { clearTimeout(hinweisUhr); if (hinweis.matches(':popover-open')) hinweis.hidePopover(); }
hinweis.addEventListener('pointerenter', () => clearTimeout(hinweisUhr));
hinweis.addEventListener('focusin', () => clearTimeout(hinweisUhr));
hinweis.addEventListener('click', e => {
if (e.target.closest('[data-hinweis-zu]')) verbergeHinweis();
if (e.target.closest('[data-oeffnen]')) oeffnen();
if (e.target.closest('[data-kasse]')) { sage('Im Einsatz geht es hier zur Kasse (in der Demo nicht verbunden).'); verbergeHinweis(); }
});
hinweis.addEventListener('keydown', e => { if (e.key === 'Escape') { verbergeHinweis(); $('.korb-knopf').focus(); } });
function setzeVariante(v, schreiben) {
variante = v === 'hinweis' ? 'hinweis' : 'drawer';
$$('.pult [data-v]').forEach(b => b.setAttribute('aria-pressed', b.dataset.v === variante));
if (schreiben) { const q = new URLSearchParams(location.search); q.set('v', variante); history.replaceState(null, '', '?' + q.toString().replace(/=(?=&|$)/g, '')); }
verbergeHinweis();
}
$$('.pult [data-v]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.v, true)));
/* ---------- Vorführung ---------- */
// Der Zeiger liegt in einer eigenen Top-Layer-Ebene, damit er auch über dem modalen Panel sichtbar ist
const ebene = document.createElement('div'); ebene.className = 'zeiger-ebene'; ebene.setAttribute('popover', 'manual'); ebene.style.cssText = 'margin:0;border:0;padding:0;background:none;width:100vw;height:100vh;overflow:visible';
ebene.innerHTML = '<svg class="zeiger" viewBox="0 0 22 22" aria-hidden="true"><path d="M3 2l15 8.2-6.6 1.6L8.6 19z" fill="#353839" stroke="#fbf8f1" stroke-width="1.5" stroke-linejoin="round"/></svg>';
document.body.append(ebene);
const zeiger = $('.zeiger', ebene);
const ebeneOben = () => { if (!html.classList.contains('vorfuehrung')) return; try { ebene.hidePopover(); ebene.showPopover(); } catch (e) {} };
let lauf = 0, angehalten = false;
const warte = async (ms, id) => { let rest = ms; while (rest > 0) { await new Promise(r => setTimeout(r, 50)); if (id !== lauf) throw 0; if (!angehalten) rest -= 50; } };
async function geheZu(el, id) {
if (typeof el === 'string') el = $(el); if (!el) return null;
const r = el.getBoundingClientRect();
if (!dialog.open && (r.top < 80 || r.bottom > innerHeight)) { el.scrollIntoView({ block: 'center', behavior: reduziert() ? 'auto' : 'smooth' }); await warte(500, id); }
const q = el.getBoundingClientRect();
zeiger.style.transform = `translate(${q.left + q.width * .5}px, ${q.top + q.height * .55}px)`;
await warte(reduziert() ? 200 : 700, id); return el;
}
async function klick(el, id) {
el = await geheZu(el, id); if (!el) return;
zeiger.classList.add('drueckt'); el.classList.add('drueckt'); await warte(150, id);
zeiger.classList.remove('drueckt'); el.classList.remove('drueckt');
el.click(); ebeneOben();
}
function grundzustand() {
schliessen(); verbergeHinweis(); clearTimeout(rueckUhr); scrollTo(0, 0);
korb = START.map(([id, menge]) => ({ id, menge }));
liste.innerHTML = ''; zeichne();
}
async function szene(id) {
grundzustand();
zeiger.style.transform = `translate(${innerWidth * .55}px, ${innerHeight * .55}px)`;
await warte(300, id);
if (variante === 'hinweis') {
await klick('[data-legen="spaetlese"]', id); await warte(2200, id);
await klick('#hinweis [data-oeffnen]', id); await warte(1600, id);
await klick('.pos[data-id="spaetlese"] [data-minus]', id); await warte(300, id);
await klick('.pos[data-id="rheinlauf"] [data-entfernen]', id); await warte(1500, id);
await klick('[data-rueck]', id); await warte(1300, id);
} else {
await klick('.korb-knopf', id); await warte(1100, id);
await klick('.dazu [data-legen="papier"]', id); await warte(1300, id);
await klick('.pos[data-id="festungsberg"] [data-plus]', id); await warte(1500, id);
await klick('.pos[data-id="rheinlauf"] [data-entfernen]', id); await warte(1500, id);
await klick('[data-rueck]', id); await warte(1300, id);
for (const p of ['papier', 'rheinlauf', 'festungsberg']) { await klick(`.pos[data-id="${p}"]:not(.weg) [data-entfernen]`, id); await warte(450, id); }
await warte(1400, id);
}
await klick('.korb [data-zu].zu', id); await warte(900, id);
}
function vorfuehren() {
const id = ++lauf; angehalten = false; html.classList.add('vorfuehrung');
try { ebene.showPopover(); } catch (e) {}
(async () => { try { while (id === lauf) await szene(id); } catch (e) { /* abgebrochen */ } })();
}
function pause() { angehalten = true; }
function weiter() { if (html.classList.contains('vorfuehrung')) angehalten = false; else vorfuehren(); }
function neustart() { lauf++; angehalten = false; html.classList.remove('vorfuehrung'); try { ebene.hidePopover(); } catch (e) {} grundzustand(); }
zeichne();
setzeVariante(variante, false);
})();
</script>
</body>
</html>