Files
Dashboard/features/dashboard/components/tables/DataTable.tsx
T
dejanandClaude Opus 5 4cb0b5c799 feat(responsive): Breakpoints für 375–1920 px
Der Prototyp war auf Schreibtischbreiten gebaut. Bei 375 px lief die Seite um
306 px seitlich über, und dem Inhalt blieben 135 px, weil die Seitenleiste ihre
vollen 240 px behielt.

Seitenleiste (an HiveGrid abgeglichen, nicht erfunden):
- Unterhalb 768 px faehrt sie als Schublade ueber den Inhalt. Derselbe
  Breakpoint, den die Software als MOBILE_BREAKPOINT fuehrt.
- Hamburger links in der Kopfzeile, FeatherMenu, 32x32, md:hidden, vor dem
  Breadcrumb — wie SubframeAppShell ihn rendert.
- Abdeckung dahinter, Escape schliesst, Fokus wandert beim Oeffnen auf den
  ersten Eintrag und kehrt beim Schliessen zum Ausloeser zurueck.
- Fokusfalle mit Tab und Umschalt+Tab: Der Rahmen traegt role=dialog und
  aria-modal, ohne Falle waere das gegenueber einem Screenreader unwahr.
- Der Einklapp-Knopf ist unterhalb md ausgeblendet. Er ragt bauartbedingt
  17 px nach rechts heraus und blieb sonst als angeschnittener, wirkungsloser
  Rest bei x = -16 am Bildrand stehen — der gemeldete Fehler.

Inhalte: Raster einspaltig ab 375 px, Seitenkopf mit Aktionen unter dem Titel
und Icon-only, Zeitraum-Segment und Reiterstreifen waagerecht scrollend statt
umbrechend (ein Segment ueber zwei Zeilen liest sich als zwei Steuerelemente),
Filter-Panel auf volle Zeilenbreite, Tabellen mit eigenem Scrollbereich.

Zwei abgeschnittene Fehler mitbehoben, die der Prueferer nicht sieht:
- KPI-Label lief bei 1280 px um 5 px aus seinem Kasten; bricht jetzt um. Die
  Karte wird dadurch eine Zeile hoeher — der Preis fuer eine vollstaendig
  lesbare Beschriftung.
- Randmarken der Auslastungsachse ragten je rund 10 px ueber die Spur. In sehr
  schmalen Karten entfaellt die Achse ganz; dort bleibt nur rund 40 px Spur,
  auf der selbst zwei Marken aneinanderstossen.

verify-layout deckt jetzt 375-1920 px ab (vorher ab 1024) und meldet zusaetzlich
Seitenueberlauf: ein waagerechter Scrollbalken auf Dokumentebene ist immer ein
Fehler, nie ein Kompromiss. 7 Seiten x 9 Breiten gruen.

Ab 1024 px ist der abgenommene Stand unveraendert — numerisch geprueft ueber die
Bounding-Boxen aller Karten, Raster, Tabellen und Kopfelemente.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 21:46:51 +02:00

