Web: Skeleton-Loading (schimmernde Ladeplatzhalter)

Statt nacktem 'Lädt…'/'Wird geladen…' jetzt geformte, schimmernde
Platzhalter an den zentralen Stellen:
- Neuer Baustein components/Skeleton.jsx (Skeleton/SkeletonLines/CardSkeleton),
  Sweep-Animation, Farben aus CSS-Tokens (Dark-Mode automatisch), respektiert
  prefers-reduced-motion.
- DataTable: bei Erstladung Platzhalterzeilen in Spaltenform (deckt alle
  8 Tabellenseiten). Nachladen mit vorhandenen Zeilen laesst Daten stehen.
- Dashboard-Karten: Zustand-Wrapper + Inline-Diagramm-Lader zeigen
  KPI-/Listen-/Diagramm-Skelette passend zur Kartenform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-04 00:30:25 +02:00
parent da5934948e
commit e214bb76a9
4 changed files with 126 additions and 17 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { Skeleton } from "./Skeleton";
/**
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
@@ -394,11 +395,30 @@ export default function DataTable({
</tr>
))}
{displayRows.length === 0 && (
loading && rows.length === 0 ? (
// Erstladung: schimmernde Platzhalterzeilen in Spaltenform statt
// eines nackten „Wird geladen…". (Beim Nachladen mit bereits
// vorhandenen Zeilen greift dieser Zweig nicht - die Daten
// bleiben stehen.)
Array.from({ length: 8 }, (_, r) => (
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
{orderedCols.map((c, ci) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>
<Skeleton
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
/>
</td>
))}
</tr>
))
) : (
// Während des Ladens NICHT „nichts gefunden" behaupten die Leer-
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
<tr><td className="empty" colSpan={orderedCols.length}>
{loading ? "Wird geladen…" : empty}
</td></tr>
)
)}
</tbody>
</table>

View File

@@ -0,0 +1,61 @@
/**
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
* durchgehend aria-hidden Screenreader sollen keine leeren Balken vorlesen.
*/
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
export function Skeleton({ w, h = 14, radius, className = "", style }) {
return (
<span
className={`skeleton ${className}`.trim()}
aria-hidden="true"
style={{ width: w, height: h, borderRadius: radius, ...style }}
/>
);
}
/** Mehrere gestapelte Text-Leisten unterschiedlicher Breite. */
export function SkeletonLines({ lines = 3 }) {
const breiten = ["90%", "72%", "82%", "60%", "76%"];
return (
<span className="skel-stack" aria-hidden="true">
{Array.from({ length: lines }, (_, i) => (
<Skeleton key={i} w={breiten[i % breiten.length]} />
))}
</span>
);
}
/**
* Kartenkörper-Skelett fürs Dashboard, grob in der Form des späteren Inhalts:
* - "kpi" Label-Zeile + große Wertzahl
* - "liste" ein paar Zeilen mit je zwei Werten (Standard)
* - "chart" ein großer Block, füllt die Karte
*/
export function CardSkeleton({ variant = "liste" }) {
if (variant === "kpi") {
return (
<span className="skel-stack" aria-hidden="true">
<Skeleton w="45%" h={12} />
<Skeleton w="60%" h={26} />
</span>
);
}
if (variant === "chart") {
// Höhe bewusst null -> keine Inline-Höhe, damit .skel-chart (flex:1) den
// Kartenkörper ausfüllt.
return <Skeleton className="skel-chart" w="100%" h={null} />;
}
// "liste"
return (
<span className="skel-stack" aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => (
<span className="skel-row" key={i}>
<Skeleton w={`${72 - i * 4}%`} />
<Skeleton w="18%" />
</span>
))}
</span>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import Icon from "../components/Icon";
import { CardSkeleton } from "../components/Skeleton";
import { useSettings } from "../settings";
import {
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
@@ -35,9 +36,9 @@ function useDaten(laden, abhaengig = []) {
return { daten, fehler };
}
function Zustand({ fehler, daten, children, leer = "Keine Daten." }) {
function Zustand({ fehler, daten, children, leer = "Keine Daten.", skelett = "liste" }) {
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (daten == null) return <div className="empty">Lädt</div>;
if (daten == null) return <CardSkeleton variant={skelett} />;
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
return children;
}
@@ -183,7 +184,7 @@ function KarteArtikel({ props: karteProps }) {
return <div className="empty">Kein Artikel gewählt über Anordnen einstellen.</div>;
}
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
{daten && (
<div className="card-stack">
<div className="kpi">
@@ -219,7 +220,7 @@ function Kennzahl({ wert, label, icon, ton }) {
function KarteStatus() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
<div className="kpi-row">
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
ton={daten?.expiring_soon ? "warn" : ""} />
@@ -234,7 +235,7 @@ function KarteStatus() {
function KarteArtikelzahl() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
</Zustand>
);
@@ -243,7 +244,7 @@ function KarteArtikelzahl() {
function KarteEinheiten() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
</Zustand>
);
@@ -321,7 +322,7 @@ function KarteEinkaufsliste({ props: karteProps }) {
});
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="liste" />;
const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
@@ -433,7 +434,7 @@ function KarteAblaufRing({ props: karteProps }) {
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird Karten-Einstellung.
const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true;
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="chart" />;
// Zustandsfarben, nie Serienfarben und immer mit Beschriftung.
const segmente = [
@@ -452,7 +453,7 @@ function KarteKategorienRing({ props: karteProps }) {
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="chart" />;
// Detailansicht: jede Kategorie einzeln statt der Bündelung zu „Andere“.
const segmente = mitFarben(
@@ -466,7 +467,7 @@ function KarteKategorienRing({ props: karteProps }) {
function KarteKategorienSaeulen() {
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="chart" />;
const zeilen = daten.slice(0, 8).map((k) => ({
name: k.name,
@@ -513,7 +514,7 @@ function KarteBestandsverlauf({ tage = 90 }) {
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="chart" />;
return (
<Line
@@ -572,7 +573,7 @@ function KarteBewegungsverlauf({ tage = 30 }) {
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="chart" />;
return (
<Wasserfall

View File

@@ -892,8 +892,35 @@ select.zeitraum:hover { color: var(--text); }
/* Wer Bewegung reduziert haben moechte, bekommt sie eingeblendet ohne Fahrt. */
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
.skeleton { animation: none; }
}
/* ---------- Skeleton (schimmernde Ladeplatzhalter) ---------- */
/* Farben aus Tokens -> passt sich Hell/Dunkel von allein an. Ueber den grauen
Grundton wandert ein hellerer Streifen (Sweep) von rechts nach links. */
.skeleton {
display: block;
background: linear-gradient(90deg,
var(--surface-2) 25%, var(--border) 37%, var(--surface-2) 63%);
background-size: 400% 100%;
animation: skeleton-glanz 1.4s ease infinite;
border-radius: var(--radius-sm);
}
@keyframes skeleton-glanz {
from { background-position: 100% 0; }
to { background-position: 0 0; }
}
.skel-stack { display: flex; flex-direction: column; gap: var(--sp-3); }
.skel-row {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3);
}
.skel-row .skeleton { flex: none; }
/* Diagramm-Platzhalter fuellt den Kartenkoerper. */
.skel-chart { flex: 1; min-height: 120px; height: auto; }
/* Tabellen-Platzhalterzeilen: kein Hover-Hintergrund. */
.table tbody tr.skel-tr:hover { background: transparent; }
/* ---------- Login ---------- */
.login-wrap { min-height: 100vh; display: grid; place-items: center; padding: var(--sp-4); }
.login-card { width: 100%; max-width: 360px; }