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 ? ( +