222 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React from "react";
import {
FeatherArrowDown,
FeatherArrowUp,
FeatherChevronLeft,
FeatherChevronRight,
FeatherSearch,
} from "@subframe/core";
export interface Spalte<T> {
key: string;
titel: string;
/** Zellinhalt; darf beliebige Knoten liefern (Badges, Links). */
zelle: (zeile: T) => React.ReactNode;
/** Sortierwert — ohne diesen ist die Spalte nicht sortierbar. */
sortWert?: (zeile: T) => number | string;
/** Zahlen gehören rechtsbündig und in Tabellenziffern. */
numerisch?: boolean;
breite?: string;
}
/**
* Tabelle für Arbeitslisten (offene Rechnungen, Aufträge, Kunden).
*
* Enthält die Interaktionen, die man in einer Demo wirklich braucht:
* Sortieren per Klick auf den Spaltenkopf, Volltextsuche, Blättern.
* Alles clientseitig — bei echten Datenmengen gehört das auf den Server.
*/
export function DataTable<T extends { id: string }>({
zeilen,
spalten,
proSeite = 8,
suchbar = true,
suchFelder,
leerText = "Keine Einträge",
}: {
zeilen: T[];
spalten: Spalte<T>[];
proSeite?: number;
suchbar?: boolean;
/** Felder, die die Suche durchsucht. */
suchFelder?: (zeile: T) => string;
leerText?: string;
}) {
const [suche, setSuche] = React.useState("");
const [seite, setSeite] = React.useState(0);
const [sortierung, setSortierung] = React.useState<{ key: string; richtung: "asc" | "desc" } | null>(
null
);
const gefiltert = React.useMemo(() => {
if (!suche.trim() || !suchFelder) return zeilen;
const begriff = suche.toLowerCase();
return zeilen.filter((zeile) => suchFelder(zeile).toLowerCase().includes(begriff));
}, [zeilen, suche, suchFelder]);
const sortiert = React.useMemo(() => {
if (!sortierung) return gefiltert;
const spalte = spalten.find((s) => s.key === sortierung.key);
if (!spalte?.sortWert) return gefiltert;
return [...gefiltert].sort((a, b) => {
const wertA = spalte.sortWert!(a);
const wertB = spalte.sortWert!(b);
const vergleich =
typeof wertA === "number" && typeof wertB === "number"
? wertA - wertB
: String(wertA).localeCompare(String(wertB), "de");
return sortierung.richtung === "asc" ? vergleich : -vergleich;
});
}, [gefiltert, sortierung, spalten]);
const seiten = Math.max(1, Math.ceil(sortiert.length / proSeite));
const aktuelleSeite = Math.min(seite, seiten - 1);
const sichtbar = sortiert.slice(aktuelleSeite * proSeite, (aktuelleSeite + 1) * proSeite);
const sortiereNach = (key: string) => {
setSeite(0);
setSortierung((aktuell) =>
aktuell?.key === key
? { key, richtung: aktuell.richtung === "asc" ? "desc" : "asc" }
: { key, richtung: "desc" }
);
};
return (
<div className="flex min-w-0 flex-col gap-3">
{suchbar && suchFelder ? (
<div className="flex h-8 items-center gap-2 rounded-md border border-solid border-border-default px-2.5">
<FeatherSearch className="text-[14px] text-text-neutral-tertiary" />
<input
value={suche}
onChange={(event) => {
setSuche(event.target.value);
setSeite(0);
}}
placeholder="In Liste suchen …"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-text-neutral-subtle"
/>
</div>
) : null}
{/* Der waagerechte Scrollbereich der Tabelle. Ohne min-w-0 am Elternteil
greift er nicht: Ein Flex-Kind wird nicht schmaler als sein Inhalt,
die Tabelle bliebe also breit und schöbe stattdessen die Seite auf.
Vorher trug der Bereich zusätzlich -mx-1/px-1 — das machte ihn 8 px
breiter als seine Spalte und ließ die Karte um 4 px überlaufen. Den
Fokusring der Sortierknöpfe deckt bereits das px-2 der Kopfzelle. */}
<div className="dv-scroll w-full min-w-0 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-solid border-border-default">
{spalten.map((spalte) => {
const aktiv = sortierung?.key === spalte.key;
return (
<th
key={spalte.key}
style={{ width: spalte.breite }}
aria-sort={
!spalte.sortWert
? undefined
: aktiv
? sortierung!.richtung === "asc"
? "ascending"
: "descending"
: "none"
}
className={`px-2 pb-2 text-[12px] font-medium text-text-neutral-tertiary ${
spalte.numerisch ? "text-right" : "text-left"
}`}
>
{spalte.sortWert ? (
<button
onClick={() => sortiereNach(spalte.key)}
className={`inline-flex items-center gap-1 hover:text-text-neutral-primary ${
spalte.numerisch ? "flex-row-reverse" : ""
} ${aktiv ? "text-text-neutral-primary" : ""}`}
>
{spalte.titel}
{aktiv ? (
sortierung!.richtung === "asc" ? (
<FeatherArrowUp className="text-[11px]" />
) : (
<FeatherArrowDown className="text-[11px]" />
)
) : null}
</button>
) : (
spalte.titel
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{sichtbar.map((zeile) => (
<tr
key={zeile.id}
className="border-b border-solid border-border-default last:border-b-0"
>
{spalten.map((spalte) => (
<td
key={spalte.key}
className={`px-2 py-2.5 text-[13px] text-text-neutral-primary ${
spalte.numerisch ? "text-right tabular-nums" : "text-left"
}`}
>
{spalte.zelle(zeile)}
</td>
))}
</tr>
))}
{!sichtbar.length ? (
<tr>
<td
colSpan={spalten.length}
className="px-2 py-10 text-center text-[13px] text-text-neutral-tertiary"
>
{leerText}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{sortiert.length > proSeite ? (
<div className="dv-no-print flex items-center justify-between">
<span className="text-[12px] text-text-neutral-tertiary">
{aktuelleSeite * proSeite + 1}–{Math.min((aktuelleSeite + 1) * proSeite, sortiert.length)} von{" "}
{sortiert.length}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setSeite((s) => Math.max(0, s - 1))}
disabled={aktuelleSeite === 0}
aria-label="Vorherige Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronLeft className="text-[14px]" />
</button>
<span className="px-1 text-[12px] tabular-nums text-text-neutral-secondary">
{aktuelleSeite + 1} / {seiten}
</span>
<button
onClick={() => setSeite((s) => Math.min(seiten - 1, s + 1))}
disabled={aktuelleSeite >= seiten - 1}
aria-label="Nächste Seite"
className="flex h-8 w-8 items-center justify-center rounded-md border border-solid border-border-default text-text-neutral-secondary disabled:opacity-40 enabled:hover:bg-neutral-50"
>
<FeatherChevronRight className="text-[14px]" />
</button>
</div>
</div>
) : null}
</div>
);
}