467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
222 lines
8.2 KiB
TypeScript
222 lines
8.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|