Datei-Ablage mit Vorschau und Prüfung
Schadensfoto zur Anfrage: echtes Dateifeld in einer Ablagefläche, Vorschau-Karten mit Entfernen, Fehler mit Dateiname am Feld und Verkleinern auf 2000 px im Browser.
Ein Feld „Fotos oder Dateien“ mit großer Ablagefläche: Dateien hineinziehen oder tippen und auswählen, auf dem Telefon auch direkt fotografieren. Jede Datei erscheint als kleine Karte mit Vorschaubild, Name, Größe und Knopf „Entfernen“. Zu große oder falsche Dateien zeigen sofort eine klare Meldung am Feld. Fotos werden vor dem Senden im Browser verkleinert.
Ein Foto vom Wasserschaden, vom Kratzer am Auto oder die Zeichnung zur Anfrage spart dem Betrieb einen Rückruf. Das Standard-Dateifeld wirkt dagegen technisch und verrät nicht, ob alles angekommen ist.
Wann einsetzen
Anfrage beim Handwerker, Schadensmeldung in der Werkstatt, Kurzbewerbung mit Lebenslauf, Anfrage mit Zeichnung im Mittelstand, Objektfotos für eine Bewertung.
Wann nicht
Wenn die Datei nicht wirklich gebraucht wird. Bei Gesundheitsunterlagen (Praxis, Pflege) und Mandantendokumenten: Dort gehört ein sicherer Kanal statt eines Formulars hin. Kein Pflichtfeld daraus machen, wenn es auch ohne geht.
Varianten
- foto: nur Bilder, accept="image/*", Kamera-Hinweis auf dem Telefon, Verkleinern auf etwa 2000 px
- dokument: PDF und Office, Größengrenze sichtbar, keine Vorschau außer Dateityp
- kompakt-mobil: nur ein Knopf „Foto aufnehmen oder wählen“ plus Liste
Umsetzung
- label umschließt input type=file (multiple, accept); das Input bleibt fokussierbar und wird nur visuell versteckt, nie display: none.
- dragenter, dragover und drop auf der Fläche; dragover auch auf window abbrechen, damit ein Fehlwurf nicht die Seite verlässt.
- Dateien in einen DataTransfer sammeln und dem Input zuweisen, damit das Formular normal sendet.
- Prüfen nach Endung und Typ (file.type kann leer sein) und Größe; Fehler per setCustomValidity am Feld wie validierung-nach-interaktion.
- Bilder mit createImageBitmap und Canvas.toBlob auf etwa 2000 px verkleinern; das entfernt auch Ortsdaten.
- Liste der Dateien als ul mit aria-live="polite"; der Entfernen-Knopf nennt den Dateinamen.
- Fallback: Ohne JS bleibt das native Dateifeld mit Text zu Typ und Größe. Upload-Ziel, Speicherdauer und Datenschutzhinweis am Feld vom Kunden prüfen lassen.
Robust umsetzen
Telefon. Ablagefläche entfällt, ein großer Knopf; Kamera über accept="image/*".
Ohne Bewegung. Hervorhebung nur über Rahmenfarbe, keine Skalierung.
Barrierefrei. Echtes Input per Tastatur erreichbar und beschriftet; Zustand und Fehler über aria-live; der Fehlertext nennt Datei und Grund; Fokus sichtbar auf der Fläche. 320 px: einspaltige Liste. 200 % Zoom: Karten brechen um. Kontrast 4,5:1 für Hinweise zu Typ und Größe. Bei langen Formularen formular-fehlerzusammenfassung.
Code
<!DOCTYPE html>
<!--
Datei-Ablage mit Vorschau und Prüfung
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/datei-upload-ablage/
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>Datei-Ablage mit Vorschau und Prüfung · 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: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 100%; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Funnel Display'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/funnel-display:vf@latest/latin-wght-normal.woff2') format('woff'); font-weight: 300 800; font-display: swap; }
@font-face { font-family: 'Funnel Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/funnel-sans:vf@latest/latin-wght-normal.woff2') format('woff'); font-weight: 300 800; 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; }
/* Eine Kurve für die Demo: token-tech-praezise aus dem Rezept handwerk (Eintritt / Austritt) */
:root {
--ein: cubic-bezier(0, 0, 0.38, 0.9);
--aus: cubic-bezier(0.2, 0, 1, 0.9);
--t-hover: 150ms; --t-flaeche: 180ms; --t-karte: 240ms; --t-karte-aus: 160ms;
}
/* Welten */
.welt-hand { --grund: #EFEDEA; --flaeche: #D3CDC2; --holz: #C3B4A5; --salbei: #BDC1A9; --text: #1A1A1A; --leise: #5E5D5C; --linie: #CDCBC9; --signal: #FB6100; --signal-text: #1A1A1A; --fehler: #B3261E; --ok: #2F6B3A;
--kopf: 'Special Gothic', system-ui, sans-serif; --fliess: 'Atkinson Hyperlegible Next', system-ui, sans-serif; --r: 4px; --r-gross: 8px; --karte: #F7F6F3; }
.welt-job { --grund: #F6F3EC; --flaeche: #EFE6D2; --text: #161616; --leise: #5A5650; --linie: #D2D0CA; --signal: #C8382A; --signal-text: #fff; --fehler: #B3261E; --ok: #1D4A3A; --tanne: #1D4A3A;
--kopf: 'Funnel Display', system-ui, sans-serif; --fliess: 'Funnel Sans', system-ui, sans-serif; --r: 6px; --r-gross: 6px; --karte: #FFFDF8; }
* { box-sizing: border-box; margin: 0; }
html { -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: clip; }
a, button, input, textarea { font: inherit; color: inherit; }
[hidden] { display: none !important; }
.sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.welt { background: var(--grund); color: var(--text); font-family: var(--fliess); font-size: 1.0625rem; line-height: 1.55; min-height: 100svh; }
.welt[hidden] { display: none; }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
/* ── Seitenrahmen ───────────────────────────────────────────────────── */
.kopfleiste { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { font-family: var(--kopf); text-decoration: none; font-weight: 700; font-size: 1.25rem; letter-spacing: -.01em; }
.welt-hand .marke { font-stretch: 100%; }
.welt-hand .marke span { font-stretch: 75%; font-weight: 600; font-size: .8125rem; letter-spacing: .04em; text-transform: uppercase; margin-left: 8px; color: var(--leise); }
.anruf { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 16px; border-radius: var(--r); background: var(--signal); color: var(--signal-text); font-weight: 700; text-decoration: none; font-variant-numeric: tabular-nums; transition: filter var(--t-hover) var(--ein); }
.anruf:hover { filter: brightness(.94); }
.anruf svg { width: 16px; height: 16px; }
.raster { max-width: 1280px; margin: 0 auto; padding: clamp(32px, 5vw, 72px) clamp(16px, 4vw, 48px) clamp(56px, 7vw, 104px); display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(24px, 4vw, 64px); align-items: start; }
.etikett { font-family: var(--kopf); font-stretch: 75%; font-weight: 600; font-size: .8125rem; letter-spacing: .04em; text-transform: uppercase; color: var(--leise); }
.welt-job .etikett { font-family: var(--fliess); font-stretch: 100%; letter-spacing: .06em; font-size: .75rem; }
h1 { font-family: var(--kopf); font-weight: 700; font-size: clamp(2.25rem, 1.5rem + 3vw, 3.5rem); line-height: 1.04; letter-spacing: -.01em; margin-top: 12px; text-wrap: balance; }
.welt-job h1 { font-weight: 650; letter-spacing: -.02em; font-size: clamp(2rem, 1.3rem + 2.2vw, 3rem); hyphens: manual; overflow-wrap: break-word; }
.text-spalte p.lead { margin-top: 18px; max-width: 44ch; }
.fakten { list-style: none; padding: 0; margin-top: 28px; border-top: 1px solid var(--linie); }
.fakten li { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--linie); }
.fakten b { font-family: var(--kopf); font-stretch: 75%; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; font-size: .8125rem; padding-top: 3px; }
.welt-job .fakten b { font-family: var(--fliess); font-stretch: 100%; font-size: .75rem; letter-spacing: .06em; }
/* ── Formular ────────────────────────────────────────────────────────── */
form { background: var(--karte); border: 1px solid var(--linie); border-radius: var(--r-gross); padding: clamp(20px, 3vw, 36px); display: grid; gap: 20px; }
.zeile2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.feld { display: grid; gap: 6px; }
.feld > label, .feld > .titel { font-weight: 700; font-size: 1rem; }
.feld small, .feld .hilfe { color: var(--leise); font-size: .9375rem; line-height: 1.45; }
.feld input:not([type=file]), .feld textarea { width: 100%; min-height: 48px; padding: 10px 12px; border: 1px solid #8C8A86; border-radius: var(--r); background: #fff; font-size: 1.0625rem; transition: border-color var(--t-hover) var(--ein); }
.feld textarea { min-height: 96px; resize: vertical; field-sizing: content; }
.feld input:focus-visible, .feld textarea:focus-visible { outline: 2px solid var(--text); outline-offset: 1px; border-color: var(--text); }
.feld input:user-invalid, .feld textarea:user-invalid, .feld input.fehlerhaft { border-color: var(--fehler); border-width: 2px; }
.feld .fehlertext { color: var(--fehler); font-size: .9375rem; font-weight: 600; display: flex; gap: 8px; align-items: flex-start; }
.feld .fehlertext:empty { display: none; }
.feld .fehlertext svg { width: 16px; height: 16px; flex: none; margin-top: 3px; }
.optional { font-weight: 400; color: var(--leise); }
/* Ablagefläche: label umschließt das echte, nur visuell versteckte Dateifeld */
.ablage { position: relative; display: grid; justify-items: center; text-align: center; gap: 8px; padding: 28px 20px; border: 2px dashed #8C8A86; border-radius: var(--r-gross); background: var(--grund); cursor: pointer;
transition: border-color var(--t-flaeche) var(--aus), background-color var(--t-flaeche) var(--aus); }
.ablage input[type=file] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; } /* fokussierbar, nie display: none */
.ablage:hover { border-color: var(--text); }
.ablage:has(input:focus-visible) { outline: 3px solid var(--text); outline-offset: 3px; border-style: solid; }
.ablage.drueber { border-color: var(--text); border-style: solid; background: var(--flaeche); transition-timing-function: var(--ein); }
.ablage.fehlerhaft { border-color: var(--fehler); }
.ablage .symbol { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 50%; background: var(--text); color: var(--grund); transition: translate var(--t-flaeche) var(--ein); }
.ablage .symbol svg { width: 22px; height: 22px; }
.ablage.drueber .symbol { translate: 0 -4px; }
.ablage .gross { font-weight: 700; font-size: 1.0625rem; }
.ablage .gross u { text-underline-offset: 3px; }
.ablage .klein { color: var(--leise); font-size: .9375rem; font-variant-numeric: tabular-nums; }
.ablage .touch { display: none; }
@media (hover: none) { .ablage .maus { display: none; } .ablage .touch { display: inline; } }
/* Kompakt: nur ein großer Knopf */
.knopf-datei { position: relative; display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 56px; padding: 0 20px; border-radius: var(--r); background: var(--text); color: var(--grund); font-weight: 700; cursor: pointer; overflow: hidden; }
.knopf-datei input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.knopf-datei:has(input:focus-visible) { outline: 3px solid var(--text); outline-offset: 3px; }
.knopf-datei svg { width: 22px; height: 22px; }
/* Liste der Dateien */
.dateien { list-style: none; padding: 0; display: grid; gap: 8px; }
.dateien:empty { display: none; }
.datei { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 8px; border: 1px solid var(--linie); border-radius: var(--r); background: #fff;
animation: karte-ein var(--t-karte) var(--ein); }
@keyframes karte-ein { from { opacity: 0; translate: 0 4px; } }
.datei.weg { animation: karte-aus var(--t-karte-aus) var(--aus) forwards; }
@keyframes karte-aus { to { opacity: 0; translate: 0 4px; } }
.datei .bild { width: 56px; height: 56px; border-radius: calc(var(--r) - 1px); object-fit: cover; background: var(--flaeche); display: grid; place-items: center; font: 700 .75rem/1 var(--fliess); letter-spacing: .04em; color: var(--text); }
.datei .typ { background: var(--flaeche); }
.datei .typ.pdf { background: #F1D9D5; color: #7A1E14; }
.datei .typ.doc { background: #D8E2EC; color: #1E3A5C; }
.datei .name { font-weight: 700; font-size: .9375rem; overflow-wrap: anywhere; line-height: 1.35; }
.datei .info { color: var(--leise); font-size: .875rem; font-variant-numeric: tabular-nums; line-height: 1.4; }
.datei .info b { font-weight: 600; color: var(--ok); }
.datei .balken { height: 3px; background: var(--linie); border-radius: 2px; margin-top: 6px; overflow: hidden; }
.datei .balken i { display: block; height: 100%; width: 100%; background: var(--text); transform-origin: left; animation: lade 900ms var(--ein) both; }
@keyframes lade { from { scale: 0 1; } }
.datei.fertig .balken { display: none; }
.datei.falsch { border-color: var(--fehler); background: #FBF1EF; }
.datei.falsch .info { color: var(--fehler); font-weight: 600; }
.datei .entfernen { min-height: 44px; padding: 0 12px; border: 1px solid transparent; border-radius: var(--r); background: none; cursor: pointer; font-size: .9375rem; text-decoration: underline; text-underline-offset: 3px; color: var(--text); }
.datei .entfernen:hover, .datei .entfernen.gezeigt { background: var(--grund); }
.datei .entfernen.gezeigt { outline: 2px solid var(--text); outline-offset: -2px; }
/* Kompakt: Kacheln */
.kacheln { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.kacheln .datei { grid-template-columns: minmax(0, 1fr); padding: 0; position: relative; overflow: hidden; gap: 0; }
.kacheln .datei .bild { width: 100%; height: auto; aspect-ratio: 1; border-radius: 0; }
.kacheln .datei .text { padding: 6px 8px 8px; }
.kacheln .datei .name { font-size: .8125rem; }
.kacheln .datei .info { font-size: .75rem; }
.kacheln .datei .entfernen { position: absolute; top: 4px; right: 4px; width: 44px; height: 44px; padding: 0; display: grid; place-items: center; background: rgba(26, 26, 26, .78); color: #fff; text-decoration: none; border-radius: 50%; }
.kacheln .datei .entfernen svg { width: 14px; height: 14px; }
.kacheln .datei.falsch .bild { background: #F3D9D5; color: var(--fehler); }
.datenschutz { border: 1px dashed #8C8A86; border-radius: var(--r); padding: 12px 14px; font-size: .9375rem; color: var(--leise); line-height: 1.45; background: repeating-linear-gradient(-45deg, transparent 0 8px, rgba(0,0,0,.025) 8px 16px); }
.datenschutz b { color: var(--text); }
.senden { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 52px; padding: 0 24px; border: 0; border-radius: var(--r); background: var(--signal); color: var(--signal-text); font-weight: 700; cursor: pointer; transition: filter var(--t-hover) var(--ein); }
.knopf:hover { filter: brightness(.94); }
.knopf:active { translate: 0 1px; }
.senden small { color: var(--leise); font-size: .875rem; }
.zusammenfassung { border-left: 4px solid var(--fehler); background: #FBF1EF; padding: 12px 16px; border-radius: var(--r); }
.zusammenfassung[hidden] { display: none; }
.zusammenfassung h2 { font-size: 1rem; font-weight: 700; }
.zusammenfassung ul { padding-left: 20px; margin-top: 4px; }
.zusammenfassung a { color: var(--fehler); font-weight: 600; }
.erfolg { display: grid; gap: 12px; background: var(--karte); border: 1px solid var(--linie); border-radius: var(--r-gross); padding: clamp(24px, 3vw, 40px); animation: karte-ein var(--t-karte) var(--ein); }
.erfolg[hidden] { display: none; }
.erfolg h2 { font-family: var(--kopf); font-size: clamp(1.625rem, 1.2rem + 1.6vw, 2.25rem); line-height: 1.1; }
.erfolg .haken { width: 48px; height: 48px; border-radius: 50%; background: var(--ok); color: #fff; display: grid; place-items: center; }
.erfolg .haken svg { width: 24px; height: 24px; }
.erfolg button { justify-self: start; min-height: 44px; padding: 0 16px; border: 1px solid var(--text); border-radius: var(--r); background: none; cursor: pointer; font-weight: 700; }
/* Variante dokument: Tannengrün-Spalte */
.welt-job .text-spalte { background: var(--tanne); color: #F6F3EC; border-radius: var(--r-gross); padding: clamp(24px, 3vw, 40px); }
.welt-job .text-spalte .etikett, .welt-job .text-spalte .fakten b { color: #DCE5DF; }
.welt-job .text-spalte .fakten, .welt-job .text-spalte .fakten li { border-color: rgba(246, 243, 236, .22); }
.welt-job .text-spalte :focus-visible { outline-color: #F6F3EC; }
.welt-job .knopf { box-shadow: none; }
.welt-job .ablage .symbol { background: var(--tanne); }
/* Variante kompakt: eine schmale Spalte */
.welt-kompakt .raster { grid-template-columns: minmax(0, 1fr); max-width: 34rem; }
.welt-kompakt h1 { font-size: clamp(2rem, 1.4rem + 2.4vw, 2.75rem); }
/* Demo-Hinweis */
.demo-hinweis { font: 500 10.5px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); }
/* Pult */
.pult { position: fixed; right: 12px; bottom: 12px; z-index: 40; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 6px 6px 12px; border-radius: 6px; background: rgba(26, 26, 26, .94); color: rgba(255, 255, 255, .72); font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; max-width: calc(100vw - 24px); }
.pult .gruppe { display: flex; flex-wrap: wrap; gap: 4px; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; height: 30px; padding: 0 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, .22); background: none; color: rgba(255, 255, 255, .84); cursor: pointer; }
.pult button[aria-pressed='true'] { background: #fff; color: #1A1A1A; border-color: #fff; }
.pult :focus-visible { outline-color: #fff; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .kopfleiste, .vorschau .fakten, .vorschau .demo-hinweis, .vorschau .feld:has(textarea), .vorschau .datenschutz, .vorschau .text-spalte .lead { display: none; }
.vorschau form { gap: 16px; }
.vorschau .raster { padding-top: 28px; }
@media (max-width: 900px) { .raster { grid-template-columns: minmax(0, 1fr); } }
@media (max-width: 560px) { .zeile2 { grid-template-columns: minmax(0, 1fr); } .fakten li { grid-template-columns: minmax(0, 1fr); gap: 2px; } .kacheln { grid-template-columns: repeat(2, minmax(0, 1fr)); } .welt-hand .marke span { display: none; } }
@media (max-width: 360px) { .datei { grid-template-columns: 48px minmax(0, 1fr); } .datei .bild { width: 48px; height: 48px; } .datei .entfernen { grid-column: 2; justify-self: start; padding: 0; } .kacheln .datei .entfernen { grid-column: auto; } }
:root.reduziert *, :root.reduziert *::before, :root.reduziert *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
:root.reduziert .ablage.drueber .symbol { translate: 0 0; }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
.ablage.drueber { outline: 3px solid Highlight; }
}
</style>
</head>
<body data-v="foto">
<main>
<!-- ═════ Varianten foto und kompakt-mobil · Rezept handwerk-werkstatt-klartext · Brennholt Haustechnik, Solingen ═════ -->
<div class="welt welt-hand" id="welt-hand">
<header class="kopfleiste">
<a class="marke" href="#h-anfrage">Brennholt<span>Haustechnik · Solingen</span></a>
<a class="anruf" href="tel:+492129876540"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 1.5 3 2.2c-.8.3-1.3 1.1-1.1 2 1 5 4.9 8.9 9.9 9.9.9.2 1.7-.3 2-1.1l.7-2.5-3.2-1.4-1.5 1.5C8 9.6 6.4 8 5.4 6.2l1.5-1.5z" fill="currentColor"/></svg>Notdienst 0212 987 654-0</a>
</header>
<div class="raster">
<div class="text-spalte">
<p class="etikett" id="e-anfrage">Schaden melden</p>
<h1 id="h-anfrage">Wasser, wo es nicht hingehört? Schicken Sie ein Foto.</h1>
<p class="lead">Mit einem Foto sehen wir vorher, welches Teil fehlt. Dann kommen wir einmal und bringen es mit.</p>
<ul class="fakten">
<li><b>Rückruf</b><span>werktags bis 16 Uhr am selben Tag</span></li>
<li><b>Fotos</b><span>bis 6 Stück, JPG, PNG, HEIC oder WebP, je bis 15 MB</span></li>
<li><b>Gebiet</b><span>Solingen, Haan, Wuppertal-Cronenberg</span></li>
</ul>
</div>
<div class="form-spalte">
<form id="form-hand" novalidate aria-labelledby="h-anfrage">
<div class="zusammenfassung" id="zf-hand" tabindex="-1" hidden><h2>Bitte prüfen Sie:</h2><ul></ul></div>
<div class="zeile2">
<div class="feld"><label for="h-name">Name</label><input id="h-name" name="name" autocomplete="name" required aria-describedby="h-name-f"><p class="fehlertext" id="h-name-f"></p></div>
<div class="feld"><label for="h-tel">Telefon für den Rückruf</label><input id="h-tel" name="telefon" type="tel" inputmode="tel" autocomplete="tel" required aria-describedby="h-tel-f"><p class="fehlertext" id="h-tel-f"></p></div>
</div>
<div class="feld"><label for="h-was">Was ist passiert? <span class="optional">(optional)</span></label><textarea id="h-was" name="beschreibung" rows="3" placeholder="z. B. Unter dem Waschbecken tropft es seit gestern."></textarea></div>
<!-- Ablage: Variante foto -->
<div class="feld nur-foto" data-ablage="foto">
<span class="titel" id="h-foto-t">Fotos <span class="optional">(optional, hilft uns sehr)</span></span>
<label class="ablage">
<input type="file" name="fotos" multiple accept="image/jpeg,image/png,image/heic,image/heif,image/webp,.jpg,.jpeg,.png,.heic,.heif,.webp" aria-labelledby="h-foto-t" aria-describedby="h-foto-h h-foto-f">
<span class="symbol" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></span>
<span class="gross"><span class="maus">Fotos hierher ziehen oder <u>auswählen</u></span><span class="touch">Foto aufnehmen oder <u>auswählen</u></span></span>
<span class="klein" id="h-foto-h">Bis 6 Fotos · JPG, PNG, HEIC, WebP · je bis 15 MB · wird vor dem Senden verkleinert</span>
</label>
<p class="fehlertext" id="h-foto-f" aria-live="polite"></p>
<ul class="dateien" aria-live="polite" aria-label="Ausgewählte Fotos"></ul>
</div>
<!-- Ablage: Variante kompakt-mobil -->
<div class="feld nur-kompakt" data-ablage="kompakt" hidden>
<span class="titel" id="k-foto-t">Fotos <span class="optional">(optional)</span></span>
<label class="knopf-datei">
<input type="file" name="fotos" multiple accept="image/jpeg,image/png,image/heic,image/heif,image/webp,.jpg,.jpeg,.png,.heic,.heif,.webp" aria-labelledby="k-foto-t k-foto-k" aria-describedby="k-foto-h k-foto-f">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>
<span id="k-foto-k">Foto aufnehmen oder wählen</span>
</label>
<small id="k-foto-h">Bis 6 Fotos, je bis 15 MB. Wird vor dem Senden verkleinert.</small>
<p class="fehlertext" id="k-foto-f" aria-live="polite"></p>
<ul class="dateien kacheln" aria-live="polite" aria-label="Ausgewählte Fotos"></ul>
</div>
<p class="datenschutz"><b>[Platzhalter Datenschutzhinweis]</b> Wofür die Fotos genutzt werden, wo sie gespeichert und wann sie gelöscht werden. Text vom Kunden prüfen lassen.</p>
<div class="senden"><button class="knopf" type="submit">Anfrage senden</button><small class="demo-hinweis">Demo: Es wird nichts gesendet.</small></div>
</form>
<div class="erfolg" id="ok-hand" tabindex="-1" hidden>
<span class="haken" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg></span>
<h2>Danke. Ihre Anfrage ist da.</h2>
<p class="ok-text">Wir rufen Sie heute bis 16 Uhr zurück.</p>
<button type="button" data-zurueck>Neue Anfrage</button>
</div>
</div>
</div>
</div>
<!-- ═════ Variante dokument · Rezept arbeitgeber-karriere · Lindemann Elektrotechnik, Plön ═════ -->
<div class="welt welt-job" id="welt-job" hidden>
<header class="kopfleiste">
<a class="marke" href="#h-job">Lindemann Elektrotechnik</a>
<span class="etikett">Karriere · Plön</span>
</header>
<div class="raster">
<div class="text-spalte">
<p class="etikett">Stelle · Vollzeit</p>
<h1 id="h-job">Elektroniker:in für Gebäude­technik.</h1>
<p class="lead">Bewirb dich in zwei Minuten. Lebenslauf reicht, Anschreiben brauchen wir nicht.</p>
<ul class="fakten">
<li><b>Gehalt</b><span>19,50 bis 25 € die Stunde, je nach Erfahrung</span></li>
<li><b>Zeit</b><span>38 Stunden, freitags um 13 Uhr Schluss</span></li>
<li><b>Ort</b><span>Plön und Umgebung, Firmenwagen</span></li>
</ul>
</div>
<div class="form-spalte">
<form id="form-job" novalidate aria-labelledby="h-job">
<div class="zusammenfassung" id="zf-job" tabindex="-1" hidden><h2>Bitte prüf noch:</h2><ul></ul></div>
<div class="zeile2">
<div class="feld"><label for="j-name">Dein Name</label><input id="j-name" name="name" autocomplete="name" required aria-describedby="j-name-f"><p class="fehlertext" id="j-name-f"></p></div>
<div class="feld"><label for="j-kontakt">Telefon oder E-Mail</label><input id="j-kontakt" name="kontakt" autocomplete="email" required aria-describedby="j-kontakt-f"><p class="fehlertext" id="j-kontakt-f"></p></div>
</div>
<div class="feld" data-ablage="dokument">
<span class="titel" id="j-cv-t">Lebenslauf <span class="optional">(optional)</span></span>
<label class="ablage">
<input type="file" name="unterlagen" multiple accept=".pdf,.doc,.docx,.odt,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text" aria-labelledby="j-cv-t" aria-describedby="j-cv-h j-cv-f">
<span class="symbol" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 3h8l4 4v14H6z M14 3v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M9 12h6M9 15.5h6" stroke="currentColor" stroke-width="1.8"/></svg></span>
<span class="gross"><span class="maus">Datei hierher ziehen oder <u>auswählen</u></span><span class="touch">Datei <u>auswählen</u></span></span>
<span class="klein" id="j-cv-h">PDF, Word oder ODT · bis 5 MB je Datei · höchstens 3 Dateien</span>
</label>
<p class="fehlertext" id="j-cv-f" aria-live="polite"></p>
<ul class="dateien" aria-live="polite" aria-label="Ausgewählte Dateien"></ul>
</div>
<p class="datenschutz"><b>[Platzhalter Datenschutzhinweis Bewerbung]</b> Wer die Unterlagen sieht, wie lange sie gespeichert bleiben (etwa sechs Monate nach Absage) und wie man sie löschen lässt. Vom Kunden prüfen lassen.</p>
<div class="senden"><button class="knopf" type="submit">Bewerbung senden</button><small class="demo-hinweis">Demo: Es wird nichts gesendet.</small></div>
</form>
<div class="erfolg" id="ok-job" tabindex="-1" hidden>
<span class="haken" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg></span>
<h2>Danke, angekommen.</h2>
<p class="ok-text">Wir melden uns innerhalb von drei Werktagen.</p>
<button type="button" data-zurueck>Zurück zum Formular</button>
</div>
</div>
</div>
</div>
</main>
<div class="pult">
<span>Variante</span>
<div class="gruppe" role="group" aria-label="Variante">
<button type="button" data-v="foto" aria-pressed="true">Foto</button>
<button type="button" data-v="dokument" aria-pressed="false">Dokument</button>
<button type="button" data-v="kompakt-mobil" aria-pressed="false">Kompakt</button>
</div>
<button type="button" id="beispiel">Beispiel ablegen</button>
</div>
<script>
(() => {
const h = document.documentElement;
const reduziert = () => h.classList.contains('reduziert');
const fmt = n => n < 1024 * 1024 ? Math.max(1, Math.round(n / 1024)) + '\u00a0KB' : (n / 1024 / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 }) + '\u00a0MB';
const MB = 1024 * 1024;
/* Regeln je Ablage */
const REGELN = {
foto: { endungen: ['jpg', 'jpeg', 'png', 'heic', 'heif', 'webp'], typen: /^image\//, max: 15 * MB, anzahl: 6, art: 'Foto', arten: 'Fotos', erlaubt: 'JPG, PNG, HEIC und WebP', bild: true },
kompakt: { endungen: ['jpg', 'jpeg', 'png', 'heic', 'heif', 'webp'], typen: /^image\//, max: 15 * MB, anzahl: 6, art: 'Foto', arten: 'Fotos', erlaubt: 'JPG, PNG, HEIC und WebP', bild: true },
dokument: { endungen: ['pdf', 'doc', 'docx', 'odt'], typen: /^(application\/pdf|application\/msword|application\/vnd\.openxmlformats-officedocument\.wordprocessingml\.document|application\/vnd\.oasis\.opendocument\.text)$/, max: 5 * MB, anzahl: 3, art: 'Datei', arten: 'Dateien', erlaubt: 'PDF, Word oder ODT', bild: false }
};
const ZIEL = 2000, QUALI = 0.8; // lange Kante, JPEG-Qualität
/* Eine Ablage: echtes Dateifeld, Fläche, Liste, Prüfung */
function Ablage(feld) {
const art = feld.dataset.ablage, R = REGELN[art];
const input = feld.querySelector('input[type=file]');
const zone = feld.querySelector('.ablage');
const liste = feld.querySelector('.dateien');
const fehler = feld.querySelector('.fehlertext');
const eintraege = []; // {id, datei, ok, grund, el}
let zaehler = 0;
function endung(n) { const m = /\.([a-z0-9]+)$/i.exec(n); return m ? m[1].toLowerCase() : ''; }
function pruefe(f) {
const e = endung(f.name);
const typOk = R.endungen.includes(e) && (!f.type || R.typen.test(f.type)); // file.type kann leer sein
if (!typOk) return '„' + f.name + '“ ist ' + (R.bild ? 'kein Foto' : 'kein erlaubtes Format') + '. Erlaubt sind ' + R.erlaubt + '.';
if (f.size > R.max) return '„' + f.name + '“ ist ' + fmt(f.size) + ' groß. Erlaubt sind bis ' + fmt(R.max) + ' je ' + R.art + '.';
if (eintraege.filter(x => x.ok).length >= R.anzahl) return '„' + f.name + '“ ist eine ' + R.art + ' zu viel. Höchstens ' + R.anzahl + ' ' + R.arten + '.';
return '';
}
/* Fotos im Browser auf etwa 2000 px verkleinern (entfernt dabei auch Ortsdaten) */
async function verkleinere(f) {
try {
const bmp = await createImageBitmap(f);
const w = bmp.width, hgt = bmp.height, s = Math.min(1, ZIEL / Math.max(w, hgt));
const nw = Math.round(w * s), nh = Math.round(hgt * s);
const c = document.createElement('canvas'); c.width = nw; c.height = nh;
c.getContext('2d').drawImage(bmp, 0, 0, nw, nh); bmp.close && bmp.close();
const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', QUALI));
const name = f.name.replace(/\.[^.]+$/, '') + '.jpg';
return { datei: new File([blob], name, { type: 'image/jpeg', lastModified: Date.now() }), von: w + ' × ' + hgt, nach: nw + ' × ' + nh, vorher: f.size };
} catch (e) {
return { datei: f, von: '', nach: '', vorher: f.size, roh: true }; // z. B. HEIC: unverändert übernehmen
}
}
function karte(x) {
const li = document.createElement('li'); li.className = 'datei' + (x.ok ? '' : ' falsch'); x.el = li;
const bild = document.createElement(x.vorschau ? 'img' : 'span'); bild.className = 'bild' + (x.vorschau ? '' : ' typ');
if (x.vorschau) { bild.src = x.vorschau; bild.alt = ''; } else { const e = endung(x.name); bild.textContent = e ? e.toUpperCase().slice(0, 4) : '?'; if (e === 'pdf') bild.classList.add('pdf'); if (/^(doc|docx|odt)$/.test(e)) bild.classList.add('doc'); }
const text = document.createElement('div'); text.className = 'text';
const n = document.createElement('p'); n.className = 'name'; n.textContent = x.name;
const i = document.createElement('p'); i.className = 'info'; i.textContent = x.info;
text.append(n, i);
if (x.laedt) { const b = document.createElement('div'); b.className = 'balken'; b.innerHTML = '<i></i>'; text.append(b); }
const weg = document.createElement('button'); weg.type = 'button'; weg.className = 'entfernen';
weg.setAttribute('aria-label', x.name + ' entfernen');
if (liste.classList.contains('kacheln')) weg.innerHTML = '<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="2"/></svg>'; else weg.textContent = 'Entfernen';
weg.addEventListener('click', () => entferne(x, true));
li.append(bild, text, weg);
return li;
}
function aktualisiere() {
/* gültige Dateien ins echte Feld, damit das Formular normal sendet */
const dt = new DataTransfer();
eintraege.filter(x => x.ok && x.datei).forEach(x => dt.items.add(x.datei));
input.files = dt.files;
const falsch = eintraege.filter(x => !x.ok);
const msg = falsch.length ? falsch.map(x => x.grund).join(' ') + (falsch.length === 1 ? ' Bitte entfernen oder ersetzen.' : ' Bitte entfernen oder ersetzen.') : '';
input.setCustomValidity(msg);
input.setAttribute('aria-invalid', falsch.length ? 'true' : 'false');
if (zone) zone.classList.toggle('fehlerhaft', !!falsch.length);
setzeFehler(msg);
}
function setzeFehler(msg) {
fehler.innerHTML = '';
if (!msg) return;
fehler.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="currentColor"/><path d="M8 4v5M8 11v1.5" stroke="#fff" stroke-width="1.8"/></svg>';
const s = document.createElement('span'); s.textContent = msg; fehler.append(s);
}
async function nimm(liste_) {
for (const f of liste_) {
const grund = pruefe(f);
const x = { id: ++zaehler, name: f.name, ok: !grund, grund };
if (grund) { x.info = grund.replace(/^„[^“]+“ /, '').replace(/^ist /, ''); x.info = x.info.charAt(0).toUpperCase() + x.info.slice(1); eintraege.push(x); liste.append(karte(x)); continue; }
if (R.bild) {
x.laedt = true; x.info = fmt(f.size) + ' · wird verkleinert …';
eintraege.push(x); const li = karte(x); liste.append(li);
const erg = await verkleinere(f);
x.datei = erg.datei; x.name = erg.datei.name; x.laedt = false;
try { x.vorschau = URL.createObjectURL(erg.datei); } catch (e) {}
x.info = erg.roh ? fmt(f.size) + ' · unverändert übernommen' : erg.von + '\u00a0→ ' + erg.nach + ' px · ' + fmt(erg.vorher) + '\u00a0→ ' + fmt(erg.datei.size);
if (!eintraege.includes(x)) continue;
await new Promise(r => setTimeout(r, reduziert() ? 0 : 500));
if (!eintraege.includes(x)) continue;
const neu = karte(x); neu.classList.add('fertig'); neu.style.animation = 'none'; li.replaceWith(neu);
} else {
x.datei = f; x.info = fmt(f.size) + ' · ' + endung(f.name).toUpperCase();
eintraege.push(x); liste.append(karte(x));
}
aktualisiere();
}
aktualisiere();
}
function entferne(x, fokus) {
const i = eintraege.indexOf(x); if (i < 0) return;
eintraege.splice(i, 1);
if (x.vorschau) URL.revokeObjectURL(x.vorschau);
const el = x.el, naechster = eintraege[i] || eintraege[i - 1];
const fertig = () => { el.remove(); aktualisiere(); };
if (reduziert()) fertig(); else { el.classList.add('weg'); setTimeout(fertig, 160); }
ansage(x.name + ' entfernt. ' + eintraege.filter(e => e.ok).length + ' von ' + R.anzahl + ' ' + R.arten + '.');
if (fokus) setTimeout(() => { const ziel = naechster && naechster.el && naechster.el.querySelector('.entfernen'); (ziel || input).focus(); }, reduziert() ? 0 : 170);
}
function leeren() { eintraege.slice().forEach(x => { if (x.vorschau) URL.revokeObjectURL(x.vorschau); x.el && x.el.remove(); }); eintraege.length = 0; aktualisiere(); }
input.addEventListener('change', () => { const f = [...input.files]; nimm(f.filter(d => !eintraege.some(x => x.datei === d))); });
if (zone) {
let tiefe = 0;
zone.addEventListener('dragenter', e => { e.preventDefault(); tiefe++; zone.classList.add('drueber'); });
zone.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
zone.addEventListener('dragleave', () => { if (--tiefe <= 0) { tiefe = 0; zone.classList.remove('drueber'); } });
zone.addEventListener('drop', e => { e.preventDefault(); tiefe = 0; zone.classList.remove('drueber'); nimm([...e.dataTransfer.files]); });
}
return { feld, input, zone, nimm, leeren, eintraege, art, setzeFehler, R };
}
/* Fehlwurf neben die Fläche verlässt die Seite nicht */
addEventListener('dragover', e => e.preventDefault());
addEventListener('drop', e => e.preventDefault());
const status = document.createElement('p'); status.className = 'sr'; status.setAttribute('aria-live', 'polite'); document.body.append(status);
function ansage(t) { status.textContent = ''; setTimeout(() => { status.textContent = t; }, 50); }
let zeit = [], laeuft = false;
const ablagen = {};
document.querySelectorAll('[data-ablage]').forEach(f => { ablagen[f.dataset.ablage] = Ablage(f); });
/* Formulare: Prüfung nach Interaktion, Zusammenfassung beim Senden, Erfolg ohne Senden */
function feldFehler(el) {
const f = document.getElementById(el.getAttribute('aria-describedby').split(' ').pop());
if (el.type === 'file') return;
let t = '';
if (el.validity.valueMissing) t = el.closest('.welt-job') ? 'Bitte trag ' + (el.name === 'name' ? 'deinen Namen' : 'Telefon oder E-Mail') + ' ein.' : 'Bitte ' + (el.name === 'name' ? 'Ihren Namen' : 'eine Telefonnummer') + ' eintragen.';
el.setAttribute('aria-invalid', t ? 'true' : 'false');
f.textContent = t;
}
document.querySelectorAll('form').forEach(form => {
form.querySelectorAll('input[required]').forEach(el => el.addEventListener('blur', () => { if (el.value || el.dataset.beruehrt) feldFehler(el); el.dataset.beruehrt = '1'; }));
form.querySelectorAll('input[required]').forEach(el => el.addEventListener('input', () => { if (el.getAttribute('aria-invalid') === 'true') feldFehler(el); }));
form.addEventListener('submit', e => {
e.preventDefault(); // Demo: sendet nichts
const zf = form.querySelector('.zusammenfassung'), ul = zf.querySelector('ul'); ul.innerHTML = '';
const falsch = [...form.querySelectorAll('input')].filter(el => el.closest('.feld') && !el.closest('.feld[hidden]') && !el.checkValidity());
falsch.forEach(el => {
feldFehler(el);
const li = document.createElement('li'), a = document.createElement('a');
const label = el.type === 'file' ? document.getElementById(el.getAttribute('aria-labelledby').split(' ')[0]).firstChild.textContent.trim() : form.querySelector('label[for="' + el.id + '"]').textContent;
const eigen = el.type === 'file' ? el.validationMessage : document.getElementById(el.getAttribute('aria-describedby').split(' ').pop()).textContent;
a.href = '#' + el.id; a.textContent = label + ': ' + (eigen || 'bitte prüfen');
a.addEventListener('click', ev => { ev.preventDefault(); el.focus(); });
li.append(a); ul.append(li);
});
if (falsch.length) { zf.hidden = false; zf.focus({ preventScroll: !!laeuft }); return; }
zf.hidden = true;
const ok = form.nextElementSibling, ab = Object.values(ablagen).find(a => form.contains(a.feld) && !a.feld.hidden);
const n = ab ? ab.input.files.length : 0;
ok.querySelector('h2').textContent = form.id === 'form-job' ? 'Danke, angekommen.' : 'Danke. Ihre Anfrage ist da.';
ok.querySelector('.ok-text').textContent = form.id === 'form-job'
? (n ? 'Mit ' + (n === 1 ? 'einer Datei' : n + ' Dateien') + '. ' : '') + 'Wir melden uns innerhalb von drei Werktagen.'
: (n ? 'Mit ' + (n === 1 ? 'einem Foto' : n + ' Fotos') + '. ' : '') + 'Wir rufen Sie heute bis 16 Uhr zurück.';
form.hidden = true; ok.hidden = false; ok.focus({ preventScroll: !!laeuft });
});
});
document.querySelectorAll('[data-zurueck]').forEach(b => b.addEventListener('click', () => { const ok = b.closest('.erfolg'); ok.hidden = true; ok.previousElementSibling.hidden = false; ok.previousElementSibling.querySelector('input').focus(); }));
/* Beispieldateien für Demo und Vorführung (im Browser erzeugt) */
async function bildDatei(quelle, name, w, hgt, fleck) {
const c = document.createElement('canvas'); c.width = w; c.height = hgt; const g = c.getContext('2d');
try {
const img = new Image(); img.src = quelle; await img.decode();
const s = Math.max(w / img.width, hgt / img.height); g.drawImage(img, (w - img.width * s) / 2, (hgt - img.height * s) / 2, img.width * s, img.height * s);
} catch (e) { g.fillStyle = '#d8d2c8'; g.fillRect(0, 0, w, hgt); }
if (fleck) { const r = g.createRadialGradient(fleck[0] * w, fleck[1] * hgt, 0, fleck[0] * w, fleck[1] * hgt, fleck[2] * w); r.addColorStop(0, 'rgba(92, 70, 40, .55)'); r.addColorStop(.7, 'rgba(110, 88, 52, .25)'); r.addColorStop(1, 'rgba(110, 88, 52, 0)'); g.fillStyle = r; g.fillRect(0, 0, w, hgt); }
const blob = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.9));
return new File([blob], name, { type: 'image/jpeg' });
}
function leereDatei(name, typ, groesse, kopf) { const b = new Uint8Array(Math.round(groesse)); if (kopf) b.set(new TextEncoder().encode(kopf)); return new File([b], name, { type: typ }); }
async function beispiele(art) {
if (art === 'dokument') return [
leereDatei('Lebenslauf_Mira_Asmussen.pdf', 'application/pdf', 248 * 1024, '%PDF-1.7\n'),
leereDatei('Gesellenbrief.pages', '', 1.1 * MB, ''),
leereDatei('Zeugnisse_komplett.pdf', 'application/pdf', 8.4 * MB, '%PDF-1.7\n')
];
return [
await bildDatei('bilder/handwerk-bad-alt.webp', 'IMG_2041.jpg', 4032, 3024, [0.66, 0.78, 0.16]),
await bildDatei('bilder/handwerk-heizraum-hoch.webp', 'IMG_2042.jpg', 3024, 4032, [0.52, 0.86, 0.2]),
leereDatei('Video_Tropfen.mov', 'video/quicktime', 24 * MB, '')
];
}
/* Varianten */
const pultKnoepfe = [...document.querySelectorAll('.pult [data-v]')];
let v = 'foto';
function aktuelleAblage() { return v === 'dokument' ? ablagen.dokument : v === 'kompakt-mobil' ? ablagen.kompakt : ablagen.foto; }
function setzeVariante(neu, url) {
v = ['foto', 'dokument', 'kompakt-mobil'].includes(neu) ? neu : 'foto';
document.body.dataset.v = v;
document.getElementById('welt-hand').hidden = v === 'dokument';
document.getElementById('welt-job').hidden = v !== 'dokument';
document.getElementById('welt-hand').classList.toggle('welt-kompakt', v === 'kompakt-mobil');
ablagen.foto.feld.hidden = v !== 'foto';
ablagen.kompakt.feld.hidden = v !== 'kompakt-mobil';
pultKnoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v ? 'true' : 'false'));
if (url) { const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(history.state, '', u); }
}
pultKnoepfe.forEach(b => b.addEventListener('click', () => { pause(); zuruecksetzen(); setzeVariante(b.dataset.v, true); }));
document.getElementById('beispiel').addEventListener('click', async () => { pause(); const a = aktuelleAblage(); a.nimm(await beispiele(a.art)); });
function zuruecksetzen() {
Object.values(ablagen).forEach(a => a.leeren());
document.querySelectorAll('.erfolg').forEach(o => { o.hidden = true; o.previousElementSibling.hidden = false; });
document.querySelectorAll('.zusammenfassung').forEach(z => { z.hidden = true; });
document.querySelectorAll('form input:not([type=file])').forEach(el => { el.value = ''; el.removeAttribute('aria-invalid'); delete el.dataset.beruehrt; });
document.querySelectorAll('.feld .fehlertext').forEach(f => { f.textContent = ''; });
document.querySelectorAll('.ablage').forEach(z => z.classList.remove('drueber'));
}
/* Vorführung: Ziehen über die Fläche, Ablegen, Prüfen, Entfernen, Senden */
const warte = ms => new Promise(r => zeit.push(setTimeout(r, ms)));
async function runde(lauf) {
zuruecksetzen();
const a = aktuelleAblage(); const dateien = await beispiele(a.art);
if (laeuft !== lauf) return;
await warte(700); if (laeuft !== lauf) return;
if (a.zone) { a.zone.classList.add('drueber'); await warte(900); a.zone.classList.remove('drueber'); }
if (laeuft !== lauf) return;
await a.nimm(dateien);
await warte(1800); if (laeuft !== lauf) return;
const falsch = a.eintraege.filter(x => !x.ok);
for (const x of falsch) { x.el.querySelector('.entfernen').classList.add('gezeigt'); }
await warte(500); if (laeuft !== lauf) return;
for (const x of falsch) x.el.querySelector('.entfernen').click();
await warte(1100); if (laeuft !== lauf) return;
const form = a.feld.closest('form');
form.querySelector('input[name=name]').value = a.art === 'dokument' ? 'Mira Asmussen' : 'Jonas Peters';
form.querySelector('input[required]:not([name=name])').value = a.art === 'dokument' ? 'mira.asmussen@example.org' : '0212 555 018';
await warte(500); if (laeuft !== lauf) return;
form.requestSubmit();
await warte(2600); if (laeuft !== lauf) return;
runde(lauf);
}
function neustart() { pause(); zuruecksetzen(); setzeVariante(v, false); }
function vorfuehren() { pause(); laeuft = Symbol(); runde(laeuft); }
function pause() { laeuft = false; zeit.forEach(clearTimeout); zeit = []; }
function weiter() { vorfuehren(); }
setzeVariante(new URLSearchParams(location.search).get('v'), false);
})();
</script>
</body>
</html>