diff --git a/docs/PLAN.md b/docs/PLAN.md index fedcf9f8..e3441e4a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -255,6 +255,7 @@ Größe vor dem Start messen: `store.mjs` (570), `app.js` (504/527) und `store.t | DS-X-15 | Designsystem-Galerie, Hülle und Grundlagen (D6): Einstellungen › Version › „Designsystem ansehen“ öffnet `#/designsystem` (eigener `React.lazy`-Chunk, außerhalb von `ROUTES`, also nicht in Seitenleiste oder Palette); Navigation über die Register-Familien, Seiten Farben (Kontrast zur Grundfläche), Schrift, Abstand, Radius, Glas, Bewegung (ruht bei reduzierter Bewegung); keine echten Daten, Beispielinhalt trägt „Beispieldaten“ | Implementierer · Claude; Root nimmt ab | DS-Blueprint-Schnitt 16.10. (DS-X-15) | Server | B | Erledigt: #1662 (merged 2026-10-16); Geschichten je Baustein kommen mit DS-X-16/17, Werkzeugleiste und Übertrag aus #1656 (DS-X-14b) nicht enthalten | Tests rot→grün; `vitest run`, typecheck, `contrast-check`, `plan-check --offline` und prepush Exit 0; Bildschirmfotos hell/dunkel; zwei Stufe-B-Reviews fremder Nicht-Anthropic-Familien | – | | DS-X-14b | Register-Abdeckung gehärtet und Galerie-Routentest (Review-Nachträge zu #1656/#1662): der Abdeckungstest erkennt jede exportierte Designkomponente (`export function`, `export const`, `export default function`), schlägt bei einem nicht erkennbaren Exportstil laut fehl, ohne Zählschwelle, Familie = oberster Ordner unter `src/design`, Pfade portabel verglichen; `isGallery` ignoriert `?query`; Einhänge-Test: `#/designsystem` zeigt die Galerie | Implementierer · Claude; Root nimmt ab | Root-Dispositionen #1656 (R1656-K1/G1, K2, K5/G2, G3) und #1662 (R1662-K1, K2) | Server | B | Erledigt: #1671 (merged 2026-10-17); die schärfere Suche fand keinen fehlenden Registereintrag | Tests rot→grün; `vitest run src/design src/shell`, typecheck, `plan-check --offline` und prepush Exit 0; zwei Stufe-B-Reviews fremder Nicht-Anthropic-Familien | – | | DS-X-07 | Austrittsübergänge: `useExitTransition` (`src/design/motion/`) hält einen schließenden Knoten für `--g-dur-exit` eingehängt und setzt `data-state="closing"`, bei reduzierter Bewegung (System oder Aus) wird sofort ausgehängt; `Sheet` und `Toast` nutzen ihn, `motion.css` trägt die Austrittsregeln; die `.g-is-new`-Einblendung für neue Agentenzeilen bleibt aus (nicht in diesem Paket) | Implementierer · Claude; Root nimmt ab | DS-X-03 (#1606); DS-Blueprint-Schnitt 16.10. (Zeile DS-X-07) | Server | B | In Arbeit: Draft-PR | red-first Vitest-Tests; `vitest run src/design`, typecheck, contrast-check, `plan-check --offline` und prepush Exit 0; ein unabhängiger Stufe-B-Review (Root beauftragt) | – | +| DS-X-09 | Tabelle v2 (Fabrik-Designsystem, Welle 1): `src/design/data/Table.tsx` bekommt sortierbare Spaltenköpfe (`aria-sort`, Enter und Leertaste), optionale Zeilenauswahl mit dem Ankreuzfeld (Kopf-Feld „teils“, Anzahl wird angesagt), eine feststehende Kopfzeile sowie Platzhalter für leer und lädt über `HonestState`; bestehende Aufrufer bleiben unverändert | Implementierer · Claude; Root nimmt ab | DS-X-05 (#1678); DS-Blueprint-Schnitt 16.10. (Zeile DS-X-09) | Server | B | In Arbeit: Entwurfs-PR `claude/ds-x-09`; keine Ansicht umgestellt (KR-07*, 10e folgen) | Tests rot→grün (`red-first.sh` Exit 0); `vitest run src/design`, typecheck, `contrast-check`, `plan-check --offline` und prepush Exit 0; Bildschirmfotos hell/dunkel; ein Stufe-B-Review einer Nicht-Anthropic-Familie, Root kommissioniert | – | | DS-T-03 | Vertrauensfehler P1-b/e: die Kartenlampe sagt, was bewiesen ist (`lampFor.ts`, `lamps/texts.ts`, `AgentCard.tsx`, `FlowTab.tsx`) | Implementierer · Claude; Root nimmt ab | Lampenform aus DS-T-02 (#1610) | Server | B | Erledigt: #1624 (merged 2026-10-16); unbekannte Spalte oder unbekannter Worker-Status zeigt auf Karte und Fabrik-Knoten die graue Unbekannt-Lampe statt „Läuft“, die grüne Lampe heißt „Test bestanden“ mit dem Alter des Testlaufs; welcher Stand getestet wurde, bleibt ein späteres Nahtpaket | fünf Test-First-Tests rot an der Merge-Base, grün am Kopf (`red-first.sh` Exit 0); typecheck, `node scripts/contrast-check.mjs` und prepush Exit 0; zwei Stufe-B-Reviews verschiedener Nicht-Autor-Familien: Subscription + OpenRouter; Root-Abnahme | – | Historisch (08.10.); Quotenteil ersetzt durch AGENTS.md „Routing“, Reviewweg durch `docs/decisions.md` „Reviewer count per tier“ (beide Stand 15.10.): Routing-Freigabe Nutzer 08.10. ~21:25Z: Codex frei bis tatsächlich 98 % nutzen; diff --git a/src/design/data/Table.tsx b/src/design/data/Table.tsx index aaba82c0..df16d6e8 100644 --- a/src/design/data/Table.tsx +++ b/src/design/data/Table.tsx @@ -1,4 +1,7 @@ -import type { ReactNode } from "react"; +import { useState, type KeyboardEvent, type ReactNode } from "react"; +import { de } from "../../i18n/de"; +import { Checkbox } from "../forms/Checkbox"; +import { HonestState } from "./HonestState"; import "./data.css"; export interface Column { @@ -6,6 +9,15 @@ export interface Column { header: string; numeric?: boolean; cell: (row: Row) => ReactNode; + /** Makes the header a sort button; the value is compared as a number or as text. */ + sortValue?: (row: Row) => string | number; +} + +export interface TableSelection { + selected: ReadonlySet; + onChange: (next: Set) => void; + /** Accessible name part of a row's checkbox; the row key when missing. */ + rowLabel?: (row: Row) => string; } export interface TableProps { @@ -14,28 +26,91 @@ export interface TableProps { columns: readonly Column[]; rows: readonly Row[]; rowKey: (row: Row) => string; - /** Shown instead of the table for zero rows (use HonestState). */ + /** Shown instead of the table for zero rows; plain text is drawn as HonestState. */ empty?: ReactNode; + /** Replaces the table with a HonestState while data loads. */ + loading?: boolean; + /** Adds a checkbox column; the caller owns the selected keys. */ + selection?: TableSelection; } -export function Table({ label, columns, rows, rowKey, empty }: TableProps) { +type SortDir = "ascending" | "descending"; + +const fill = (text: string, vars: Record) => text.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k])); +const compare = (a: string | number, b: string | number) => + typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), "de", { numeric: true }); + +export function Table({ label, columns, rows, rowKey, empty, loading, selection }: TableProps) { + // none -> ascending -> descending -> none; one column at a time. + const [sort, setSort] = useState<{ key: string; dir: SortDir } | null>(null); + if (loading) return
; if (rows.length === 0) { + if (typeof empty === "string") return ; if (empty) return <>{empty}; return null; } const num = (c: Column) => (c.numeric ? "g-tbl__n" : undefined); - return ( + const cycle = (key: string) => setSort((s) => (s?.key !== key ? { key, dir: "ascending" } : s.dir === "ascending" ? { key, dir: "descending" } : null)); + const column = columns.find((c) => c.key === sort?.key); + const shown = !sort || !column?.sortValue ? rows : [...rows].sort((a, b) => compare(column.sortValue!(a), column.sortValue!(b)) * (sort.dir === "ascending" ? 1 : -1)); + const keys = rows.map(rowKey); + const picked = selection ? keys.filter((k) => selection.selected.has(k)).length : 0; + const toggle = (key: string, on: boolean) => { + const next = new Set(selection!.selected); + if (on) next.add(key); else next.delete(key); + selection!.onChange(next); + }; + const header = (c: Column) => { + if (!c.sortValue) return {c.header}; + const dir = sort?.key === c.key ? sort.dir : "none"; + // Enter and Space are handled here, not left to the click a browser derives from them, so both paths cycle exactly once. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + cycle(c.key); + }; + return ( + + + + ); + }; + const table = (
- {columns.map((c) => )} + + {selection && ( + + )} + {columns.map(header)} + - {rows.map((row) => ( - {columns.map((c) => )} - ))} + {shown.map((row) => { + const key = rowKey(row); + return ( + + {selection && ( + + )} + {columns.map((c) => )} + + ); + })}
{c.header}
+ {de["table.selectAll"]}} checked={picked === 0 ? false : picked === keys.length ? true : "mixed"} onCheckedChange={(on) => selection.onChange(on ? new Set(keys) : new Set())} /> +
{c.cell(row)}
+ {fill(de["table.selectRow"], { row: selection.rowLabel?.(row) ?? key })}} checked={selection.selected.has(key)} onCheckedChange={(on) => toggle(key, on)} /> + {c.cell(row)}
); + if (!selection) return table; + return ( + <> +

{fill(de["table.selected"], { n: picked })}

+ {table} + + ); } diff --git a/src/design/data/data.css b/src/design/data/data.css index 278f8ebc..4e411e15 100644 --- a/src/design/data/data.css +++ b/src/design/data/data.css @@ -7,13 +7,26 @@ background: var(--g-content-bg); border: 1px solid var(--g-hair); border-radius: var(--g-r-card); min-width: 0; } .g-glass .g-tw, .g-chrome .g-tw, .g-glass .g-hs, .g-chrome .g-hs { background: var(--g-tint); } -.g-tw { overflow-x: auto; } +.g-tw { overflow: auto; max-height: 100%; } .g-tw:focus-visible { outline: 2px solid var(--g-accent); outline-offset: 2px; } .g-tbl { width: 100%; border-collapse: collapse; } .g-tbl th { font: 500 12px/1.3 var(--g-font-sans); color: var(--g-muted); text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--g-line); white-space: nowrap; + position: sticky; top: 0; z-index: 1; + /* The content surface is translucent: rows would show through, so it sits on the opaque ground. */ + background: linear-gradient(var(--g-content-bg), var(--g-content-bg)), var(--g-ground); } +.g-glass .g-tbl th, .g-chrome .g-tbl th { background: linear-gradient(var(--g-tint), var(--g-tint)), var(--g-ground); } +.g-tbl__sort { all: unset; cursor: pointer; } +.g-tbl__sort:focus-visible { outline: var(--g-focus); outline-offset: var(--g-focus-offset); } +.g-tbl th[aria-sort] .g-tbl__sort::after { content: " \2195"; opacity: .5; } +.g-tbl th[aria-sort="ascending"] .g-tbl__sort::after { content: " \2191"; opacity: 1; } +.g-tbl th[aria-sort="descending"] .g-tbl__sort::after { content: " \2193"; opacity: 1; } +.g-tbl th[aria-sort="ascending"], .g-tbl th[aria-sort="descending"] { color: var(--g-ink); } +.g-tbl .g-tbl__sel { width: 1%; padding-right: 0; } +.g-tbl tbody tr[aria-selected="true"] td { background: var(--g-tint); } +.g-tbl__count { margin: 0 0 4px; font: 500 12px/1.3 var(--g-font-sans); color: var(--g-muted); } .g-tbl td { padding: 0 12px; height: 44px; border-bottom: 1px solid var(--g-hair); font-size: 13px; color: var(--g-ink); vertical-align: middle; } .g-tbl tbody tr:last-child td { border-bottom: 0; } .g-tbl .g-tbl__n { text-align: right; font-variant-numeric: tabular-nums; } diff --git a/src/design/data/table.selection.test.tsx b/src/design/data/table.selection.test.tsx new file mode 100644 index 00000000..67ce0ac2 --- /dev/null +++ b/src/design/data/table.selection.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { expect, it } from "vitest"; + +import { Table } from "./Table"; + +const rows = [{ id: "a", name: "Alpha" }, { id: "b", name: "Beta" }, { id: "c", name: "Gamma" }]; +type Row = (typeof rows)[number]; +const columns = [{ key: "name", header: "Name", cell: (r: Row) => r.name }]; + +function Harness() { + const [selected, setSelected] = useState>(new Set()); + return r.id} selection={{ selected, onChange: setSelected, rowLabel: (r) => r.name }} />; +} + +it("selection announces count", () => { + render(); + const live = screen.getByRole("status"); + expect(live).toHaveAttribute("aria-live", "polite"); + expect(live).toHaveTextContent("0 ausgewählt"); + fireEvent.click(screen.getByRole("checkbox", { name: "Zeile auswählen: Alpha" })); + expect(live).toHaveTextContent("1 ausgewählt"); + expect(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" })).toBePartiallyChecked(); + fireEvent.click(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" })); + expect(live).toHaveTextContent("3 ausgewählt"); + expect(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" })).toBeChecked(); + fireEvent.click(screen.getByRole("checkbox", { name: "Alle Zeilen auswählen" })); + expect(live).toHaveTextContent("0 ausgewählt"); + expect(screen.getAllByRole("row")[1]).not.toHaveAttribute("aria-selected", "true"); +}); + +it("marks selected rows with aria-selected", () => { + render(); + fireEvent.click(screen.getByRole("checkbox", { name: "Zeile auswählen: Beta" })); + expect(screen.getAllByRole("row")[2]).toHaveAttribute("aria-selected", "true"); + expect(screen.getAllByRole("row")[1]).toHaveAttribute("aria-selected", "false"); +}); + +it("renders no checkboxes or live region without a selection prop", () => { + render(
r.id} />); + expect(screen.queryByRole("checkbox")).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); +}); diff --git a/src/design/data/table.sort.test.tsx b/src/design/data/table.sort.test.tsx new file mode 100644 index 00000000..b973ec41 --- /dev/null +++ b/src/design/data/table.sort.test.tsx @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { expect, it } from "vitest"; + +import { Table } from "./Table"; + +const css = readFileSync(resolve(__dirname, "data.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + +const rows = [{ id: "b", name: "Beta", n: 12 }, { id: "a", name: "Alpha", n: 7 }, { id: "c", name: "Gamma", n: 30 }]; +type Row = (typeof rows)[number]; +const columns = [ + { key: "name", header: "Name", sortValue: (r: Row) => r.name, cell: (r: Row) => r.name }, + { key: "n", header: "Count", numeric: true, sortValue: (r: Row) => r.n, cell: (r: Row) => r.n }, + { key: "plain", header: "Plain", cell: () => "-" }, +]; +const names = () => screen.getAllByRole("row").slice(1).map((r) => within(r).getAllByRole("cell")[0].textContent); + +it("cycles aria-sort from none to ascending to descending by keyboard", () => { + render(
r.id} />); + const th = screen.getByRole("columnheader", { name: "Name" }); + const button = within(th).getByRole("button", { name: "Name" }); + expect(th).toHaveAttribute("aria-sort", "none"); + expect(names()).toEqual(["Beta", "Alpha", "Gamma"]); + button.focus(); + // jsdom turns no key into a click, so Enter and Space must be handled on the button itself. + fireEvent.keyDown(button, { key: "Enter" }); + expect(th).toHaveAttribute("aria-sort", "ascending"); + expect(names()).toEqual(["Alpha", "Beta", "Gamma"]); + fireEvent.keyDown(button, { key: " " }); + expect(th).toHaveAttribute("aria-sort", "descending"); + expect(names()).toEqual(["Gamma", "Beta", "Alpha"]); + fireEvent.click(button); + expect(th).toHaveAttribute("aria-sort", "none"); + expect(names()).toEqual(["Beta", "Alpha", "Gamma"]); +}); + +it("sorts one column at a time and compares numbers numerically", () => { + render(
r.id} />); + fireEvent.click(screen.getByRole("button", { name: "Name" })); + fireEvent.click(screen.getByRole("button", { name: "Count" })); + expect(screen.getByRole("columnheader", { name: "Name" })).toHaveAttribute("aria-sort", "none"); + expect(screen.getByRole("columnheader", { name: "Count" })).toHaveAttribute("aria-sort", "ascending"); + expect(names()).toEqual(["Alpha", "Beta", "Gamma"]); + expect(screen.getByRole("columnheader", { name: "Plain" })).not.toHaveAttribute("aria-sort"); + expect(screen.queryByRole("button", { name: "Plain" })).toBeNull(); +}); + +it("renders the empty slot text through HonestState and the loading slot instead of the table", () => { + const { rerender } = render(
r.id} empty="Keine Agenten" />); + expect(screen.getByText("Keine Agenten")).toHaveClass("g-hs__title"); + expect(screen.queryByRole("table")).toBeNull(); + rerender(
r.id} loading />); + expect(screen.getByRole("status")).toHaveTextContent("Lädt …"); + expect(screen.queryByRole("table")).toBeNull(); +}); + +it("keeps the header sticky inside the scroll region", () => { + expect(css).toMatch(/\.g-tbl th\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0/); +}); diff --git a/src/design/registry.ts b/src/design/registry.ts index 8f4a8c65..4a1f58df 100644 --- a/src/design/registry.ts +++ b/src/design/registry.ts @@ -87,9 +87,9 @@ export const COMPONENT_REGISTRY: readonly ComponentEntry[] = [ }, { name: "Table", family: "data", - variants: ["column numeric"], states: ["no rows shows the empty node or nothing"], - a11y: "Labelled, focusable scroll region; header cells have scope=\"col\".", - text: "Headers and cells from the caller; use HonestState for empty.", + variants: ["column numeric", "column sortValue sorts", "selection adds checkboxes"], states: ["no rows shows the empty node or nothing; empty text draws as HonestState", "loading shows HonestState instead of the table", "sorted ascending, descending or none", "selected rows, header box mixed when partial"], + a11y: "Labelled, focusable scroll region; header cells have scope=\"col\"; sortable headers hold a button and aria-sort (Enter or Space cycles); selection count in a polite status region; sticky header.", + text: "src/i18n/de.ts table.*; headers and cells from the caller; use HonestState for empty.", }, { name: "Orb", family: "fabrik", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 8441e60c..f8fec507 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -122,6 +122,10 @@ export const de = { "honest.empty": "Noch nichts da", "honest.offline": "Nicht verbunden", "honest.locked": "Gesperrt", + "table.loading": "Lädt …", + "table.selected": "{n} ausgewählt", + "table.selectAll": "Alle Zeilen auswählen", + "table.selectRow": "Zeile auswählen: {row}", "proof.gates": "Gates", "flag.d1.label": "Neue Oberfläche (Vorschau)", "flag.d1.description": "Zeigt die neue Oberfläche als Vorschau; sie ist noch nicht fertig.",