Files
Dashboard/features/dashboard/components/cards/ChartCard.tsx
T
dejanandClaude Opus 5 351a26efa4 fix(layout): kollidierenden und überlaufenden Text beseitigen
Gemeldeter Fehler: In der Ringlegende klebten Bezeichnung und Wert aneinander
("Gestellt189,1 T€"). Ursache war eine eigene Regression — beim Beheben der
abgeschnittenen Legendennamen war das truncate entfallen, ohne Ersatz. Damit
schrumpft die Namensspalte durch min-w-0 + grow auf null Breite, und der Text
läuft sichtbar über seinen Kasten hinaus:

  1280 px  Kastenbreite 0 px, Text 43 px darüber hinaus
  1440 px  Kastenbreite 25 px, Text 18 px darüber hinaus
  1600 px  unauffällig

Genau deshalb war es auf den Screenshots nicht zu sehen: Der Fehler tritt nur in
bestimmten Fensterbreiten auf.

Neu: scripts/verify-layout.mjs + npm run verify:layout. Lädt alle sechs Seiten in
sieben Breiten (1024–1920) und misst jede Textzeile über Range.getClientRects.
Gemessen wird auf Textzeilen, nicht auf Elementkästen — sonst melden benachbarte
Tabellenzellen mit Innenabstand falsch positiv. Mindestabstand 8 px = Carbon
$spacing-03. Der Lauf fand 110 Fehler; alle behoben:

- Ringlegende: Container-Query statt fester Zweispaltigkeit. Legende sitzt rechts
  vom Ring, wenn Platz ist, sonst darunter — Carbon: "positioned to the right of
  the chart when space is plentiful". Namen brechen um statt überzulaufen.
- Auslastungs-Achse: Beschriftungsdichte staffelt sich über drei Container-Stufen,
  vorher überlappten die Zahlen in schmalen Karten um bis zu 16 px.
- Handlungsbedarf und Kennzahl-Labels: break-words statt Überlauf.
- Kartentitel und -untertitel: break-words, weil Dateipfade keine Leerzeichen
  zum Umbrechen haben.

Dokumentiert als DESIGN-SYSTEM.md §9 mit Begründung, warum das ein gemessener
Prüfer sein muss und keine Sichtprüfung.

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

145 lines
5.0 KiB
TypeScript

"use client";
import React from "react";
import {
FeatherDownload,
FeatherEyeOff,
FeatherInfo,
FeatherMoreHorizontal,
} from "@subframe/core";
import { useWidgets } from "../../context/WidgetContext";
import { useToast } from "../../context/ToastContext";
/**
* Rahmen für jede Visualisierung (COMP-09).
*
* Enthält die einzige "echte" Interaktionslogik des Prototyps:
* Über das ⋯-Menü lässt sich die Karte ausblenden; sie taucht dann in der
* Filterleiste zum Wiedereinblenden auf. Der Zustand ist persistiert.
*/
export function ChartCard({
id,
titel,
untertitel,
hinweis,
aktion,
legende,
children,
className = "",
}: {
/** Stabile ID — Schlüssel für Ausblenden/Einblenden. */
id: string;
titel: string;
untertitel?: string;
/** Erklärt die Kennzahl; erscheint als Tooltip am (i). */
hinweis?: string;
aktion?: React.ReactNode;
legende?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
const { istSichtbar, verstecke } = useWidgets();
const { zeigeToast } = useToast();
const [menuOffen, setMenuOffen] = React.useState(false);
const menu = React.useRef<HTMLDivElement>(null);
const ausloeser = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (!menuOffen) return;
const schliesse = (event: MouseEvent) => {
if (!menu.current?.contains(event.target as Node)) setMenuOffen(false);
};
// Escape schliesst und gibt den Fokus an den Ausloeser zurueck — sonst
// landet die Tastaturbedienung nach dem Schliessen am Seitenanfang.
const taste = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMenuOffen(false);
ausloeser.current?.focus();
}
};
document.addEventListener("mousedown", schliesse);
document.addEventListener("keydown", taste);
return () => {
document.removeEventListener("mousedown", schliesse);
document.removeEventListener("keydown", taste);
};
}, [menuOffen]);
if (!istSichtbar(id)) return null;
return (
<section
className={`flex flex-col overflow-hidden rounded-lg border border-solid border-border-default bg-white shadow-sm ${className}`}
>
<header className="flex flex-none items-start justify-between gap-3 px-4 pb-3 pt-4">
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-1.5">
<h2 className="min-w-0 break-words text-[14px] font-semibold leading-tight text-text-neutral-primary">
{titel}
</h2>
{hinweis ? (
<span
title={hinweis}
className="flex h-4 w-4 flex-none cursor-help items-center justify-center text-text-neutral-subtle hover:text-text-neutral-secondary"
>
<FeatherInfo className="text-[13px]" />
</span>
) : null}
</div>
{untertitel ? (
<p className="line-clamp-2 break-words text-[12px] leading-snug text-text-neutral-tertiary">
{untertitel}
</p>
) : null}
</div>
<div className="dv-no-print relative flex flex-none items-center gap-1" ref={menu}>
{aktion}
<button
ref={ausloeser}
onClick={() => setMenuOffen((wert) => !wert)}
aria-label={`Optionen für ${titel}`}
aria-haspopup="menu"
aria-expanded={menuOffen}
className="flex h-8 w-8 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100 hover:text-text-neutral-primary"
>
<FeatherMoreHorizontal className="text-[16px]" />
</button>
{menuOffen ? (
<div
role="menu"
className="absolute right-0 top-9 z-40 flex w-52 flex-col rounded-lg border border-solid border-border-default bg-white py-1 shadow-lg"
>
<button
onClick={() => {
verstecke(id, titel);
setMenuOffen(false);
}}
className="flex items-center gap-2 px-3 py-2 text-left text-[13px] text-text-neutral-primary hover:bg-neutral-50"
>
<FeatherEyeOff className="text-[14px] text-text-neutral-tertiary" />
Widget ausblenden
</button>
<button
onClick={() => {
zeigeToast("Als Bild vorgemerkt", `${titel} — Export folgt in der Umsetzung`);
setMenuOffen(false);
}}
className="flex items-center gap-2 px-3 py-2 text-left text-[13px] text-text-neutral-primary hover:bg-neutral-50"
>
<FeatherDownload className="text-[14px] text-text-neutral-tertiary" />
Als Bild exportieren
</button>
</div>
) : null}
</div>
</header>
{legende ? <div className="flex-none px-4 pb-2">{legende}</div> : null}
<div className="min-h-0 grow px-4 pb-4">{children}</div>
</section>
);
}