Mengen-Stepper mit Einheit und Summe
Mengenwahl mit Minus, Eingabe und Plus: Ziffer rollt in Richtung der Änderung, Einheit mit Plural (Laib, Laibe, kg), sichtbare Grenzen, Rundung auf den Schritt, Gedrückt-Halten und mitlaufende Summe; dazu Tickets mit gemeinsamer Kapazität und Warenkorb mit Papierkorb bei 1.
Bäckerei, Metzgerei, Hofladen, Feinkost: Stück, Laib und Gewicht in einer Vorbestellung, Grundpreis und Circa-Summe.
Mengenwahl mit Minus, Eingabefeld und Plus. Die Ziffer rollt in die Richtung der Änderung, die Einheit passt sich an („1 Laib“, „2 Laibe“, „0,75 kg“). Grenzen sind sichtbar: Am Minimum und Maximum ist der Knopf gesperrt und ein Satz sagt warum, bei Gewicht wird auf den Schritt gerundet und das gemeldet. Gedrückt halten zählt schneller. Darunter läuft die Summe mit. Drei Rollen: Theke (Stück, Laib, kg), Tickets mit gemeinsamer Kapazität und Regeln, kompakt im Warenkorb mit Papierkorb bei 1.
Mengen sind der häufigste Klick in Bestellstrecken. Ein Stepper, der Einheit, Grenze und Preis im selben Blick zeigt, erspart Rückfragen („Wie viel ist 0,5?“, „Warum geht nicht mehr?“). Die rollende Ziffer macht die Richtung spürbar, ohne zu verzögern. Im Bestand lösen Vorbestellung, Warenkorb, Kaufbox, Veranstaltung und Buchungsleiste das je selbst; ein gemeinsamer Baustein hält Verhalten und Barrierefreiheit gleich.
Wann einsetzen
Vorbestellung in Bäckerei und Metzgerei, Hofladen, Shop und Warenkorb, Tickets und Zeitfenster (Erwachsene, Kinder), Personen bei Tisch- und Zimmerbuchung, Gutscheinmengen.
Wann nicht
Für große oder freie Mengen (über etwa 50) ist ein Zahlfeld mit Auswahlliste besser. Nicht für Werte ohne feste Schritte (Freitext-Betrag). Keine erfundenen Knappheitszahlen: Kapazitäten kommen aus dem System des Betriebs.
Varianten
- Theke: Stück, Laib und Gewicht mit Schritt 0,25 kg, Grundpreis je kg, Summe ca.
- Tickets: mehrere Stepper teilen sich die freien Plätze eines Zeitfensters, Kinder nur mit Erwachsenen
- Kompakt: kleiner Stepper in der Warenkorbzeile, bei Menge 1 wird Minus zum Entfernen mit Rückgängig
Umsetzung
Je Stepper eine Gruppe (role=group mit aria-labelledby auf den Produktnamen): Knopf „weniger“, ein <input type=text inputmode=decimal role=spinbutton> mit aria-valuemin, aria-valuemax, aria-valuenow und aria-valuetext („2 Laibe“), Knopf „mehr“. Tasten nach WAI-APG: Pfeile ±Schritt, Bild auf und ab ±großer Schritt, Pos1 und Ende. Eingaben mit Komma werden beim Verlassen geparst, auf den Schritt gerundet und begrenzt; die Rundung steht als Hinweis unter dem Feld (aria-describedby). Am Minimum und Maximum aria-disabled am Knopf (bleibt fokussierbar) plus Satz mit Grund. Gedrückt halten: nach 400 ms Wiederholung, Abstand sinkt bis 60 ms. Ziffernwechsel: ein Overlay (aria-hidden) mit alter und neuer Zahl in einer Maske von 1lh, translateY je nach Richtung, das echte Feld ist währenddessen transparent. Einheit über Intl.PluralRules('de-DE') und eine Tabelle (Laib/Laibe, Stück/Stück, Person/Personen), Gewicht mit Intl.NumberFormat('de-DE', {minimumFractionDigits: 2}). Summe mit Intl.NumberFormat als Währung, Live-Ansage gebündelt nach 500 ms Ruhe. Bei Tickets begrenzt die Restkapazität alle Plus-Knöpfe gemeinsam.
Robust umsetzen
Telefon. Knöpfe 44 px, Feld mindestens 3 Zeichen breit, inputmode=decimal öffnet die Zifferntastatur mit Komma. Summe und Weiter in einer festen Leiste unten. Gedrückt halten funktioniert auch per Touch (pointerdown), Kontextmenü und Textauswahl sind auf den Knöpfen aus.
Ohne Bewegung. Ziffern wechseln ohne Rollen, Knöpfe federn nicht. Hinweise erscheinen ohne Versatz.
Barrierefrei. Spinbutton nach WAI-APG mit aria-valuetext inklusive Einheit. Knöpfe benannt („Roggenmisch: einen Laib weniger“). Gesperrte Knöpfe mit aria-disabled statt disabled, damit der Fokus nicht verloren geht. Grenze und Rundung als Text am Feld, Summe in einer role=status-Zeile mit gebündelter Ansage. Kontrast der Knöpfe 3 : 1 zur Fläche, Text 4,5 : 1.
Code
<!DOCTYPE html>
<!--
Mengen-Stepper mit Einheit und Summe
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/mengen-stepper/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-v="theke">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mengen-Stepper mit Einheit und Summe · 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: 'Shrikhand'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/shrikhand@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Onest'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/onest:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Anybody'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/anybody:vf@latest/latin-wdth-normal.woff2') format('woff'); font-weight: 100 900; font-stretch: 50% 150%; font-display: swap; }
@font-face { font-family: 'TikTok Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/tiktok-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 300 900; font-stretch: 75% 150%; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
/* Theke und Kompakt: Rezept lebensmittelhandwerk-theke (Mehl, Kruste, Ofen, Krustenrot) */
:root {
--grund: #F7F1E6; --text: #241B14; --flaeche: #EBD8BB; --karte: #FBF7EF; --dunkel: #2B211B; --signal: #8A3C12; --signal-hover: #6f2f0c;
--leise: #665746; --linie: #DDCDB4; --feld: #8c7a66; --fehler: #B3261E; --ok: #2f6b3a;
--kopf: 'Shrikhand', Georgia, serif; --kopf-gewicht: 400; --kopf-breite: 100%; --kopf-lauf: -0.01em;
--schrift: 'Onest', system-ui, sans-serif;
--r-klein: 6px; --r-gross: 16px;
--ui-ein: cubic-bezier(0.16, 1, 0.3, 1); --ui-aus: cubic-bezier(0.7, 0, 0.84, 0);
--feder: cubic-bezier(0.34, 1.56, 0.64, 1); /* nur Knopfdruck */
--t-ziffer: 300ms; --t-feder: 400ms; --t-ui: 150ms; --t-zu: 200ms;
}
/* Tickets: Rezept freizeit-erlebnis-ticket (Hallengrau, Matte, Griff-Magenta) */
:root[data-v="tickets"] {
--grund: #F4F3EF; --text: #17161A; --flaeche: #E6E3DA; --karte: #FAF9F6; --dunkel: #19181D; --signal: #AE1650; --signal-hover: #8f1142;
--leise: #5B595F; --linie: #D3D0C7; --feld: #8a8790;
--kopf: 'Anybody', 'Arial Narrow', sans-serif; --kopf-gewicht: 850; --kopf-breite: 72%; --kopf-lauf: -0.02em;
--schrift: 'TikTok Sans', system-ui, sans-serif;
--r-klein: 8px; --r-gross: 20px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: var(--schrift); -webkit-text-size-adjust: 100%; transition: background-color var(--t-zu) var(--ui-aus); }
body { min-height: 100svh; font-size: 1.0625rem; line-height: 1.6; }
button, input { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root:has(:focus-visible) { scroll-behavior: auto; }
a { color: inherit; text-underline-offset: .2em; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.ziffern { font-variant-numeric: tabular-nums; }
[data-nur] { display: none !important; }
:root[data-v="theke"] [data-nur~="theke"], :root[data-v="tickets"] [data-nur~="tickets"], :root[data-v="kompakt"] [data-nur~="kompakt"] { display: revert !important; }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding: 16px clamp(16px, 4vw, 48px); }
.marke { font-family: var(--kopf); font-weight: var(--kopf-gewicht); font-stretch: var(--kopf-breite); font-size: 1.75rem; line-height: 1; letter-spacing: var(--kopf-lauf); text-decoration: none; }
.kopf small { font-size: .875rem; color: var(--leise); }
.offen { display: inline-flex; align-items: center; gap: 8px; font-size: .875rem; font-weight: 500; }
.offen::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); }
main { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(28px, 5vw, 80px); align-items: start; padding: clamp(24px, 4vw, 56px) clamp(16px, 4vw, 48px) clamp(96px, 8vw, 120px); max-width: 1320px; margin: 0 auto; }
.einleitung { position: sticky; top: 24px; }
.rubrik { font-size: .875rem; font-weight: 600; color: var(--leise); margin-bottom: 14px; }
h1 { font-family: var(--kopf); font-weight: var(--kopf-gewicht); font-stretch: var(--kopf-breite); font-size: clamp(2.25rem, 1.3rem + 2.6vw, 3.4rem); line-height: 1.04; letter-spacing: var(--kopf-lauf); text-wrap: balance; margin-bottom: 20px; }
:root[data-v="tickets"] h1 { line-height: .95; text-transform: none; }
.einleitung p { max-width: 38ch; text-wrap: pretty; }
.fakten { list-style: none; display: grid; gap: 0; margin-top: 28px; border-top: 1px solid var(--linie); max-width: 420px; }
.fakten li { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--linie); font-size: .9375rem; }
.fakten li span:first-child { color: var(--leise); }
.fakten li span:last-child { font-weight: 600; text-align: right; }
/* Bestellzettel */
.zettel { background: var(--flaeche); border-radius: var(--r-gross); padding: clamp(18px, 2.4vw, 32px); container: zettel / inline-size; }
.zettel h2 { font-size: 1.125rem; font-weight: 650; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.zettel h2 small { font-size: .875rem; font-weight: 500; color: var(--leise); }
.posten { list-style: none; margin-top: 10px; }
.posten > li { display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; grid-template-areas: "bild text menge" "bild hint hint"; gap: 4px 16px; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--linie); }
.posten > li:last-child { border-bottom: 0; }
.posten .zeichnung { grid-area: bild; width: 52px; height: 52px; align-self: start; }
.posten .was { grid-area: text; min-width: 0; }
.posten h3 { font-size: 1.0625rem; font-weight: 600; line-height: 1.3; }
.posten .meta { font-size: .875rem; color: var(--leise); line-height: 1.45; }
.menge { grid-area: menge; display: flex; align-items: center; gap: 14px; justify-self: end; }
.zeile-summe { min-width: 5.6em; text-align: right; font-weight: 600; font-variant-numeric: tabular-nums; }
.zeile-summe.null { color: var(--leise); font-weight: 400; }
.hint { grid-area: hint; justify-self: end; text-align: right; font-size: .875rem; line-height: 1.4; color: var(--leise); min-height: 0; max-width: 42ch; }
.hint:empty { display: none; }
.hint.warn { color: var(--text); }
.hint.fehler { color: var(--fehler); }
.hint.neu { animation: hint-ein var(--t-ziffer) var(--ui-ein); }
@keyframes hint-ein { from { opacity: 0; translate: 0 -4px; } }
/* Der Stepper */
.stepper { display: inline-grid; grid-template-columns: 44px 4.6ch 44px; align-items: center; height: 44px; border-radius: 999px; background: var(--karte); box-shadow: inset 0 0 0 1px var(--feld); }
.stepper button { width: 44px; height: 44px; display: grid; place-items: center; border: 0; background: none; border-radius: 50%; cursor: pointer; touch-action: manipulation; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; transition: background-color var(--t-ui) var(--ui-ein), color var(--t-ui) var(--ui-ein); }
.stepper button svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; }
@media (hover: hover) { .stepper button:not([aria-disabled="true"]):hover { background: color-mix(in srgb, var(--text) 9%, transparent); } }
.stepper button[aria-disabled="true"] { color: color-mix(in srgb, var(--text) 38%, transparent); cursor: not-allowed; }
.stepper button:focus-visible { outline-offset: -2px; }
.zahl { position: relative; height: 44px; display: grid; place-items: center; overflow: hidden; }
.zahl input { width: 100%; min-width: 3.2ch; height: 44px; border: 0; background: none; text-align: center; font-weight: 650; font-size: 1.0625rem; font-variant-numeric: tabular-nums; padding: 0 2px; }
.zahl input:focus-visible { outline: 2px solid var(--text); outline-offset: -1px; border-radius: 4px; }
.zahl input[aria-invalid="true"] { color: var(--fehler); }
.rolle { position: absolute; inset: 0; pointer-events: none; display: grid; place-items: center; font-weight: 650; font-size: 1.0625rem; font-variant-numeric: tabular-nums; line-height: 44px; }
.rolle span { grid-area: 1 / 1; }
.zahl.rollt input { color: transparent; }
.einheit { font-size: .875rem; color: var(--leise); min-width: 5em; }
/* Summe */
.summe { display: flex; align-items: center; justify-content: space-between; gap: 12px 20px; flex-wrap: wrap; margin-top: 8px; padding-top: 18px; border-top: 2px solid var(--text); }
.summe .betrag { font-size: 1.5rem; font-weight: 700; line-height: 1.1; }
.summe .betrag small { display: block; font-size: .875rem; font-weight: 500; color: var(--leise); }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; padding: 0 24px; border-radius: 999px; border: 0; background: var(--signal); color: #fff; font-weight: 600; cursor: pointer; text-decoration: none; transition: background-color var(--t-ui) var(--ui-ein); }
.knopf:hover { background: var(--signal-hover); }
.knopf[aria-disabled="true"] { background: color-mix(in srgb, var(--text) 22%, var(--flaeche)); color: var(--text); cursor: not-allowed; }
.kleingedruckt { margin-top: 14px; font-size: .8125rem; color: var(--leise); line-height: 1.5; }
.regel { margin-top: 14px; padding: 12px 14px; border-radius: var(--r-klein); background: var(--karte); font-size: .9375rem; line-height: 1.45; display: flex; gap: 10px; }
.regel svg { flex: none; width: 18px; height: 18px; margin-top: 2px; }
.regel.fehler { color: var(--fehler); box-shadow: inset 3px 0 0 var(--fehler); }
.regel.warn { box-shadow: inset 3px 0 0 var(--text); }
.regel:empty, .regel[hidden] { display: none; }
/* Tickets: Plätze als Reihe */
.plaetze { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin-top: 6px; }
.sitze { display: flex; gap: 6px; }
.sitze i { width: 22px; height: 22px; border-radius: 7px; box-shadow: inset 0 0 0 1.5px var(--feld); background: var(--karte); transition: background-color var(--t-ui) var(--ui-ein), box-shadow var(--t-ui) var(--ui-ein); }
.sitze i.belegt { background: var(--signal); box-shadow: none; animation: sitz var(--t-feder) var(--feder); }
@keyframes sitz { from { scale: .6; } }
.plaetze span { font-size: .9375rem; font-weight: 600; }
/* Kompakt: Warenkorbzeilen */
.korb { list-style: none; margin-top: 8px; }
.korb > li { display: grid; grid-template-rows: 1fr; transition: grid-template-rows var(--t-zu) var(--ui-aus), opacity var(--t-zu) var(--ui-aus); }
.korb > li.weg { grid-template-rows: 0fr; opacity: 0; }
.korb > li > div { overflow: hidden; min-height: 0; }
.korb .zeile { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; grid-template-areas: "bild text preis" "bild menge preis"; gap: 6px 14px; padding: 14px 0; border-bottom: 1px solid var(--linie); align-items: center; }
.korb .zeichnung { grid-area: bild; width: 44px; height: 44px; align-self: start; }
.korb h3 { grid-area: text; font-size: 1rem; font-weight: 600; line-height: 1.3; }
.korb h3 small { display: block; font-size: .8125rem; color: var(--leise); font-weight: 400; }
.korb .menge { grid-area: menge; justify-self: start; gap: 10px; }
.korb .zeile-summe { grid-area: preis; align-self: start; }
.korb .stepper { grid-template-columns: 34px 3.6ch 34px; height: 34px; }
.korb .stepper button { width: 34px; height: 34px; }
.korb .zahl, .korb .zahl input { height: 34px; }
.korb .rolle { line-height: 34px; font-size: 1rem; }
.korb .zahl input { font-size: 1rem; }
.stepper button.entfernen { color: var(--fehler); }
.leer { padding: 28px 0 12px; text-align: center; }
.leer[hidden] { display: none; }
.leer p { color: var(--leise); margin-bottom: 14px; }
.toast { position: fixed; left: 50%; bottom: 24px; translate: -50% 0; z-index: 30; width: max-content; max-width: calc(100vw - 32px); display: flex; align-items: center; gap: 16px; padding: 10px 10px 10px 18px; border-radius: 999px; background: var(--dunkel); color: #fff; font-size: .9375rem; box-shadow: 0 10px 30px -12px rgba(0,0,0,.45); transition: opacity var(--t-ziffer) var(--ui-ein), translate var(--t-ziffer) var(--ui-ein); }
.toast[hidden] { display: flex; opacity: 0; translate: -50% 12px; pointer-events: none; visibility: hidden; transition: opacity var(--t-zu) var(--ui-aus), translate var(--t-zu) var(--ui-aus), visibility 0s var(--t-zu); }
.toast button { border: 0; background: var(--grund); color: var(--text); border-radius: 999px; padding: 8px 14px; font-weight: 600; cursor: pointer; }
/* Feste Leiste unten (Telefon) */
.leiste { display: none; }
/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 40; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 5px; background: rgba(36, 27, 20, .92); color: #f7f1e6; border-radius: 999px; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.pult span { padding: 0 8px 0 10px; opacity: .75; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; background: none; border: 0; border-radius: 999px; padding: 9px 12px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #f7f1e6; color: #241b14; }
.pult button:focus-visible { outline: 2px solid #f0c27a; outline-offset: 1px; }
@media (max-width: 900px) {
main { grid-template-columns: minmax(0, 1fr); }
.einleitung { position: static; }
.fakten { display: none; }
}
/* Schmaler Zettel (Vorschau, Tablet, Telefon): Menge unter den Namen */
@container zettel (max-width: 520px) {
.posten > li { grid-template-columns: 44px minmax(0, 1fr); grid-template-areas: "bild text" "bild menge" "bild hint"; }
.posten .zeichnung { width: 44px; height: 44px; }
.menge { justify-self: stretch; margin-top: 6px; } .zeile-summe { margin-left: auto; }
.hint { justify-self: start; text-align: left; }
}
@media (max-width: 640px) {
.posten > li { grid-template-columns: 44px minmax(0, 1fr); grid-template-areas: "bild text" "bild menge" "bild hint"; }
.posten .zeichnung { width: 44px; height: 44px; }
.menge { justify-self: start; margin-top: 6px; }
.hint { justify-self: start; text-align: left; }
.zettel { padding: 16px 14px; }
.menge { justify-self: stretch; } .zeile-summe { margin-left: auto; }
.summe .knopf { display: none; }
.leiste { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); background: var(--grund); border-top: 1px solid var(--linie); }
.leiste b { font-size: 1.125rem; font-variant-numeric: tabular-nums; display: block; line-height: 1.2; }
.leiste small { font-size: .8125rem; color: var(--leise); }
.pult { bottom: 76px; }
.toast { bottom: 84px; }
}
@media (max-width: 360px) {
.menge { flex-wrap: wrap; gap: 8px; }
.zeile-summe { min-width: 0; }
}
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .kleingedruckt, .vorschau .leiste { display: none; }
.vorschau main { padding-top: 36px; }
:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
.stepper { border: 1px solid ButtonText; }
.sitze i.belegt { background: Highlight; }
}
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#" data-marke>Feldkorn</a>
<small data-nur="theke kompakt">Bäckerei in Neuwied · Heddesdorf und Engers</small>
<small data-nur="tickets">Boulderhalle Hangwerk · Neuwied, Hafenstraße</small>
<span class="offen" data-offen>Heute bis 18 Uhr geöffnet</span>
</header>
<main>
<section class="einleitung">
<!-- Theke -->
<div data-nur="theke">
<p class="rubrik">Vorbestellen für Samstag</p>
<h1>Was legen wir Ihnen zurück?</h1>
<p>Bis Freitag 18 Uhr bestellen, Samstag ab 7 Uhr in Ihrer Filiale abholen. Bezahlt wird an der Theke.</p>
<ul class="fakten">
<li><span>Abholung</span><span>Samstag ab 7:00 Uhr</span></li>
<li><span>Filiale</span><span>Heddesdorf, Marktplatz</span></li>
<li><span>Bestellschluss</span><span>Freitag, 18:00 Uhr</span></li>
</ul>
</div>
<!-- Tickets -->
<div data-nur="tickets">
<p class="rubrik">Zeitfenster Samstag, 14:30 bis 16:00 Uhr</p>
<h1>Wer kommt mit an die Wand?</h1>
<p>90 Minuten Bouldern, Leihschuhe vor Ort. Kinder unter 12 klettern mit einer erwachsenen Begleitung.</p>
<ul class="fakten">
<li><span>Datum</span><span>Samstag, 14:30 Uhr</span></li>
<li><span>Dauer</span><span>90 Minuten</span></li>
<li><span>Plätze im Fenster</span><span data-frei-text>noch 6</span></li>
</ul>
</div>
<!-- Kompakt -->
<div data-nur="kompakt">
<p class="rubrik">Abholkorb</p>
<h1>Ihr Korb für Samstag</h1>
<p>Mengen direkt in der Zeile ändern. Bei einem Stück wird aus Minus der Papierkorb, und ein Fehlgriff lässt sich rückgängig machen.</p>
</div>
</section>
<section class="zettel" aria-labelledby="zettel-titel">
<h2 id="zettel-titel"><span data-zettel-titel>Ihre Vorbestellung</span> <small data-positionen>0 Positionen</small></h2>
<div class="plaetze" data-nur="tickets"><div class="sitze" aria-hidden="true" data-sitze></div><span data-frei>6 von 6 Plätzen frei</span></div>
<ul class="posten" data-posten></ul>
<ul class="korb" data-korb></ul>
<div class="leer" data-leer hidden><p>Ihr Korb ist leer.</p><a class="knopf" href="#">Zum Sortiment</a></div>
<p class="regel" data-regel hidden></p>
<div class="summe">
<p class="betrag"><span class="vh">Summe: </span><span data-summe>0,00 €</span><small data-summe-zusatz>Endpreis inkl. MwSt.</small></p>
<a class="knopf" href="#" data-weiter>Vorbestellen</a>
</div>
<p class="kleingedruckt" data-klein></p>
</section>
</main>
<div class="leiste" aria-hidden="true"><div><b data-leiste-summe>0,00 €</b><small data-leiste-pos>0 Positionen</small></div><a class="knopf" href="#" tabindex="-1" data-leiste-knopf>Vorbestellen</a></div>
<div class="toast" role="status" data-toast hidden><span data-toast-text></span><button type="button" data-rueckgaengig>Rückgängig</button></div>
<p class="vh" role="status" aria-live="polite" data-ansage></p>
<div class="pult" role="group" aria-label="Variante der Demo">
<span>Variante</span>
<button type="button" data-wahl="theke" aria-pressed="true">Theke</button>
<button type="button" data-wahl="tickets" aria-pressed="false">Tickets</button>
<button type="button" data-wahl="kompakt" aria-pressed="false">Kompakt</button>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
const regeln = new Intl.PluralRules('de-DE');
const EINHEIT = { laib: ['Laib', 'Laibe'], stueck: ['Stück', 'Stück'], kg: ['kg', 'kg'], person: ['Person', 'Personen'], kind: ['Kind', 'Kinder'] };
const ZEICHNUNG = {
laib: '<path d="M8 33c0-11 9-19 24-19s24 8 24 19c0 6-5 9-24 9S8 39 8 33Z" fill="#E2BE8A"/><path d="M8 33c0-11 9-19 24-19s24 8 24 19c0 6-5 9-24 9S8 39 8 33Z"/><path d="M19 22l5 10M29 19l4 12M39 20l3 11"/>',
broetchen: '<ellipse cx="32" cy="32" rx="18" ry="13" fill="#E2BE8A"/><ellipse cx="32" cy="32" rx="18" ry="13"/><path d="M32 19c-3 6-3 20 0 26"/>',
blech: '<path d="M10 22h44v20H10z" fill="#EFD39A"/><path d="M10 22h44v20H10z"/><path d="M10 27h44"/><circle cx="19" cy="34" r="1.6"/><circle cx="28" cy="37" r="1.6"/><circle cx="37" cy="33" r="1.6"/><circle cx="46" cy="37" r="1.6"/>',
zwiebel: '<path d="M12 42 32 14l20 28Z" fill="#EFD39A"/><path d="M12 42 32 14l20 28Z"/><path d="M12 42h40"/><path d="M27 32c2-2 4-2 6 0M33 38c2-2 4-2 6 0M24 39c2-2 4-2 6 0"/>',
person: '<circle cx="32" cy="20" r="7" fill="#F2B8CB"/><circle cx="32" cy="20" r="7"/><path d="M18 46c1-9 7-14 14-14s13 5 14 14"/>',
jugend: '<circle cx="32" cy="22" r="6" fill="#F2B8CB"/><circle cx="32" cy="22" r="6"/><path d="M20 46c1-8 6-12 12-12s11 4 12 12"/>',
kind: '<circle cx="32" cy="26" r="5" fill="#F2B8CB"/><circle cx="32" cy="26" r="5"/><path d="M23 46c1-6 4-9 9-9s8 3 9 9"/>',
klein: '<circle cx="32" cy="30" r="4" fill="#F2B8CB"/><circle cx="32" cy="30" r="4"/><path d="M26 46c0-5 3-7 6-7s6 2 6 7"/>'
};
const zeichnung = k => `<svg class="zeichnung" viewBox="0 0 64 64" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${ZEICHNUNG[k]}</svg>`;
const DATEN = {
theke: {
marke: 'Feldkorn', titel: 'Ihre Vorbestellung', weiter: 'Vorbestellen',
klein: 'Alle Preise inkl. MwSt. Gewichtsware wird beim Abholen gewogen, der Endpreis kann leicht abweichen. Mehr als die Höchstmenge? Rufen Sie uns an: 02631 000 000.',
posten: [
{ id: 'roggen', name: 'Roggenmisch-Kruste', meta: '1000 g · 4,90 € je Laib', bild: 'laib', einheit: 'laib', preis: 4.9, min: 0, max: 6, step: 1, gross: 2, start: 0 },
{ id: 'seele', name: 'Dinkel-Seele', meta: '0,85 € je Stück', bild: 'broetchen', einheit: 'stueck', preis: .85, min: 0, max: 40, step: 1, gross: 10, start: 0 },
{ id: 'butter', name: 'Butterkuchen vom Blech', meta: '2,20 € je Stück', bild: 'blech', einheit: 'stueck', preis: 2.2, min: 0, max: 24, step: 1, gross: 6, start: 0 },
{ id: 'zwiebel', name: 'Zwiebelkuchen, nach Gewicht', meta: '1,90 € je 100 g · Grundpreis 19,00 € je kg', bild: 'zwiebel', einheit: 'kg', preis: 19, min: 0, max: 3, step: .25, gross: 1, start: 0, gewicht: true }
]
},
tickets: {
marke: 'Hangwerk', titel: 'Tickets für 14:30 Uhr', weiter: 'Weiter zur Buchung', kapazitaet: 6,
klein: 'Preise inkl. MwSt. für 90 Minuten. Leihschuhe 3,50 € vor Ort. Die freien Plätze kommen in echt aus dem Buchungssystem.',
posten: [
{ id: 'erw', name: 'Erwachsene', meta: 'ab 18 Jahren · 16,00 €', bild: 'person', einheit: 'person', preis: 16, min: 0, max: 6, step: 1, gross: 2, start: 0 },
{ id: 'jug', name: 'Jugendliche', meta: '12 bis 17 Jahre · 13,00 €', bild: 'jugend', einheit: 'person', preis: 13, min: 0, max: 6, step: 1, gross: 2, start: 0 },
{ id: 'kind', name: 'Kinder', meta: '6 bis 11 Jahre · 9,00 €', bild: 'kind', einheit: 'kind', preis: 9, min: 0, max: 6, step: 1, gross: 2, start: 0, begleitung: true },
{ id: 'klein', name: 'Kinder unter 6', meta: 'frei, zählen als Platz', bild: 'klein', einheit: 'kind', preis: 0, min: 0, max: 4, step: 1, gross: 2, start: 0, begleitung: true }
]
},
kompakt: {
marke: 'Feldkorn', titel: 'Abholkorb', weiter: 'Zur Kasse',
klein: 'Alle Preise inkl. MwSt. Abholung Samstag ab 7 Uhr, bezahlt wird im Laden.',
posten: [
{ id: 'roggen', name: 'Roggenmisch-Kruste', meta: '4,90 € je Laib', bild: 'laib', einheit: 'laib', preis: 4.9, min: 1, max: 6, step: 1, gross: 2, start: 2 },
{ id: 'seele', name: 'Dinkel-Seele', meta: '0,85 € je Stück', bild: 'broetchen', einheit: 'stueck', preis: .85, min: 1, max: 40, step: 1, gross: 10, start: 2 },
{ id: 'butter', name: 'Butterkuchen vom Blech', meta: '2,20 € je Stück', bild: 'blech', einheit: 'stueck', preis: 2.2, min: 1, max: 24, step: 1, gross: 6, start: 4 }
]
}
};
let v = 'theke', D = DATEN.theke, stepper = [];
const fmtZahl = (p, x) => p.gewicht ? x.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(x);
const einheit = (p, x) => EINHEIT[p.einheit][regeln.select(x) === 'one' && Number.isInteger(x) ? 0 : 1];
const runde = (x, step) => Math.round(x / step) * step;
const sauber = x => Math.round(x * 1000) / 1000;
// ---------- Ein Stepper ----------
function Stepper(p, li) {
const s = { p, wert: p.start, li };
const $in = $('input', li), $minus = $('[data-minus]', li), $plus = $('[data-plus]', li), $hint = $('.hint', li), $zahl = $('.zahl', li), $einheit = $('.einheit', li), $summe = $('.zeile-summe', li);
s.max = () => {
if (!D.kapazitaet) return p.max;
const rest = D.kapazitaet - gesamtPlaetze() + s.wert;
return Math.min(p.max, rest);
};
function hint(text, art = '') {
if ($hint.textContent === text && $hint.className.includes(art)) return;
$hint.textContent = text; $hint.className = 'hint' + (art ? ' ' + art : '') + (text ? ' neu' : '');
if (text) setTimeout(() => $hint.classList.remove('neu'), 400);
}
s.hint = hint; s.$hint = $hint;
function rollen(alt, neu, richtung) {
if (reduziert() || alt === neu) return;
let r = $('.rolle', $zahl);
r.innerHTML = `<span>${fmtZahl(p, alt)}</span><span>${fmtZahl(p, neu)}</span>`;
const [a, b] = r.children;
$zahl.classList.add('rollt');
const opt = { duration: 300, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'both' };
a.animate([{ translate: '0 0', opacity: 1 }, { translate: `0 ${-richtung * 100}%`, opacity: 0 }], opt);
const an = b.animate([{ translate: `0 ${richtung * 100}%`, opacity: 0 }, { translate: '0 0', opacity: 1 }], opt);
clearTimeout(s._rt);
s._rt = setTimeout(() => { $zahl.classList.remove('rollt'); r.innerHTML = ''; }, 310);
an.onfinish = () => {};
}
function feder(knopf) {
if (reduziert()) return;
knopf.animate([{ scale: .86 }, { scale: 1 }], { duration: 400, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' });
}
s.setze = function (neu, quelle = '', meldung = '') {
const alt = s.wert, max = s.max();
neu = sauber(Math.min(Math.max(neu, p.min), max));
s.wert = neu;
if (quelle !== 'tippen-laufend') $in.value = fmtZahl(p, neu);
$in.setAttribute('aria-valuenow', neu);
$in.setAttribute('aria-valuemax', max);
$in.setAttribute('aria-valuetext', `${fmtZahl(p, neu)} ${einheit(p, neu)}`);
$in.removeAttribute('aria-invalid');
$einheit.textContent = einheit(p, neu);
if (quelle === 'knopf' || quelle === 'taste') rollen(alt, neu, neu > alt ? 1 : -1);
zustand();
if (meldung) hint(meldung[0], meldung[1]);
else if (neu >= max && max > 0 && neu > 0) hint(grenzText(), 'warn');
else if (D.kapazitaet && max === s.wert && s.wert === 0) hint('');
else hint('');
if ($summe) { const b = neu * p.preis; $summe.textContent = (p.gewicht && b ? 'ca. ' : '') + euro.format(b); $summe.classList.toggle('null', b === 0 && !(v === 'tickets' && neu > 0)); }
if (alt !== neu) aenderung(s, alt, neu);
};
function grenzText() {
if (D.kapazitaet && s.max() < p.max) return 'Mehr Plätze hat dieses Zeitfenster nicht.';
if (p.gewicht) return `Höchstens ${fmtZahl(p, p.max)} kg je Vorbestellung.`;
return `Höchstens ${p.max} ${EINHEIT[p.einheit][1]} je Vorbestellung.`;
}
function zustand() {
const max = s.max();
const unten = s.wert <= p.min, oben = s.wert >= max;
// Kompakt: bei Menge 1 wird Minus zum Entfernen
const entfernen = v === 'kompakt' && s.wert <= 1;
$minus.classList.toggle('entfernen', entfernen);
$minus.innerHTML = entfernen ? '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.7 8.5h5.6l.7-8.5"/></svg>' : '<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7h10"/></svg>';
$minus.setAttribute('aria-label', entfernen ? `${p.name} entfernen` : `${p.name}: ${p.gewicht ? '250 g' : 'ein' + (p.einheit === 'laib' ? 'en Laib' : p.einheit === 'stueck' ? ' Stück' : p.einheit === 'kind' ? ' Kind' : 'e Person')} weniger`);
$minus.setAttribute('aria-disabled', unten && !entfernen);
$plus.setAttribute('aria-disabled', oben);
}
s.zustand = zustand;
function schritt(richtung, gross) {
const st = (gross ? p.gross : p.step) * richtung;
if (richtung < 0 && v === 'kompakt' && s.wert <= 1) { entfernen(s); return false; }
const max = s.max();
if (richtung > 0 && s.wert >= max) { hint(grenzText(), 'warn'); return false; }
if (richtung < 0 && s.wert <= p.min) return false;
s.setze(runde(s.wert + st, p.step), 'knopf');
return true;
}
s.schritt = schritt;
// Gedrückt halten: erst Schritt, nach 400 ms Wiederholung, schneller werdend bis 60 ms
function halten(knopf, richtung) {
let t, takt = 160, an = false;
const stop = () => { clearTimeout(t); an = false; };
knopf.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
e.preventDefault(); an = true; feder(knopf);
if (!schritt(richtung)) return;
takt = 160;
t = setTimeout(function wieder() { if (!an) return; if (!schritt(richtung)) return stop(); takt = Math.max(60, takt * .85); t = setTimeout(wieder, takt); }, 400);
});
['pointerup', 'pointerleave', 'pointercancel', 'blur'].forEach(n => knopf.addEventListener(n, stop));
knopf.addEventListener('contextmenu', e => e.preventDefault());
// Tastatur (Enter, Leertaste): click mit detail 0
knopf.addEventListener('click', e => { if (e.detail === 0) { feder(knopf); schritt(richtung); } });
}
halten($minus, -1); halten($plus, 1);
// Spinbutton-Tasten nach WAI-APG
$in.addEventListener('keydown', e => {
const k = { ArrowUp: [1, 0], ArrowDown: [-1, 0], PageUp: [1, 1], PageDown: [-1, 1] }[e.key];
if (k) { e.preventDefault(); if (k[0] < 0 && v === 'kompakt' && s.wert <= 1) return; schritt(k[0], k[1]); return; }
if (e.key === 'Home') { e.preventDefault(); s.setze(p.min, 'taste'); }
if (e.key === 'End') { e.preventDefault(); s.setze(s.max(), 'taste'); }
if (e.key === 'Enter') { e.preventDefault(); uebernehmen(); }
});
// Freie Eingabe: erst beim Verlassen prüfen, runden und melden
function uebernehmen() {
const roh = $in.value.trim().replace(/\s|kg|g/gi, '').replace(',', '.');
if (roh === '') { s.setze(p.min, '', ['', '']); return; }
const x = Number(roh);
if (!Number.isFinite(x)) { $in.setAttribute('aria-invalid', 'true'); hint('Bitte eine Zahl eingeben, zum Beispiel ' + (p.gewicht ? '0,75' : '2') + '.', 'fehler'); return; }
const max = s.max();
if (x > max) return s.setze(max, '', [D.kapazitaet && max < p.max ? `Mehr als ${max} ${max === 1 ? 'Platz' : 'Plätze'} sind in diesem Zeitfenster nicht frei.` : `Höchstens ${fmtZahl(p, max)} ${einheit(p, max)}. Auf ${fmtZahl(p, max)} gesetzt.`, 'warn']);
if (x < p.min) return s.setze(p.min, '', [`Mindestens ${fmtZahl(p, p.min)}.`, 'warn']);
const r = sauber(runde(x, p.step));
if (Math.abs(r - x) > 1e-9) return s.setze(r, '', [p.gewicht ? `Auf ${fmtZahl(p, r)} kg gerundet, wir schneiden in Schritten von 250 g.` : `Auf ${r} gerundet.`, 'warn']);
s.setze(r);
}
$in.addEventListener('change', uebernehmen);
$in.addEventListener('focus', () => $in.select());
s.uebernehmen = uebernehmen;
s.$in = $in; s.$plus = $plus; s.$minus = $minus;
return s;
}
function stepperHTML(p, i) {
return `<div class="menge"><div class="stepper" role="group" aria-labelledby="n-${p.id}">
<button type="button" data-minus aria-label="weniger"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7h10"/></svg></button>
<div class="zahl"><input type="text" inputmode="decimal" role="spinbutton" autocomplete="off" aria-label="Menge ${p.name}" aria-valuemin="${p.min}" aria-valuemax="${p.max}" aria-valuenow="${p.start}" aria-describedby="h-${p.id}" value="${fmtZahl(p, p.start)}"><span class="rolle" aria-hidden="true"></span></div>
<button type="button" data-plus aria-label="${p.name}: mehr"><svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2 7h10M7 2v10"/></svg></button>
</div><span class="einheit" aria-hidden="true"></span>${v === 'kompakt' ? '' : '<span class="zeile-summe null">0,00 €</span>'}</div>`;
}
function bauen() {
D = DATEN[v];
$('[data-marke]').textContent = D.marke;
$('[data-zettel-titel]').textContent = D.titel;
$('[data-weiter]').textContent = D.weiter; $('[data-leiste-knopf]').textContent = D.weiter;
$('[data-klein]').textContent = D.klein;
$('[data-offen]').textContent = v === 'tickets' ? 'Heute bis 22 Uhr geöffnet' : 'Heute bis 18 Uhr geöffnet';
const liste = v === 'kompakt' ? $('[data-korb]') : $('[data-posten]');
$('[data-posten]').innerHTML = ''; $('[data-korb]').innerHTML = '';
if (v === 'kompakt') {
liste.innerHTML = D.posten.map(p => `<li data-id="${p.id}"><div><div class="zeile">${zeichnung(p.bild)}<h3 id="n-${p.id}">${p.name}<small>${p.meta}</small></h3>${stepperHTML(p)}<span class="zeile-summe"></span><p class="hint" id="h-${p.id}" style="grid-column:2/-1"></p></div></div></li>`).join('');
} else {
liste.innerHTML = D.posten.map(p => `<li data-id="${p.id}">${zeichnung(p.bild)}<div class="was"><h3 id="n-${p.id}">${p.name}</h3><p class="meta">${p.meta}</p></div>${stepperHTML(p)}<p class="hint" id="h-${p.id}"></p></li>`).join('');
}
stepper = D.posten.map(p => Stepper(p, $(`[data-id="${p.id}"]`, liste)));
$('[data-sitze]').innerHTML = D.kapazitaet ? Array.from({ length: D.kapazitaet }, () => '<i></i>').join('') : '';
$('[data-leer]').hidden = true;
versucht = false; zuletzt = null;
stepper.forEach(s => s.setze(s.p.start));
gesamt(true);
}
const gesamtPlaetze = () => stepper.reduce((a, s) => a + (s.entfernt ? 0 : s.wert), 0);
let ansageT, zuletzt = null, versucht = false;
function aenderung(s) { zuletzt = s; gesamt(); }
function gesamt(still) {
const aktiv = stepper.filter(s => !s.entfernt);
const summe = aktiv.reduce((a, s) => a + s.wert * s.p.preis, 0);
const pos = aktiv.filter(s => s.wert > 0).length;
const ca = aktiv.some(s => s.p.gewicht && s.wert > 0);
const posText = v === 'tickets' ? `${gesamtPlaetze()} ${gesamtPlaetze() === 1 ? 'Person' : 'Personen'}` : `${pos} ${pos === 1 ? 'Position' : 'Positionen'}`;
$('[data-summe]').textContent = (ca ? 'ca. ' : '') + euro.format(summe);
$('[data-leiste-summe]').textContent = (ca ? 'ca. ' : '') + euro.format(summe);
$('[data-summe-zusatz]').textContent = ca ? 'Endpreis nach Wiegen, inkl. MwSt.' : 'Endpreis inkl. MwSt.';
$('[data-positionen]').textContent = posText; $('[data-leiste-pos]').textContent = posText;
// Tickets: gemeinsame Kapazität und Begleitregel
let sperre = pos === 0, regel = '', art = '';
if (D.kapazitaet) {
const belegt = gesamtPlaetze(), frei = D.kapazitaet - belegt;
$$('[data-sitze] i').forEach((i, k) => i.classList.toggle('belegt', k < belegt));
$('[data-frei]').textContent = `${frei} von ${D.kapazitaet} Plätzen frei`;
$('[data-frei-text]').textContent = frei === 0 ? 'alle gewählt' : `noch ${frei}`;
stepper.forEach(s => { s.zustand(); s.$in.setAttribute('aria-valuemax', s.max()); });
const kinder = stepper.filter(s => s.p.begleitung).reduce((a, s) => a + s.wert, 0), erw = stepper.find(s => s.p.id === 'erw').wert;
if (kinder > 0 && erw === 0) { regel = 'Kinder unter 12 bouldern nur mit einer erwachsenen Begleitung. Bitte mindestens eine erwachsene Person wählen.'; art = versucht ? 'fehler' : 'warn'; sperre = true; }
else if (frei === 0) { regel = 'Alle 6 Plätze dieses Fensters sind gewählt. Um 16:00 Uhr sind noch 11 Plätze frei.'; art = 'warn'; }
// Grenz-Hinweis nur an der Zeile, an der zuletzt geklickt wurde; die gemeinsame Meldung steht unten
stepper.forEach(s => { if (s !== zuletzt && /Zeitfenster/.test(s.$hint.textContent)) s.hint(''); if (frei > 0 && /Zeitfenster/.test(s.$hint.textContent)) s.hint(''); });
}
const $r = $('[data-regel]');
if (regel) { if ($r.textContent !== regel) { $r.innerHTML = `<svg viewBox="0 0 18 18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="9" cy="9" r="7.5"/><path d="M9 5v5M9 12.5v.5"/></svg><span>${regel}</span>`; } $r.className = 'regel ' + art; $r.hidden = false; }
else { $r.hidden = true; $r.textContent = ''; }
[$('[data-weiter]'), $('[data-leiste-knopf]')].forEach(k => k.setAttribute('aria-disabled', sperre));
if (v === 'kompakt') { $('[data-leer]').hidden = aktiv.length > 0; $('.summe').hidden = aktiv.length === 0; stepper.forEach(s => { const z = $('.zeile-summe', s.li); if (z) z.textContent = euro.format(s.wert * s.p.preis); }); }
else $('.summe').hidden = false;
if (still) return;
// Live-Ansage gebündelt nach 500 ms Ruhe
clearTimeout(ansageT);
ansageT = setTimeout(() => { $('[data-ansage]').textContent = `Summe ${(ca ? 'circa ' : '')}${euro.format(summe)}, ${posText}.${regel ? ' ' + regel : ''}`; }, 500);
}
// Kompakt: Entfernen mit Rückgängig
let letzte = null, toastT;
function entfernen(s) {
s.entfernt = true; letzte = s;
const li = s.li; li.classList.add('weg'); li.setAttribute('aria-hidden', 'true'); li.inert = true;
// Fokus auf die Überschrift des Korbs: keine Tastatur springt auf, der Kontext bleibt
const titel = $('#zettel-titel'); titel.tabIndex = -1; titel.focus({ preventScroll: true });
$('[data-toast-text]').textContent = `${s.p.name} entfernt.`;
$('[data-toast]').hidden = false;
clearTimeout(toastT); toastT = setTimeout(() => { $('[data-toast]').hidden = true; }, 5000);
gesamt();
}
$('[data-rueckgaengig]').addEventListener('click', () => {
if (!letzte) return;
const s = letzte; letzte = null;
s.entfernt = false; s.li.classList.remove('weg'); s.li.removeAttribute('aria-hidden'); s.li.inert = false;
$('[data-toast]').hidden = true; s.setze(1); gesamt(); s.$in.focus();
});
$$('[data-weiter], [data-leiste-knopf]').forEach(k => k.addEventListener('click', e => {
e.preventDefault();
if (k.getAttribute('aria-disabled') === 'true') { versucht = true; gesamt(true); const r = $('[data-regel]'); if (!r.hidden) r.animate?.([{ translate: '-4px 0' }, { translate: '4px 0' }, { translate: '0 0' }], { duration: reduziert() ? 1 : 240 }); $('[data-ansage]').textContent = r.hidden ? 'Bitte zuerst eine Menge wählen.' : r.textContent; return; }
$('[data-ansage]').textContent = 'In der Demo endet der Weg hier.';
}));
// ---------- Variante ----------
function variante(neu, url = true) {
v = DATEN[neu] ? neu : 'theke';
html.dataset.v = v;
$$('[data-wahl]').forEach(b => b.setAttribute('aria-pressed', b.dataset.wahl === v));
$('[data-toast]').hidden = true;
bauen();
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q); }
}
$$('[data-wahl]').forEach(b => b.addEventListener('click', () => { const lief = laeuft; pause(); variante(b.dataset.wahl); if (lief) vorfuehren(); }));
// ---------- Vorführung ----------
let laeuft = false, gen = 0, uhr = null;
const warte = ms => new Promise(r => { uhr = setTimeout(r, ms); });
const S = id => stepper.find(s => s.p.id === id);
async function druecke(s, richtung, n = 1, takt = 420) {
const k = richtung > 0 ? s.$plus : s.$minus;
for (let i = 0; i < n; i++) {
const g = gen; k.classList.add('gedrueckt');
if (!reduziert()) k.animate([{ scale: .86 }, { scale: 1 }], { duration: 400, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' });
s.schritt(richtung); await warte(takt); if (g !== gen) return false;
}
return true;
}
async function tippe(s, text) {
const g = gen; s.$in.value = ''; s.$in.classList.add('tippt');
for (const z of text) { s.$in.value += z; await warte(160); if (g !== gen) return false; }
s.uebernehmen(); return true;
}
async function vorfuehren() {
pause(); laeuft = true; const g = ++gen; html.classList.add('vorfuehrung');
bauen(); await warte(600); if (g !== gen) return;
if (v === 'theke') {
if (!await druecke(S('roggen'), 1, 2)) return;
if (!await druecke(S('seele'), 1, 8, 130)) return; // wie gedrückt halten
await warte(300); if (g !== gen) return;
if (!await druecke(S('zwiebel'), 1, 3)) return;
await warte(400); if (g !== gen) return;
if (!await tippe(S('zwiebel'), '1,3')) return; // wird auf 1,25 kg gerundet
await warte(1400); if (g !== gen) return;
if (!await druecke(S('roggen'), 1, 5, 260)) return; // stößt an die Grenze
await warte(1800); if (g !== gen) return;
} else if (v === 'tickets') {
if (!await druecke(S('erw'), 1, 2)) return;
if (!await druecke(S('kind'), 1, 3)) return;
if (!await druecke(S('klein'), 1, 2)) return; // letzter Platz, dann Grenze
await warte(1600); if (g !== gen) return;
if (!await druecke(S('erw'), -1, 2)) return; // Begleitregel greift
await warte(1800); if (g !== gen) return;
if (!await druecke(S('erw'), 1, 1)) return;
await warte(1400); if (g !== gen) return;
} else {
if (!await druecke(S('butter'), -1, 1)) return;
if (!await druecke(S('seele'), -1, 1)) return; // jetzt 1: Minus wird Papierkorb
await warte(700); if (g !== gen) return;
if (!await druecke(S('seele'), -1, 1)) return; // entfernt, Rückgängig erscheint
await warte(1700); if (g !== gen) return;
$('[data-rueckgaengig]').click();
await warte(1200); if (g !== gen) return;
if (!await druecke(S('roggen'), 1, 3, 300)) return;
await warte(1500); if (g !== gen) return;
}
if (g === gen) uhr = setTimeout(vorfuehren, 300);
}
function neustart() { pause(); bauen(); }
function pause() { gen++; laeuft = false; clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
function weiter() { vorfuehren(); }
variante(new URLSearchParams(location.search).get('v'), false);
})();
</script>
</body>
</html>