Abschnitt 20 · Lektion 7
Bau dir was
Das Abschlussprojekt endet mit dem Rat, vor einem Framework erst zwei oder drei kleine Sachen ohne zu bauen. Hier stehen Ideen dafür.
Den Kassenzettel und die Stoppuhr hat sich der Kurs ausgedacht, und abgenommen hat sie eine Prüfliste, die vorher wusste, was herauskommen soll. Das eigentliche Ziel sind aber Seiten, die du selbst benutzt, auf dem Telefon im Laden oder beim Training. Was auf deinem Rechner entsteht, kann ich von hier aus nicht prüfen, und ich tue auch nicht so, deshalb hat diese Lektion keine Aufgabe. Jede Idee hier geht mit dem, was du jetzt kannst, und setzt sich aus Stücken zusammen, die du im Kurs schon einmal gebaut hast.
Wie man eine eigene Seite angeht
Am Anfang steht kein Code, sondern die Frage, was sich die Seite merken muss. Bei einer Einkaufsliste sind das die Einträge und bei jedem, ob er schon im Wagen liegt. Wer das als Beispielobjekt hinschreiben kann, hat den Zustand, und alles andere ordnet sich um ihn herum.
Weiter geht es in der Reihenfolge aus dem Abschlussprojekt. Erst die Logik als Funktionen, die Werte hinein und Werte heraus reichen, dann eine Funktion, die aus dem Zustand die ganze Seite zeichnet, zuletzt die Ereignisse. Jeder Handler ändert nur den Zustand und lässt danach neu zeichnen. Soll die Seite ein Neuladen überstehen, kommt am Schluss der Speicher dazu, mit einem Präfix am Schlüssel und einem Standardzustand für den Fall, dass noch nichts gespeichert ist.
Die Dateien liegen dabei in einem Ordner auf deinem Rechner, so wie in der Lektion „Deine erste eigene Datei“. Ohne Module und ohne fetch reicht der Doppelklick, mit ihnen kommt die Seite über den kleinen lokalen Server von dort.
Am Ende nehme ich jede Seite mit der Abnahmeliste aus dem Abschlussprojekt ab. Die Console bleibt leer, die Seite lässt sich ohne Maus bedienen, sie übersteht ein Neuladen, und weder eine leere Eingabe noch kaputte Daten bringen sie durcheinander.
Die Einkaufsliste, die bleibt
<h1>Einkaufsliste</h1>
<form id="neu">
<label for="eintrag">Was fehlt?</label>
<input id="eintrag" name="eintrag" autocomplete="off" required>
<button type="submit">Dazuschreiben</button>
</form>
<ul id="liste"></ul> body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 0;
padding: 1rem;
line-height: 1.6;
color: #1c1917;
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.75rem;
}
button,
input {
font: inherit;
}
button {
padding: 0.4rem 0.8rem;
border: 1px solid #d6d3d1;
border-radius: 6px;
background: #fafaf9;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
}
li {
margin-bottom: 0.5rem;
}
/* Abgehakt heißt durchgestrichen. Dasselbe sagt
aria-pressed allen, die die Seite nicht sehen. */
button[aria-pressed="true"] {
text-decoration: line-through;
color: #57534e;
} const SCHLUESSEL = "einkauf:liste";
// Der Zustand ist die Liste selbst. Liegt noch nichts im
// Speicher, fängt sie mit zwei Einträgen an.
function laden() {
const roh = localStorage.getItem(SCHLUESSEL);
if (roh === null) {
return [
{ text: "Milch", erledigt: false },
{ text: "Brot", erledigt: true },
];
}
try {
return JSON.parse(roh);
} catch (fehler) {
return [];
}
}
function speichern(eintraege) {
localStorage.setItem(SCHLUESSEL, JSON.stringify(eintraege));
}
const eintraege = laden();
const liste = document.getElementById("liste");
const feld = document.getElementById("eintrag");
// Zeichnet immer die ganze Liste, jeder Eintrag ein Button.
function zeichnen() {
const zeilen = [];
for (const [index, eintrag] of eintraege.entries()) {
const button = document.createElement("button");
button.type = "button";
button.textContent = eintrag.text;
button.dataset.index = String(index);
button.setAttribute("aria-pressed", String(eintrag.erledigt));
const zeile = document.createElement("li");
zeile.append(button);
zeilen.push(zeile);
}
liste.replaceChildren(...zeilen);
}
document.getElementById("neu").addEventListener("submit", (event) => {
event.preventDefault();
const text = feld.value.trim();
if (text === "") return;
eintraege.push({ text, erledigt: false });
speichern(eintraege);
zeichnen();
feld.value = "";
});
// Ein Listener für alle Einträge, auch für die, die erst
// später dazukommen.
liste.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
const index = Number(button.dataset.index);
eintraege[index].erledigt = !eintraege[index].erledigt;
speichern(eintraege);
zeichnen();
liste.querySelectorAll("button")[index].focus();
});
zeichnen(); Die naheliegendste Idee ist die Liste, die du sowieso führst, nur dass sie jetzt auf dem Telefon liegt. Gebraucht werden ein Formular wie in Abschnitt 13, eine Liste aus Daten wie in Abschnitt 11, ein einziger Listener für alle Einträge wie bei der Event-Delegation und der Speicher aus Abschnitt 17. Neu ist davon nichts, nur die Zusammenstellung.
Jeder Eintrag ist ein Button mit aria-pressed, wie die Titel im Abschlussprojekt. Damit lässt sich die Liste mit Tab und Leertaste abhaken, und ein Screenreader sagt bei jedem Eintrag an, ob er gedrückt ist. Eine Zeile ist trotzdem dazugekommen, die letzte im Listener. zeichnen() ersetzt alle Buttons, auch den, der gerade den Fokus hatte, und ohne diese Zeile läge der Fokus danach auf keinem Eintrag mehr. Das nächste Tab finge dann wieder beim ersten an, und wer mit der Tastatur weiter unten abhakt, liefe jedes Mal die ganze Liste herunter.
Nach einem Klick auf Seite neu laden steht alles wieder da, auch das Abgehakte. Ausbauen lässt sich die Liste mit einem Button, der alles Erledigte auf einmal entfernt, oder mit einer Menge hinter jedem Eintrag.
Die Stoppuhr mit Runden
<h1>Stoppuhr</h1>
<p id="anzeige"></p>
<button id="start" type="button">Start</button>
<button id="runde" type="button">Runde</button>
<button id="stopp" type="button">Stopp</button>
<ol id="runden"></ol> body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 0;
padding: 1rem;
line-height: 1.6;
color: #1c1917;
}
h1 {
font-size: 1.5rem;
margin: 0 0 0.75rem;
}
#anzeige {
font-size: 2rem;
font-variant-numeric: tabular-nums;
margin: 0 0 0.75rem;
}
button {
font: inherit;
padding: 0.4rem 0.8rem;
border: 1px solid #d6d3d1;
border-radius: 6px;
background: #fafaf9;
cursor: pointer;
}
ol {
font-variant-numeric: tabular-nums;
} const anzeige = document.getElementById("anzeige");
const rundenListe = document.getElementById("runden");
let gestartet = 0; // Zeitpunkt des Starts
let endstand = 0; // die Zeit beim Stopp
let kennung = null; // das laufende Intervall, sonst null
let runden = []; // der Gesamtstand am Ende jeder Runde
// Wie bei der Stoppuhr aus Abschnitt 15: MM:SS:HH.
function formatieren(ms) {
const hundertstel = Math.floor(ms / 10);
const teile = [
Math.floor(hundertstel / 6000),
Math.floor(hundertstel / 100) % 60,
hundertstel % 100,
];
return teile.map((zahl) => String(zahl).padStart(2, "0")).join(":");
}
function stand() {
return kennung === null ? endstand : performance.now() - gestartet;
}
function zeichnen() {
anzeige.textContent = formatieren(stand());
}
// Eine Runde ist der Abstand zum Stand davor.
function rundenZeichnen() {
const zeilen = [];
let vorher = 0;
for (const gesamt of runden) {
const zeile = document.createElement("li");
zeile.textContent = `${formatieren(gesamt - vorher)}, gesamt ${formatieren(gesamt)}`;
zeilen.push(zeile);
vorher = gesamt;
}
rundenListe.replaceChildren(...zeilen);
}
document.getElementById("start").addEventListener("click", () => {
if (kennung !== null) return;
gestartet = performance.now();
runden = [];
rundenZeichnen();
kennung = setInterval(zeichnen, 30);
});
document.getElementById("runde").addEventListener("click", () => {
if (kennung === null) return;
runden.push(stand());
rundenZeichnen();
});
document.getElementById("stopp").addEventListener("click", () => {
if (kennung === null) return;
endstand = stand();
clearInterval(kennung);
kennung = null;
zeichnen();
});
zeichnen(); Die Stoppuhr aus Abschnitt 15, diesmal mit Runden. Wer läuft oder schwimmt, will nicht nur die ganze Zeit wissen, sondern auch, wie lange jede einzelne Runde gedauert hat. Gerechnet wird wie dort mit performance.now() und nicht durch Zählen, und festgehalten wird bei jeder Runde nur der Gesamtstand. Wie lange eine Runde gedauert hat, ist der Abstand zum Stand davor, und den rechnet erst das Zeichnen aus.
Ein Neuladen übersteht diese Uhr nicht, und mit performance.now() allein ginge es auch nicht, weil die Zahl mit jeder Seite neu bei null anfängt. Wer die Uhr über einen Neustart retten will, legt den Startzeitpunkt als Date.now() in den Speicher und nimmt in Kauf, dass die Uhr des Rechners einmal springen kann.
Weitere Ideen
Der Kassenzettel als Seite statt in der Console, mit einem Formular für Posten und Preis und einer Summe, die sich bei jedem neuen Posten selbst nachrechnet, in Cent wie im ganzen Kurs. Die Bestenliste für die eigene Spielrunde aus einer JSON-Datei, die neben der Seite liegt, mit allen vier Ladezuständen. Eine lange Seite zum Lesen, die sich mit einem IntersectionObserver und dem Speicher merkt, welche Abschnitte schon einmal im Bild waren.
Danach kommt noch der Test zu diesem Abschnitt, und dann hast du JavaScript durch.
Zum Mitnehmen
Diese Lektion hat keine Aufgabe, sie sammelt Ideen für eigene Seiten, die mit diesem Kurs schon gehen. Angehen lässt sich jede in der Reihenfolge aus dem Abschlussprojekt, erst die Daten, dann die Logik, dann die Darstellung, zuletzt die Bedienung, und am Ende kommt die Abnahmeliste.
Jetzt du
Basis Konto, kostenlosIm Editor änderst du die Beispiele dieser Lektion und lässt sie gleich laufen. So merkst du am schnellsten, ob es sitzt.
Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.
Was in dieser Lektion steckt
-
Artikel mit 2 Beispielen zum Ausprobieren
Steht hier, ohne Konto lesbar.