fix: Einstellungskarten in feste Spalten statt Spaltenfluss

Beim Abrufen und beim Bearbeiten sprang die halbe Seite. Ursache war der
CSS-Spaltenfluss: Aendert eine Karte ihre Hoehe, verteilt der Browser ALLE
Karten neu auf die Spalten. Ich hatte das zweimal am Symptom kuriert
(Formular in den Dialog verschoben), aber die naechste wachsende Karte
brachte es zurueck.

Jetzt bekommt jede Karte fest ihre Spalte. Waechst eine, verschiebt sich
nur, was in derselben Spalte darunter steht. Die Spaltenzahl kommt aus der
tatsaechlichen Containerbreite (ResizeObserver), nicht aus der
Fensterbreite – in der schmalen Seitenleisten-Ansicht stimmte das sonst
nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 20:54:03 +02:00
co-authored by Claude Opus 5
parent 899241f991
commit e93f565629
4 changed files with 53 additions and 20 deletions
+47
View File
@@ -0,0 +1,47 @@
import { Children, useEffect, useRef, useState } from "react";
/**
* Karten in feste Spalten verteilen.
*
* Warum nicht CSS: Ein Raster richtet die Karten zeilenweise aus – die zweite
* Reihe beginnt erst unter der höchsten Karte der ersten, was bei sehr
* unterschiedlich hohen Karten grosse Löcher hinterlässt. Der CSS-Spaltenfluss
* (`column-width`) füllt zwar schön auf, verteilt aber ALLE Karten neu, sobald
* eine ihre Höhe ändert. Beim Bearbeiten oder nach einem Abruf sprang deshalb
* die halbe Seite.
*
* Hier bekommt jede Karte fest ihre Spalte (reihum). Wächst eine, verschiebt
* sich nur, was in derselben Spalte darunter steht – alles andere bleibt liegen.
*/
export default function Kartenspalten({ children, min = 380, gap = "var(--sp-4)" }) {
const container = useRef(null);
const [spalten, setSpalten] = useState(1);
useEffect(() => {
const el = container.current;
if (!el) return undefined;
const messen = () => {
const breite = el.getBoundingClientRect().width;
setSpalten(Math.max(1, Math.floor((breite + 24) / (min + 24))));
};
messen();
const beobachter = new ResizeObserver(messen);
beobachter.observe(el);
return () => beobachter.disconnect();
}, [min]);
const karten = Children.toArray(children).filter(Boolean);
const verteilt = Array.from({ length: spalten }, () => []);
karten.forEach((karte, i) => verteilt[i % spalten].push(karte));
return (
<div ref={container} style={{ display: "flex", alignItems: "flex-start", gap }}>
{verteilt.map((spalte, i) => (
<div key={i} style={{ flex: "1 1 0", minWidth: 0, display: "flex",
flexDirection: "column", gap }}>
{spalte}
</div>
))}
</div>
);
}
-16
View File
@@ -284,22 +284,6 @@
min-width: 0; min-width: 0;
} }
/* Einstellungen & Mein Konto: Karten fliessen spaltenweise.
Ein Raster richtet die Karten zeilenweise aus – die zweite Reihe beginnt erst
unter der HOECHSTEN Karte der ersten, was bei sehr unterschiedlich hohen
Karten ein grosses Loch hinterlaesst. Der Spaltenfluss stapelt stattdessen
weiter, sobald oben Platz frei wird. Die Spalten sind bewusst breit: vier
schmale Karten nebeneinander waren auf dem Notebook nicht mehr lesbar. */
.settings-grid {
column-width: 380px;
column-gap: var(--sp-4);
}
.settings-grid > * {
break-inside: avoid;
margin-bottom: var(--sp-4);
width: 100%;
}
/* Nutzer-Chip in der Seitenleisten-Fußzeile ist ein Link auf „Mein Konto". */ /* Nutzer-Chip in der Seitenleisten-Fußzeile ist ein Link auf „Mein Konto". */
a.sidebar-user { a.sidebar-user {
text-decoration: none; text-decoration: none;
+3 -2
View File
@@ -6,6 +6,7 @@ import { api, ApiError } from "../api";
import { useToast } from "../toast"; import { useToast } from "../toast";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import PasswordInput from "../components/PasswordInput"; import PasswordInput from "../components/PasswordInput";
import Kartenspalten from "../components/Kartenspalten";
import PasswordRules from "../components/PasswordRules"; import PasswordRules from "../components/PasswordRules";
import Geraetemarken from "./settings/Geraetemarken"; import Geraetemarken from "./settings/Geraetemarken";
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy"; import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
@@ -56,7 +57,7 @@ export default function Account() {
return ( return (
<div> <div>
<h1>{t("account.title")}</h1> <h1>{t("account.title")}</h1>
<div className="settings-grid"> <Kartenspalten>
<form className="card" onSubmit={onSubmit}> <form className="card" onSubmit={onSubmit}>
<h2 style={{ marginTop: 0 }}>{t("account.password")}</h2> <h2 style={{ marginTop: 0 }}>{t("account.password")}</h2>
{fehler && ( {fehler && (
@@ -101,7 +102,7 @@ export default function Account() {
</div> </div>
<Geraetemarken /> <Geraetemarken />
</div> </Kartenspalten>
</div> </div>
); );
} }
+3 -2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../auth";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import Kartenspalten from "../components/Kartenspalten";
import PolicyCard from "./settings/PolicyCard"; import PolicyCard from "./settings/PolicyCard";
import Postfaecher from "./settings/Postfaecher"; import Postfaecher from "./settings/Postfaecher";
import RestoreCard from "./settings/RestoreCard"; import RestoreCard from "./settings/RestoreCard";
@@ -76,7 +77,7 @@ export default function Settings() {
</div> </div>
)} )}
<div className="settings-grid"> <Kartenspalten>
<div className="card"> <div className="card">
<h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2> <h2 style={{ marginTop: 0 }}>{t("settings.general")}</h2>
<label> <label>
@@ -164,7 +165,7 @@ export default function Settings() {
<Postfaecher /> <Postfaecher />
<RestoreCard /> <RestoreCard />
</div> </Kartenspalten>
</div> </div>
); );
} }