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
This commit is contained in:
1 parent
b3d71c1f19
commit
4cb0b5c799
14 files changed
+343
-39
No files matched your search
@@ -8,3 +8,6 @@ npm-debug.log*
|
||||
.env*
|
||||
next-env.d.ts
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Screenshot-Ausgaben lokaler Prüfläufe
|
||||
shots
|
||||
@@ -101,6 +101,24 @@ body {
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
/*
|
||||
* Der Einklapp-Knopf der Seitenleiste ragt bauartbedingt 17 px nach rechts
|
||||
* heraus. Faehrt die Leiste unterhalb `md` als Schublade nach links aus dem
|
||||
* Bild, bleibt genau dieser Ueberstand bei x = -16 sichtbar: ein angeschnittener
|
||||
* Knopf, der anklickbar ist und nichts Sichtbares bewirkt, weil er nur den
|
||||
* Zustand der unsichtbaren Schublade aendert.
|
||||
*
|
||||
* Einklappen ergibt in einer Schublade ohnehin keinen Sinn — dort oeffnet und
|
||||
* schliesst der Hamburger in der Kopfzeile. Also unterhalb `md` ausblenden.
|
||||
* `group/7ce7d712` ist der von Subframe erzeugte Marker des Knopf-Wrappers in
|
||||
* SidebarWithSections; er aendert sich nur bei einem bewussten Resync.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
[data-shell-sidebar] [class*="group/7ce7d712"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Radix-Portale (Dropdowns, Tooltips) ueber den Sticky-Header heben. */
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1000 !important;
|
||||
@@ -117,6 +135,16 @@ body {
|
||||
transition: opacity 140ms ease, filter 140ms ease;
|
||||
}
|
||||
|
||||
/* Waagerecht scrollende Leisten ohne sichtbaren Balken: Der Balken wuerde
|
||||
unter einem 40 px hohen Reiterband mehr stoeren als helfen; dass gescrollt
|
||||
werden kann, zeigt der angeschnittene letzte Reiter. */
|
||||
.dv-scroll-x {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.dv-scroll-x::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbars in Karten dezent halten. */
|
||||
.dv-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -75,6 +75,30 @@ Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob etwas fehlt oder
|
||||
etwas kaputt ist. Siehe
|
||||
[SCOPE-ANALYTICS-DASHBOARD.md](SCOPE-ANALYTICS-DASHBOARD.md).
|
||||
|
||||
### Schmale Fenster
|
||||
|
||||
Unterhalb **768 px** — derselbe Breakpoint, den die Software als `MOBILE_BREAKPOINT`
|
||||
führt — fährt die Seitenleiste als Schublade über den Inhalt, statt ihn zu
|
||||
verdrängen. Geöffnet wird sie über einen Hamburger links in der Kopfzeile.
|
||||
|
||||
Das ist **nicht erfunden, sondern abgeglichen**: `SubframeAppShell` im
|
||||
Zielsystem rendert an derselben Stelle denselben Knopf — `FeatherMenu`, 32 × 32,
|
||||
`md:hidden`, links vor dem Breadcrumb. Übernommen wurden auch das Verhalten der
|
||||
Schublade (Abdeckung dahinter, Escape schließt, Fokus wandert beim Öffnen auf
|
||||
den ersten Eintrag und kehrt beim Schließen zum Auslöser zurück) und die
|
||||
Fokusfalle: Der Rahmen trägt `role="dialog"` und `aria-modal`, und ohne Falle
|
||||
wäre das gegenüber einem Screenreader eine Unwahrheit.
|
||||
|
||||
Zwei Beobachtungen dazu, damit sie beim Einbau nicht als Fehler gelesen werden:
|
||||
|
||||
- Die geöffnete Schublade **verdeckt den Hamburger**. Geschlossen wird deshalb
|
||||
über Escape, einen Klick auf die Abdeckung oder eine Navigation. In der
|
||||
Software ist das genauso; der Umschaltzweig im Klickhandler ist dort wie hier
|
||||
praktisch unerreichbar.
|
||||
- Der **Einklapp-Knopf** der Seitenleiste ist unterhalb `md` ausgeblendet. Er
|
||||
ragt bauartbedingt 17 px nach rechts heraus und blieb sonst als
|
||||
angeschnittener, wirkungsloser Rest am linken Bildrand stehen.
|
||||
|
||||
---
|
||||
|
||||
## Aufbau jeder Analyse-Seite
|
||||
|
||||
@@ -78,7 +78,12 @@ export function KPICard({
|
||||
return (
|
||||
<article className="flex min-h-[136px] flex-col rounded-lg border border-solid border-border-default bg-white p-4 shadow-sm transition-shadow duration-200 hover:shadow-md">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-[12px] font-medium leading-tight text-text-neutral-tertiary">
|
||||
{/* min-w-0 + break-words: Ohne das schiebt ein langes Label wie
|
||||
„Umsatz (Zahlungseingang)" das Icon aus der Karte und laeuft selbst
|
||||
ueber den Rand. Die Karte wird dadurch eine Zeile hoeher — das ist
|
||||
der Preis dafuer, dass die Beschriftung vollstaendig lesbar bleibt
|
||||
(docs/DESIGN-SYSTEM.md §9). */}
|
||||
<span className="min-w-0 break-words text-[12px] font-medium leading-tight text-text-neutral-tertiary">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
@@ -90,7 +95,12 @@ export function KPICard({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<span className="text-[28px] font-semibold leading-none tracking-[-0.02em] text-text-neutral-primary">
|
||||
{/* "277.460 €" und "34,2 Tage" tragen ihre Einheit als eigenes Wort.
|
||||
Ohne nowrap bricht die schmale Karte zwischen Betrag und Einheit um:
|
||||
Die Zahl liest sich dann einen Augenblick lang als Wert ohne Einheit,
|
||||
und die Karte wächst um eine Zeile, wodurch die Reihe ihre gemeinsame
|
||||
Grundlinie verliert. */}
|
||||
<span className="whitespace-nowrap text-[28px] font-semibold leading-none tracking-[-0.02em] text-text-neutral-primary">
|
||||
{wert}
|
||||
</span>
|
||||
{zusatz ? (
|
||||
|
||||
@@ -109,8 +109,12 @@ export function AuslastungsBalken({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Achse statt Farblegende — die Zonen erklären sich über das Band. */}
|
||||
<div className="grid grid-cols-[minmax(96px,24%)_minmax(0,1fr)_48px] items-center gap-3 pt-1">
|
||||
{/* Achse statt Farblegende — die Zonen erklären sich über das Band.
|
||||
In sehr schmalen Karten bleibt für die Spur nur rund 40 px; dort
|
||||
stoßen selbst die beiden Randmarken aneinander. Eine Achse, die man
|
||||
nicht mehr lesen kann, ist kein Kompromiss, sondern Störung — sie
|
||||
entfällt dann, und die Erläuterung darunter trägt die Skala. */}
|
||||
<div className="hidden grid-cols-[minmax(96px,24%)_minmax(0,1fr)_48px] items-center gap-3 pt-1 @sm:grid">
|
||||
<span />
|
||||
<span className="relative block h-4">
|
||||
{/* Beschriftungsdichte staffelt sich mit der Containerbreite: ganz
|
||||
@@ -126,10 +130,14 @@ export function AuslastungsBalken({
|
||||
: index % 2 === 1
|
||||
? "hidden @lg:inline"
|
||||
: "hidden @md:inline";
|
||||
// Zentriert man auch die Randmarken, ragt die Haelfte ihrer
|
||||
// Breite ueber die Spur hinaus — links wie rechts je rund 10 px.
|
||||
const rand =
|
||||
t === min ? "translate-x-0" : t === max ? "-translate-x-full" : "-translate-x-1/2";
|
||||
return (
|
||||
<span
|
||||
key={t}
|
||||
className={`absolute top-0 -translate-x-1/2 text-[11px] tabular-nums text-text-neutral-tertiary ${stufe}`}
|
||||
className={`absolute top-0 text-[11px] tabular-nums text-text-neutral-tertiary ${rand} ${stufe}`}
|
||||
style={{ left: `${pos(t)}%` }}
|
||||
>
|
||||
{t}
|
||||
|
||||
@@ -67,16 +67,32 @@ export function AnalyticsToolbar({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dv-no-print sticky top-14 z-20 flex flex-col gap-2 border-b border-solid border-border-default bg-neutral-50/95 px-6 py-3 backdrop-blur">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Zeitraum als Segment-Control: eine Auswahl, immer sichtbar. */}
|
||||
<div className="flex items-center rounded-md border border-solid border-border-default bg-white p-0.5">
|
||||
<div className="dv-no-print sticky top-14 z-20 flex flex-col gap-2 border-b border-solid border-border-default bg-neutral-50/95 px-4 py-3 backdrop-blur sm:px-6">
|
||||
{/* `relative` ist der Bezugsrahmen für die Filter-Panels: Unter `sm`
|
||||
hängen sie an dieser Zeile statt am einzelnen Knopf und nutzen die
|
||||
volle Breite — siehe MehrfachAuswahl. */}
|
||||
<div className="relative flex flex-wrap items-center gap-2">
|
||||
{/* Zeitraum als Segment-Control: eine Auswahl, immer sichtbar.
|
||||
*
|
||||
* Bei 375 px sind die sechs Stufen zusammen rund 340 px breit und
|
||||
* passen nicht mehr neben die Filterknöpfe. Umbrechen scheidet aus:
|
||||
* Ein Segment-Control ist ein Bauteil mit einem Rahmen, und die
|
||||
* Stufen stehen in einer Reihenfolge (heute → alles). Über zwei
|
||||
* Zeilen gebrochen liest es sich als zwei Steuerelemente, und die
|
||||
* Reihenfolge geht verloren. Deshalb scrollt es waagerecht — ohne
|
||||
* sichtbare Leiste, weil der angeschnittene letzte Knopf schon
|
||||
* anzeigt, dass es weitergeht.
|
||||
*
|
||||
* Ab `sm` passt alles; dort bleibt der Überlauf sichtbar, damit der
|
||||
* Fokusring nicht am Rahmen abgeschnitten wird.
|
||||
*/}
|
||||
<div className="flex min-w-0 items-center overflow-x-auto rounded-md border border-solid border-border-default bg-white p-0.5 [scrollbar-width:none] sm:overflow-x-visible [&::-webkit-scrollbar]:hidden">
|
||||
{ZEITRAEUME.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => setFilter((aktuell) => ({ ...aktuell, zeitraum: preset }))}
|
||||
aria-pressed={filter.zeitraum === preset}
|
||||
className={`rounded px-2.5 py-1 text-[13px] font-medium transition-colors ${
|
||||
className={`flex-none whitespace-nowrap rounded px-2.5 py-1 text-[13px] font-medium transition-colors ${
|
||||
filter.zeitraum === preset
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-text-neutral-secondary hover:bg-neutral-100"
|
||||
@@ -87,7 +103,8 @@ export function AnalyticsToolbar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<span className="mx-1 h-6 w-px bg-border-default" />
|
||||
{/* Der Trenner steht zwischen zwei Zeilen nur im Weg. */}
|
||||
<span className="mx-1 hidden h-6 w-px bg-border-default sm:block" />
|
||||
|
||||
{erlaubt.has("gewerke") ? (
|
||||
<MehrfachAuswahl
|
||||
@@ -145,7 +162,7 @@ export function AnalyticsToolbar({
|
||||
{chips.length ? (
|
||||
<button
|
||||
onClick={zuruecksetzen}
|
||||
className="ml-auto flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-text-neutral-tertiary hover:bg-neutral-100 hover:text-text-neutral-primary"
|
||||
className="flex h-8 flex-none items-center gap-1.5 whitespace-nowrap rounded-md px-2.5 text-[13px] font-medium text-text-neutral-tertiary hover:bg-neutral-100 hover:text-text-neutral-primary sm:ml-auto"
|
||||
>
|
||||
<FeatherRotateCcw className="text-[14px]" />
|
||||
Filter zurücksetzen
|
||||
|
||||
@@ -52,7 +52,12 @@ export function MehrfachAuswahl({
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={wrapper} className="relative">
|
||||
// Unter `sm` bewusst *nicht* `relative`: Das Panel ist 256 px breit, der
|
||||
// vierte Filterknopf steht bei 375 px aber schon bei x = 278 — an den Knopf
|
||||
// gehängt liefe es weit aus dem Bild. Ohne eigenen Bezugsrahmen hängt es
|
||||
// stattdessen an der Filterleiste (AnalyticsToolbar, `relative` an der
|
||||
// Knopfzeile) und nimmt dort die volle Zeilenbreite ein.
|
||||
<div ref={wrapper} className="sm:relative">
|
||||
<button
|
||||
ref={ausloeser}
|
||||
onClick={() => setOffen((wert) => !wert)}
|
||||
@@ -74,7 +79,7 @@ export function MehrfachAuswahl({
|
||||
</button>
|
||||
|
||||
{offen ? (
|
||||
<div className="absolute left-0 top-9 z-50 flex w-64 flex-col rounded-lg border border-solid border-border-default bg-white shadow-lg">
|
||||
<div className="absolute inset-x-0 top-full z-50 mt-2 flex flex-col rounded-lg border border-solid border-border-default bg-white shadow-lg sm:inset-x-auto sm:left-0 sm:top-9 sm:mt-0 sm:w-64">
|
||||
{optionen.length >= suchbarAb ? (
|
||||
<div className="flex items-center gap-2 border-b border-solid border-border-default px-3 py-2">
|
||||
<FeatherSearch className="text-[14px] text-text-neutral-tertiary" />
|
||||
|
||||
@@ -101,12 +101,18 @@ export function AnalyticsInnerNav({
|
||||
</SidebarInside>
|
||||
</div>
|
||||
|
||||
{/* Schmale Fenster: Reiter statt Leiste — wie im Original. */}
|
||||
<div className="w-full border-b border-solid border-border-default bg-white px-4 py-2 sm:hidden">
|
||||
<Tabs>
|
||||
{/* Schmale Fenster: Reiter statt Leiste — wie im Original.
|
||||
Waagerecht scrollend statt umbrechend: Fünf Reiter brauchen mindestens
|
||||
425 px, bei 375 px lief die ganze Seite dadurch seitlich über. Über
|
||||
zwei Zeilen gebrochen liest sich ein Reiterband als zwei Steuerelemente
|
||||
— dasselbe Mittel wie beim Zeitraum-Segment in der Filterleiste. */}
|
||||
<div className="dv-scroll-x w-full min-w-0 overflow-x-auto border-b border-solid border-border-default bg-white px-4 py-2 sm:hidden">
|
||||
<Tabs className="w-max min-w-full">
|
||||
{ANALYSE_SEITEN.map((seite) => (
|
||||
<Link key={seite.id} href={seite.href} className="contents no-underline">
|
||||
<Tabs.Item active={seite.id === aktiv}>{seite.label}</Tabs.Item>
|
||||
<Tabs.Item active={seite.id === aktiv} className="whitespace-nowrap">
|
||||
{seite.label}
|
||||
</Tabs.Item>
|
||||
</Link>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FeatherBell, FeatherHelpCircle, FeatherSearch } from "@subframe/core";
|
||||
import { FeatherBell, FeatherHelpCircle, FeatherMenu, FeatherSearch } from "@subframe/core";
|
||||
import { SidebarWithSections } from "@/ui/components/SidebarWithSections";
|
||||
import { NAV_GRUPPEN, NAV_SEKUNDAER, ANALYSE_SEITEN, type NavEintrag } from "./navigation";
|
||||
import { FeatherIcon } from "./FeatherIcon";
|
||||
import { useIstSchmal } from "../../hooks/useIstSchmal";
|
||||
import { DATENSATZ_INFO } from "../../data/seed";
|
||||
import { formatDatum, formatZahl } from "../../utils/formatters";
|
||||
import { HEUTE } from "../../utils/dateUtils";
|
||||
@@ -41,6 +42,78 @@ import { HEUTE } from "../../utils/dateUtils";
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [eingeklappt, setEingeklappt] = React.useState(false);
|
||||
// Unterhalb `md` fährt die Leiste über den Inhalt statt ihn zu verdrängen.
|
||||
// Bei 375 px blieben sonst 135 px für den Inhalt, und die Seite lief um über
|
||||
// 300 px seitlich über. Dasselbe Vorgehen wie in der Software.
|
||||
const istSchmal = useIstSchmal();
|
||||
const ausloeser = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Beim Seitenwechsel schließen — sonst bleibt die Leiste über dem Ziel
|
||||
// liegen. Der Pfad wandert dafür in den State: Zustand beim Rendern
|
||||
// anzupassen ist hier richtig, ein Effekt würde einen zweiten Durchlauf
|
||||
// auslösen und die Leiste kurz über der neuen Seite zeigen.
|
||||
const [schublade, setSchublade] = React.useState({ offen: false, pfad: pathname });
|
||||
if (schublade.pfad !== pathname) setSchublade({ offen: false, pfad: pathname });
|
||||
const mobilOffen = schublade.offen;
|
||||
const leisteRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
/** Schließen und den Fokus dorthin zurückgeben, wo er hergekommen ist. */
|
||||
const schliesseSchublade = React.useCallback(() => {
|
||||
setSchublade((aktuell) => ({ ...aktuell, offen: false }));
|
||||
window.requestAnimationFrame(() => ausloeser.current?.focus());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fokusfalle der offenen Schublade.
|
||||
*
|
||||
* Nicht optional: Der Rahmen trägt `role="dialog"` und `aria-modal`, und
|
||||
* damit ist einem Screenreader zugesagt, dass nichts dahinter erreichbar ist.
|
||||
* Ohne Falle wandert der Tabulator hinter die Abdeckung in eine Seite, die
|
||||
* optisch verdeckt ist — die Zusage wäre eine Unwahrheit.
|
||||
*
|
||||
* Übernommen aus `SubframeAppShell` im Zielsystem: Fokus auf das erste
|
||||
* bedienbare Element, Tab und Umschalt+Tab laufen zyklisch, Escape schließt
|
||||
* und gibt den Fokus an den Auslöser zurück.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!mobilOffen) return;
|
||||
const leiste = leisteRef.current;
|
||||
if (!leiste) return;
|
||||
|
||||
const bedienbar = () =>
|
||||
Array.from(
|
||||
leiste.querySelectorAll<HTMLElement>(
|
||||
'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((el) => el.getClientRects().length > 0);
|
||||
|
||||
window.requestAnimationFrame(() => bedienbar()[0]?.focus());
|
||||
|
||||
const taste = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
schliesseSchublade();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const elemente = bedienbar();
|
||||
if (!elemente.length) return;
|
||||
const erstes = elemente[0];
|
||||
const letztes = elemente[elemente.length - 1];
|
||||
const aktiv = document.activeElement;
|
||||
if (event.shiftKey && (aktiv === erstes || !leiste.contains(aktiv))) {
|
||||
event.preventDefault();
|
||||
letztes.focus();
|
||||
} else if (!event.shiftKey && aktiv === letztes) {
|
||||
event.preventDefault();
|
||||
erstes.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", taste);
|
||||
return () => document.removeEventListener("keydown", taste);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mobilOffen]);
|
||||
|
||||
const istAktiv = (eintrag: NavEintrag) =>
|
||||
eintrag.ziel.href === "/analytics"
|
||||
@@ -75,12 +148,32 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full bg-neutral-50">
|
||||
{/* Abdeckung hinter der ausgefahrenen Leiste: Ein Klick daneben schließt. */}
|
||||
{mobilOffen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-neutral-900/40 md:hidden"
|
||||
onClick={schliesseSchublade}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* z-50 ist nötig, nicht dekorativ: `position: sticky` erzeugt einen
|
||||
eigenen Stapelkontext. Ohne z-Wert am Rahmen bleibt das z-index des
|
||||
Einklapp-Knopfes darin gefangen, und die Inhaltsspalte — ein späteres
|
||||
Geschwisterelement — malt über den Teil, der aus der Leiste
|
||||
herausragt. Die Software setzt an derselben Stelle ebenfalls z-50. */}
|
||||
<div className="dv-no-print sticky top-0 z-50 h-screen flex-none">
|
||||
<div
|
||||
ref={leisteRef}
|
||||
data-shell-sidebar
|
||||
className={`dv-no-print z-50 h-screen flex-none max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:transition-transform md:sticky md:top-0 ${
|
||||
mobilOffen ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
||||
}`}
|
||||
role={istSchmal ? "dialog" : undefined}
|
||||
aria-modal={istSchmal && mobilOffen ? true : undefined}
|
||||
aria-label={istSchmal ? "Navigation" : undefined}
|
||||
aria-hidden={istSchmal && !mobilOffen ? true : undefined}
|
||||
inert={istSchmal && !mobilOffen}
|
||||
>
|
||||
<SidebarWithSections
|
||||
variant={eingeklappt ? "closed" : "open"}
|
||||
onToggleCollapse={() => setEingeklappt((wert) => !wert)}
|
||||
@@ -118,6 +211,19 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
<div className="flex min-w-0 grow flex-col">
|
||||
<header className="dv-no-print sticky top-0 z-30 flex h-14 flex-none items-center gap-4 border-b border-solid border-border-default bg-white/95 px-6 backdrop-blur">
|
||||
<button
|
||||
ref={ausloeser}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
mobilOffen ? schliesseSchublade() : setSchublade({ offen: true, pfad: pathname })
|
||||
}
|
||||
aria-label="Navigation"
|
||||
aria-expanded={mobilOffen}
|
||||
className="inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-text-neutral-secondary hover:bg-neutral-100 md:hidden"
|
||||
>
|
||||
<FeatherMenu className="text-[18px]" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2 text-[13px]">
|
||||
<span className="text-text-neutral-tertiary">{bereich}</span>
|
||||
{aktiveAnalyse ? (
|
||||
|
||||
@@ -4,9 +4,13 @@ import React from "react";
|
||||
* Inhaltsspalte einer Seite.
|
||||
* 24 px Außenabstand, 16 px Rasterabstand — entspricht dem 8-px-System
|
||||
* aus den UI-Guidelines (internal ≤ external).
|
||||
*
|
||||
* Unter `sm` sind 24 px Rand ein Achtel der Fensterbreite: Der Rand kostet
|
||||
* dort mehr Platz, als er Ruhe bringt. Deshalb 16 px — der nächste Wert der
|
||||
* Skala, nicht ein neuer.
|
||||
*/
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="flex flex-col gap-6 px-6 pb-10">{children}</div>;
|
||||
return <div className="flex flex-col gap-6 px-4 pb-10 sm:px-6">{children}</div>;
|
||||
}
|
||||
|
||||
/** Standard-Raster für eine Kartenreihe. */
|
||||
@@ -26,11 +30,27 @@ export function Raster({
|
||||
obenBuendig?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
/**
|
||||
* Jede Leiter beginnt einspaltig. Zwei Spalten sind bei 375 px rund 155 px
|
||||
* je Karte — darin bricht ein Währungswert zwischen Betrag und Euro-Zeichen
|
||||
* um, und die Karte wird höher statt schmaler.
|
||||
*
|
||||
* Wann die erste Verdopplung kommt, hängt am Inhalt, nicht an der Zielzahl:
|
||||
* Die Leitern 4 und 5 tragen KPI-Karten (kurzer Wert, feste Höhe) und
|
||||
* verdoppeln bei `sm`, wo die Seitenleiste noch ausgefahren ist und die
|
||||
* volle Fensterbreite zur Verfügung steht. Die Leitern 2 und 3 tragen
|
||||
* Diagramme und Tabellen, die halbiert erst ab `md` lesbar bleiben.
|
||||
*
|
||||
* Keine Leiter erhöht bei `md`: Dort kommt die Seitenleiste zurück und
|
||||
* nimmt 240 px weg — die Karten würden schmaler statt breiter.
|
||||
*
|
||||
* Ab 1024 px ist jede Leiter identisch zum abgenommenen Stand.
|
||||
*/
|
||||
const klassen: Record<number, string> = {
|
||||
2: "grid grid-cols-1 gap-4 md:grid-cols-2",
|
||||
3: "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3",
|
||||
4: "grid grid-cols-2 gap-4 lg:grid-cols-4",
|
||||
5: "grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-5",
|
||||
4: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4",
|
||||
5: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5",
|
||||
};
|
||||
return (
|
||||
<div className={`${klassen[spalten]} ${obenBuendig ? "items-start" : ""}`}>{children}</div>
|
||||
|
||||
@@ -27,10 +27,24 @@ export function PageHeader({
|
||||
}) {
|
||||
const { zeigeToast } = useToast();
|
||||
|
||||
/**
|
||||
* Unter `sm` tragen die beiden beschrifteten Knöpfe nur ihr Icon.
|
||||
* Nebeneinander brauchen "Aktualisieren" und "Export" rund 240 px — mehr,
|
||||
* als bei 375 px neben dem Titel überhaupt vorhanden ist. Die Beschriftung
|
||||
* entfällt, `aria-label` bleibt: Für die Sprachausgabe ändert sich nichts.
|
||||
*/
|
||||
const knopf =
|
||||
"flex h-9 w-9 flex-none items-center justify-center gap-2 rounded-md border border-solid " +
|
||||
"border-border-default bg-white text-[13px] font-medium text-text-neutral-secondary " +
|
||||
"hover:bg-neutral-50 sm:w-auto sm:px-3";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 px-6 pb-6 pt-6">
|
||||
// Unter `sm` untereinander: Titel und Aktionen nebeneinander lassen für den
|
||||
// Titel keine 130 px übrig — er zerfiele in vier Zeilen, während die Knöpfe
|
||||
// aus dem Bild laufen.
|
||||
<div className="flex flex-col items-stretch gap-4 px-4 pb-6 pt-6 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:px-6">
|
||||
<div className="flex min-w-0 flex-col gap-0">
|
||||
<h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-text-neutral-primary">
|
||||
<h1 className="break-words text-[22px] font-semibold leading-tight tracking-[-0.02em] text-text-neutral-primary">
|
||||
{titel}
|
||||
</h1>
|
||||
<p className="text-[13px] leading-relaxed text-text-neutral-tertiary">{beschreibung}</p>
|
||||
@@ -42,21 +56,23 @@ export function PageHeader({
|
||||
<>
|
||||
<button
|
||||
onClick={() => zeigeToast("Ansicht aktualisiert", "Demo-Daten, Stichtag 30.07.2026")}
|
||||
className="flex h-9 items-center gap-2 rounded-md border border-solid border-border-default bg-white px-3 text-[13px] font-medium text-text-neutral-secondary hover:bg-neutral-50"
|
||||
className={knopf}
|
||||
aria-label="Ansicht aktualisieren"
|
||||
>
|
||||
<FeatherRefreshCw className="text-[15px]" />
|
||||
Aktualisieren
|
||||
<span className="hidden sm:inline">Aktualisieren</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zeigeToast("Export vorgemerkt", "PDF/CSV folgt in der Umsetzung")}
|
||||
className="flex h-9 items-center gap-2 rounded-md border border-solid border-border-default bg-white px-3 text-[13px] font-medium text-text-neutral-secondary hover:bg-neutral-50"
|
||||
className={knopf}
|
||||
aria-label="Ansicht exportieren"
|
||||
>
|
||||
<FeatherDownload className="text-[15px]" />
|
||||
Export
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => zeigeToast("Link kopiert", "Teilen wird serverseitig umgesetzt")}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-solid border-border-default bg-white text-text-neutral-tertiary hover:bg-neutral-50"
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-md border border-solid border-border-default bg-white text-text-neutral-tertiary hover:bg-neutral-50"
|
||||
aria-label="Ansicht teilen"
|
||||
>
|
||||
<FeatherShare2 className="text-[15px]" />
|
||||
|
||||
@@ -86,7 +86,7 @@ export function DataTable<T extends { id: string }>({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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" />
|
||||
@@ -102,7 +102,13 @@ export function DataTable<T extends { id: string }>({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="dv-scroll -mx-1 overflow-x-auto px-1">
|
||||
{/* 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">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Ist das Fenster schmaler als der `md`-Breakpoint (768 px)?
|
||||
*
|
||||
* Nötig, weil unterhalb `md` die Seitenleiste über den Inhalt fährt statt ihn
|
||||
* zu verdrängen — und eine geschlossene Schublade muss dann auch für
|
||||
* Screenreader und Tastatur verschwinden (`aria-hidden`, `inert`). Über reine
|
||||
* CSS-Klassen ist das nicht zu haben: `max-md:` verändert die Darstellung,
|
||||
* nicht den Zugänglichkeitsbaum. Ohne Breitenprüfung wäre die Leiste auch auf
|
||||
* dem Desktop dauerhaft unerreichbar.
|
||||
*
|
||||
* `useSyncExternalStore` statt Effekt plus State: `matchMedia` ist genau so
|
||||
* ein externer Speicher, und beim Server-Rendering liefert die Momentaufnahme
|
||||
* `false` — die Leiste ist dort also sichtbar, was ohne JavaScript richtig ist.
|
||||
*/
|
||||
const ABFRAGE = "(max-width: 767px)";
|
||||
|
||||
function abonnieren(melde: () => void) {
|
||||
const abfrage = window.matchMedia(ABFRAGE);
|
||||
abfrage.addEventListener("change", melde);
|
||||
return () => abfrage.removeEventListener("change", melde);
|
||||
}
|
||||
|
||||
export function useIstSchmal(): boolean {
|
||||
return React.useSyncExternalStore(
|
||||
abonnieren,
|
||||
() => window.matchMedia(ABFRAGE).matches,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,10 @@ const SEITEN = [
|
||||
"/analytics/team",
|
||||
"/komponenten",
|
||||
];
|
||||
const BREITEN = [1024, 1152, 1280, 1366, 1440, 1600, 1920];
|
||||
// Von Handy bis großem Schirm. Die schmalen Breiten sind die interessanten:
|
||||
// Dort fährt die Seitenleiste über den Inhalt, das Raster wird einspaltig und
|
||||
// die Filterleiste bricht um.
|
||||
const BREITEN = [375, 414, 640, 768, 834, 1024, 1280, 1440, 1920];
|
||||
const MIN_ABSTAND = 8;
|
||||
|
||||
const EXE =
|
||||
@@ -89,6 +92,11 @@ const messen = (MIN) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Die Seite selbst darf nie seitlich scrollen — ein waagerechter
|
||||
// Scrollbalken auf Dokumentebene ist immer ein Fehler, kein Kompromiss.
|
||||
const doc = document.documentElement;
|
||||
const seitenUeberlauf = doc.scrollWidth > doc.clientWidth + 1 ? doc.scrollWidth - doc.clientWidth : 0;
|
||||
|
||||
// Text, der aus seinem eigenen Kasten läuft
|
||||
const ueberlauf = [...document.querySelectorAll("body *")]
|
||||
.filter((el) => {
|
||||
@@ -99,7 +107,7 @@ const messen = (MIN) => {
|
||||
})
|
||||
.map((el) => ({ text: el.textContent.trim().slice(0, 30), wo: pfad(el), um: el.scrollWidth - el.clientWidth }));
|
||||
|
||||
return { treffer, ueberlauf };
|
||||
return { treffer, ueberlauf, seitenUeberlauf };
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ executablePath: EXE });
|
||||
@@ -115,7 +123,18 @@ for (const breite of BREITEN) {
|
||||
process.exit(1);
|
||||
}
|
||||
await seite.waitForTimeout(500);
|
||||
const { treffer, ueberlauf } = await seite.evaluate(messen, MIN_ABSTAND);
|
||||
const { treffer, ueberlauf, seitenUeberlauf } = await seite.evaluate(messen, MIN_ABSTAND);
|
||||
if (seitenUeberlauf > 0) {
|
||||
const key = `S|${pfad}|${breite}`;
|
||||
gesammelt.set(key, {
|
||||
art: "Seitenüberlauf",
|
||||
text: `${pfad} läuft um ${seitenUeberlauf}px seitlich über`,
|
||||
wo: "document",
|
||||
um: seitenUeberlauf,
|
||||
seiten: new Set([pfad]),
|
||||
breiten: new Set([breite]),
|
||||
});
|
||||
}
|
||||
for (const t of treffer) {
|
||||
const key = `K|${t.a}|${t.b}|${t.wo}`;
|
||||
if (!gesammelt.has(key)) gesammelt.set(key, { art: "Kollision", ...t, seiten: new Set(), breiten: new Set() });
|
||||
@@ -136,9 +155,12 @@ await browser.close();
|
||||
|
||||
const liste = [...gesammelt.values()].sort((a, b) => (a.luecke ?? -99) - (b.luecke ?? -99));
|
||||
for (const f of liste) {
|
||||
const kopf = f.art === "Kollision"
|
||||
? `${f.ueberlappt ? "ÜBERLAPPT" : "zu eng"} (${f.luecke}px): "${f.a}" ⟷ "${f.b}"`
|
||||
: `ÜBERLAUF (+${f.um}px): "${f.text}"`;
|
||||
const kopf =
|
||||
f.art === "Kollision"
|
||||
? `${f.ueberlappt ? "ÜBERLAPPT" : "zu eng"} (${f.luecke}px): "${f.a}" ⟷ "${f.b}"`
|
||||
: f.art === "Seitenüberlauf"
|
||||
? `SEITE SCROLLT (+${f.um}px): ${f.text}`
|
||||
: `ÜBERLAUF (+${f.um}px): "${f.text}"`;
|
||||
console.log(` ${kopf}`);
|
||||
console.log(` ${f.wo}`);
|
||||
console.log(` ${[...f.seiten].join(" ")} · ${[...f.breiten].join(" ")}`);
|
||||
@@ -148,4 +170,4 @@ if (liste.length) {
|
||||
console.error(`\n✗ ${liste.length} Layoutfehler. Mindestabstand ist ${MIN_ABSTAND} px (Carbon $spacing-03).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✓ Kein kollidierender oder überlaufender Text — ${SEITEN.length} Seiten × ${BREITEN.length} Breiten.`);
|
||||
console.log(`✓ Kein kollidierender Text, kein Überlauf — ${SEITEN.length} Seiten × ${BREITEN.length} Breiten (${BREITEN[0]}–${BREITEN.at(-1)} px).`);
|
||||
Reference in new issue
Block a user