From 4cb0b5c7997919f27bf268b0aff32f0b25855dab Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Sat, 29 Aug 2026 21:46:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(responsive):=20Breakpoints=20f=C3=BCr=2037?= =?UTF-8?q?5=E2=80=931920=20px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX --- .gitignore | 3 + app/globals.css | 28 +++++ docs/NAVIGATION.md | 24 ++++ .../dashboard/components/cards/KPICard.tsx | 14 ++- .../components/charts/AuslastungsBalken.tsx | 14 ++- .../components/filters/AnalyticsToolbar.tsx | 31 +++-- .../components/filters/MehrfachAuswahl.tsx | 9 +- .../components/layout/AnalyticsInnerNav.tsx | 14 ++- .../dashboard/components/layout/AppShell.tsx | 110 +++++++++++++++++- .../components/layout/PageContainer.tsx | 26 ++++- .../components/layout/PageHeader.tsx | 30 +++-- .../dashboard/components/tables/DataTable.tsx | 10 +- features/dashboard/hooks/useIstSchmal.ts | 33 ++++++ scripts/verify-layout.mjs | 36 ++++-- 14 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 features/dashboard/hooks/useIstSchmal.ts diff --git a/.gitignore b/.gitignore index 2687b56..83bc1e1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ npm-debug.log* .env* next-env.d.ts tsconfig.tsbuildinfo + +# Screenshot-Ausgaben lokaler Prüfläufe +shots diff --git a/app/globals.css b/app/globals.css index a53633a..206da8c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md index 76539b1..9f20ae7 100644 --- a/docs/NAVIGATION.md +++ b/docs/NAVIGATION.md @@ -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 diff --git a/features/dashboard/components/cards/KPICard.tsx b/features/dashboard/components/cards/KPICard.tsx index 8732527..6161098 100644 --- a/features/dashboard/components/cards/KPICard.tsx +++ b/features/dashboard/components/cards/KPICard.tsx @@ -78,7 +78,12 @@ export function KPICard({ return (
- + {/* 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). */} + {label}
- + {/* "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. */} + {wert} {zusatz ? ( diff --git a/features/dashboard/components/charts/AuslastungsBalken.tsx b/features/dashboard/components/charts/AuslastungsBalken.tsx index 305378c..65dc9d2 100644 --- a/features/dashboard/components/charts/AuslastungsBalken.tsx +++ b/features/dashboard/components/charts/AuslastungsBalken.tsx @@ -109,8 +109,12 @@ export function AuslastungsBalken({ ); })} - {/* Achse statt Farblegende — die Zonen erklären sich über das Band. */} -
+ {/* 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. */} +
{/* 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 ( {t} diff --git a/features/dashboard/components/filters/AnalyticsToolbar.tsx b/features/dashboard/components/filters/AnalyticsToolbar.tsx index 6429714..4db7a37 100644 --- a/features/dashboard/components/filters/AnalyticsToolbar.tsx +++ b/features/dashboard/components/filters/AnalyticsToolbar.tsx @@ -67,16 +67,32 @@ export function AnalyticsToolbar({ ); return ( -
-
- {/* Zeitraum als Segment-Control: eine Auswahl, immer sichtbar. */} -
+
+ {/* `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. */} +
+ {/* 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. + */} +
{ZEITRAEUME.map((preset) => (
- + {/* Der Trenner steht zwischen zwei Zeilen nur im Weg. */} + {erlaubt.has("gewerke") ? ( Filter zurücksetzen diff --git a/features/dashboard/components/filters/MehrfachAuswahl.tsx b/features/dashboard/components/filters/MehrfachAuswahl.tsx index f1823d1..e660525 100644 --- a/features/dashboard/components/filters/MehrfachAuswahl.tsx +++ b/features/dashboard/components/filters/MehrfachAuswahl.tsx @@ -52,7 +52,12 @@ export function MehrfachAuswahl({ ); return ( -
+ // 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. +
{offen ? ( -
+
{optionen.length >= suchbarAb ? (
diff --git a/features/dashboard/components/layout/AnalyticsInnerNav.tsx b/features/dashboard/components/layout/AnalyticsInnerNav.tsx index fd3bd3d..50a5a23 100644 --- a/features/dashboard/components/layout/AnalyticsInnerNav.tsx +++ b/features/dashboard/components/layout/AnalyticsInnerNav.tsx @@ -101,12 +101,18 @@ export function AnalyticsInnerNav({
- {/* Schmale Fenster: Reiter statt Leiste — wie im Original. */} -
- + {/* 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. */} +
+ {ANALYSE_SEITEN.map((seite) => ( - {seite.label} + + {seite.label} + ))} diff --git a/features/dashboard/components/layout/AppShell.tsx b/features/dashboard/components/layout/AppShell.tsx index 6f0fc13..2acbcf1 100644 --- a/features/dashboard/components/layout/AppShell.tsx +++ b/features/dashboard/components/layout/AppShell.tsx @@ -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(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(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( + '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 (
+ {/* Abdeckung hinter der ausgefahrenen Leiste: Ein Klick daneben schließt. */} + {mobilOffen ? ( +