Merge pull request 'Seitenleiste: Marke mit Münzstapel, ein Klick schiebt sie auf Symbolbreite' (#36) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/36
This commit is contained in:
fds2610 2026-08-18 12:35:42 +02:00
commit b0675dc58b
7 changed files with 440 additions and 9 deletions

View file

@ -1,5 +1,28 @@
# Änderungen
## Seitenleiste: Marke mit Münzstapel, ein Klick schiebt sie auf Symbolbreite (18.08.2026)
Oben in der Seitenleiste steht jetzt eine richtige **Marke**: das Bild `Coinstack.png` aus den
Altdaten als Symbol (`public/static/coinstack.png`, auf 128 px gerechnet) und daneben der
zweifarbige Schriftzug **Finanz**manager – dieselbe Machart wie die `sidebar-brand` im
Schwesterprojekt Steuerkram.
Die Marke ist zugleich der **Schalter**: Ein Klick schiebt die Seitenleiste von 232 px auf
64 px zusammen, dann stehen dort nur noch das Markensymbol und die Symbole der Menüpunkte –
mit unveränderten Zielen; der gewonnene Platz geht an den Hauptbereich. Ein Klick auf das
Symbol ganz oben klappt sie wieder auf ihre alte Breite auf.
Zwei Feinheiten: Der Zustand steht in einem **Cookie**, nicht im `localStorage` – so rendert
der Server die Leiste schon in der richtigen Breite, statt sie bei jedem Seitenwechsel kurz
breit aufblitzen zu lassen. Und die Beschriftungen werden eingeklappt nur **aus dem Bild
geschoben** (`clip-path`), nicht mit `display:none` entfernt: Sonst verlören die Verweise
ihren Namen für Vorleseprogramme. In schmalen Fenstern, wo die Navigation ohnehin waagerecht
liegt, bleibt alles wie gehabt.
Nebenbei behoben: Die Marke der Seitenleiste hieß bisher schlicht `.marke` – dieselbe Klasse
wie die kleine Statusmarke weiter unten im Stilblatt, deren Regeln sie überschrieben. Sie
heißt jetzt `.seitenleisten-marke`.
## Bankleitzahlen-Verzeichnis: die FinTS-Adresse nachschlagen statt suchen (18.08.2026)
Beim Anlegen einer FinTS-Verbindung fehlt in aller Regel genau eine Angabe: der

View file

@ -24,6 +24,7 @@
--navi-text: #cdd7e2;
--navi-text-aktiv: #ffffff;
--navi-aktiv: #1f6feb;
--navi-marke: #6ea8fe;
--positiv: #1a7f4b;
--negativ: #c0392b;
--warnung: #9a6700;
@ -117,14 +118,41 @@ code {
overflow-y: auto;
}
.marke {
/* Die Marke der Seitenleiste hat eine eigene Klasse: ".marke" allein ist
weiter unten die kleine Statusmarke, deren Regeln hier sonst gewönnen. */
.seitenleisten-marke {
display: flex;
align-items: center;
gap: .6rem;
width: 100%;
background: none;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, .08);
font-family: inherit;
font-size: 1.05rem;
font-weight: 600;
font-weight: 700;
color: #fff;
padding: 1.25rem 1.25rem 1rem;
padding: 1.1rem 1.25rem;
letter-spacing: .01em;
text-align: left;
cursor: pointer;
}
.seitenleisten-marke:hover { background: rgba(255, 255, 255, .05); }
.marken-symbol {
width: 1.6em;
height: 1.6em;
flex-shrink: 0;
object-fit: contain;
}
.marken-text { color: #fff; white-space: nowrap; }
/* Wie im Steuerkram: die zweite Worthälfte trägt die Akzentfarbe. */
.marken-text span { color: var(--navi-marke); }
.seitenleiste ul {
list-style: none;
margin: 0;
@ -166,6 +194,32 @@ code {
min-width: 0;
}
/* ── Eingeklappte Seitenleiste ───────────────────────────────────────────── */
/* Ein Klick auf die Marke schiebt die Leiste auf Symbolbreite zusammen. Die
Beschriftungen verschwinden nur aus dem Bild, nicht aus dem Baum – sonst
verlören die Verweise ihren Namen für Vorleseprogramme. */
.rahmen.eingeklappt { grid-template-columns: 64px 1fr; }
.rahmen.eingeklappt .seitenleisten-marke,
.rahmen.eingeklappt .seitenleiste a {
justify-content: center;
gap: 0;
padding-left: 0;
padding-right: 0;
}
.rahmen.eingeklappt .marken-text,
.rahmen.eingeklappt .beschriftung {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* ── Werkzeugleiste ──────────────────────────────────────────────────────── */
.werkzeugleiste {
@ -2070,7 +2124,25 @@ table.liste tr.luecke td {
/* ── Schmale Fenster ─────────────────────────────────────────────────────── */
@media (max-width: 820px) {
.rahmen { grid-template-columns: 1fr; }
.rahmen,
.rahmen.eingeklappt { grid-template-columns: 1fr; }
/* Waagerecht gibt es nichts zu sparen: Beschriftungen bleiben sichtbar. */
.rahmen.eingeklappt .marken-text,
.rahmen.eingeklappt .beschriftung {
position: static;
width: auto;
height: auto;
clip-path: none;
}
.rahmen.eingeklappt .seitenleisten-marke,
.rahmen.eingeklappt .seitenleiste a {
justify-content: flex-start;
gap: .6rem;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.seitenleiste {
position: static;

View file

@ -11,6 +11,24 @@
(function () {
'use strict';
// Die Marke klappt die Seitenleiste auf Symbolbreite zusammen und wieder
// auf. Der Zustand steht in einem Cookie und nicht im localStorage: So
// rendert der Server die Leiste schon in der richtigen Breite, statt sie
// bei jedem Seitenwechsel kurz breit aufblitzen zu lassen.
const schalter = document.querySelector('[data-seitenleiste-schalter]');
const rahmen = document.querySelector('.rahmen');
if (schalter && rahmen) {
schalter.addEventListener('click', function () {
const eingeklappt = rahmen.classList.toggle('eingeklappt');
schalter.setAttribute('aria-expanded', eingeklappt ? 'false' : 'true');
schalter.title = eingeklappt ? 'Seitenleiste ausklappen' : 'Seitenleiste einklappen';
document.cookie = 'seitenleiste=' + (eingeklappt ? 'eingeklappt' : 'offen')
+ '; path=/; max-age=31536000; samesite=lax';
});
}
// Formulare mit data-bestaetigen fragen vor dem Absenden nach.
// In der Capture-Phase, damit die Rückfrage auch dann greift, wenn ein
// anderer Handler das Ereignis unterwegs stoppt.

BIN
public/static/coinstack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -20,6 +20,11 @@ $navigation = [
['pfad' => '/verwaltung', 'name' => 'Verwaltung', 'icon' => '⚙', 'auch' => ['/termine', '/vertraege']],
];
// Ob die Seitenleiste eingeklappt ist, merkt sich ein Cookie. Das Skript
// setzt es beim Umschalten; hier entscheidet es schon beim Rendern, damit die
// breite Leiste nicht bei jedem Seitenwechsel kurz aufblitzt.
$eingeklappt = ($_COOKIE['seitenleiste'] ?? '') === 'eingeklappt';
/** @param list<string> $auch */
$istAktiv = static function (string $pfad, array $auch = []) use ($aktuellerPfad): bool {
if (!is_string($aktuellerPfad)) {
@ -50,16 +55,26 @@ $istAktiv = static function (string $pfad, array $auch = []) use ($aktuellerPfad
</head>
<body>
<?php include __DIR__ . '/meldungen.php'; ?>
<div class="rahmen">
<nav class="seitenleiste" aria-label="Hauptnavigation">
<div class="marke">Finanzmanager</div>
<div class="rahmen<?= $eingeklappt ? ' eingeklappt' : '' ?>">
<nav class="seitenleiste" id="seitenleiste" aria-label="Hauptnavigation">
<!-- Die Marke ist zugleich der Schalter: Ein Klick schiebt die
Seitenleiste auf Symbolbreite zusammen und wieder auf. -->
<button type="button" class="seitenleisten-marke" data-seitenleiste-schalter
aria-controls="seitenleiste" aria-expanded="<?= $eingeklappt ? 'false' : 'true' ?>"
title="<?= $eingeklappt ? 'Seitenleiste ausklappen' : 'Seitenleiste einklappen' ?>">
<img src="/static/coinstack.png" alt="" class="marken-symbol">
<span class="marken-text">Finanz<span>manager</span></span>
</button>
<ul>
<?php foreach ($navigation as $eintrag): ?>
<li>
<?php /* title bleibt hinten: Der Navigationstest prüft href und
class in dieser Reihenfolge unmittelbar hintereinander. */ ?>
<a href="<?= View::h($eintrag['pfad']) ?>"
class="<?= $istAktiv($eintrag['pfad'], $eintrag['auch'] ?? []) ? 'aktiv' : '' ?>">
class="<?= $istAktiv($eintrag['pfad'], $eintrag['auch'] ?? []) ? 'aktiv' : '' ?>"
title="<?= View::h($eintrag['name']) ?>">
<span class="icon" aria-hidden="true"><?= $eintrag['icon'] ?></span>
<?= View::h($eintrag['name']) ?>
<span class="beschriftung"><?= View::h($eintrag['name']) ?></span>
</a>
</li>
<?php endforeach; ?>

View file

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
/**
* Die Marke der Seitenleiste trägt das Coinstack-Symbol und den zweifarbigen
* Schriftzug – und dient zugleich als Schalter: Ein Klick schiebt die Leiste
* auf Symbolbreite zusammen, ein zweiter klappt sie wieder auf.
*/
class SeitenleisteEinklappenTest extends TestCase
{
protected string $testName = 'Test 33: Seitenleiste ein- und ausklappen';
public function run(): void
{
$this->markeZeigtSymbolUndSchriftzug();
$this->markeIstDerSchalter();
$this->cookieEntscheidetUeberDieBreite();
$this->stilblattSchiebtNurDieBeschriftungenWeg();
$this->skriptMerktSichDenZustand();
}
private function markeZeigtSymbolUndSchriftzug(): void
{
$symbol = dirname(__DIR__) . '/public/static/coinstack.png';
$this->assert(is_file($symbol), 'Das Markensymbol liegt unter public/static/coinstack.png');
$this->assertEquals('image/png', (string)(getimagesize($symbol)['mime'] ?? ''), 'Das Symbol ist ein PNG');
$leiste = $this->seitenleiste($this->rendere('/'));
$this->assert(
str_contains($leiste, '<img src="/static/coinstack.png" alt="" class="marken-symbol">'),
'Die Marke führt das Coinstack-Symbol'
);
$this->assert(
str_contains($leiste, '<span class="marken-text">Finanz<span>manager</span></span>'),
'Der Schriftzug ist wie im Steuerkram zweigeteilt'
);
$this->assert(
!str_contains($leiste, '<div class="marke">'),
'Die alte Marke als Statusmarken-Div ist verschwunden'
);
}
private function markeIstDerSchalter(): void
{
$leiste = $this->seitenleiste($this->rendere('/'));
$this->assert(
(bool)preg_match('#<button type="button" class="seitenleisten-marke" data-seitenleiste-schalter#', $leiste),
'Die Marke ist ein Schalter'
);
$this->assert(
str_contains($leiste, 'aria-controls="seitenleiste"'),
'Der Schalter nennt die Seitenleiste, die er bedient'
);
$this->assert(
str_contains($leiste, 'id="seitenleiste"'),
'Die Seitenleiste trägt die angesprochene Kennung'
);
// Die Symbole behalten ihr Ziel; nur die Beschriftung bekommt eine
// eigene Hülle, damit sie sich eingeklappt wegschieben lässt.
foreach (['/', '/konten', '/banking', '/zahlungsverkehr', '/auswertungen', '/planung', '/wertpapiere', '/verwaltung'] as $ziel) {
$this->assert(
(bool)preg_match('#href="' . preg_quote($ziel, '#') . '"#', $leiste),
'Der Menüpunkt ' . $ziel . ' bleibt erreichbar'
);
}
$this->assertEquals(
8,
substr_count($leiste, '<span class="beschriftung">'),
'Jeder Menüpunkt trägt seine Beschriftung in einer eigenen Hülle'
);
$this->assertEquals(
8,
substr_count($leiste, '<span class="icon" aria-hidden="true">'),
'Und jeder behält sein Symbol'
);
$this->assert(
str_contains($leiste, 'title="Kontenübersicht"'),
'Eingeklappt verrät der Titel, wohin ein Symbol führt'
);
}
private function cookieEntscheidetUeberDieBreite(): void
{
$offen = $this->rendere('/');
$this->assert(str_contains($offen, '<div class="rahmen">'), 'Ohne Cookie steht die Leiste offen');
$this->assert(str_contains($offen, 'aria-expanded="true"'), 'Der Schalter meldet die offene Leiste');
$this->assert(str_contains($offen, 'title="Seitenleiste einklappen"'), 'Und lädt zum Einklappen ein');
$eingeklappt = $this->rendere('/', 'eingeklappt');
$this->assert(
str_contains($eingeklappt, '<div class="rahmen eingeklappt">'),
'Mit Cookie rendert der Server die schmale Leiste sofort'
);
$this->assert(str_contains($eingeklappt, 'aria-expanded="false"'), 'Der Schalter meldet die schmale Leiste');
$this->assert(
str_contains($eingeklappt, 'title="Seitenleiste ausklappen"'),
'Und lädt zum Ausklappen ein'
);
$unsinn = $this->rendere('/', 'irgendwas');
$this->assert(
str_contains($unsinn, '<div class="rahmen">'),
'Ein fremder Cookie-Wert lässt die Leiste offen'
);
}
private function stilblattSchiebtNurDieBeschriftungenWeg(): void
{
$stil = $this->stilblatt();
$this->assert(
(bool)preg_match('#\.rahmen\.eingeklappt\s*\{[^}]*grid-template-columns:\s*64px 1fr#', $stil),
'Eingeklappt bleibt nur eine Symbolspalte stehen'
);
$this->assert(
(bool)preg_match('#\.rahmen\s*\{[^}]*grid-template-columns:\s*232px 1fr#', $stil),
'Offen behält die Leiste ihre ursprüngliche Breite'
);
// display:none nähme den Verweisen ihren Namen – deshalb wird die
// Beschriftung nur aus dem Bild geschoben.
$this->assert(
(bool)preg_match(
'#\.rahmen\.eingeklappt \.marken-text,\s*\.rahmen\.eingeklappt \.beschriftung\s*\{[^}]*clip-path:\s*inset\(50%\)#',
$stil
),
'Die Beschriftungen verschwinden aus dem Bild, nicht aus dem Baum'
);
$this->assert(
!preg_match('#\.rahmen\.eingeklappt \.marken-symbol[^{]*\{[^}]*display:\s*none#', $stil),
'Das Markensymbol bleibt eingeklappt sichtbar'
);
$this->assert(
(bool)preg_match('#\.seitenleisten-marke\s*\{[^}]*cursor:\s*pointer#', $stil),
'Die Marke zeigt sich als anklickbar'
);
$this->assert(
(bool)preg_match('#\.marken-text span\s*\{\s*color:\s*var\(--navi-marke\)#', $stil),
'Die zweite Worthälfte trägt die Akzentfarbe'
);
$this->assert(
(bool)preg_match('#--navi-marke:\s*#', $stil),
'Die Akzentfarbe steht als Custom Property'
);
// In schmalen Fenstern liegt die Navigation waagerecht - dort gibt es
// nichts einzusparen.
$schmal = substr($stil, (int)strpos($stil, '@media (max-width: 820px)'));
$this->assert(
(bool)preg_match('#\.rahmen,\s*\.rahmen\.eingeklappt\s*\{\s*grid-template-columns:\s*1fr#', $schmal),
'Im schmalen Fenster spielt das Einklappen keine Rolle'
);
$this->assert(
(bool)preg_match('#\.rahmen\.eingeklappt \.beschriftung\s*\{[^}]*clip-path:\s*none#', $schmal),
'Und die Beschriftungen bleiben dort lesbar'
);
}
private function skriptMerktSichDenZustand(): void
{
$skript = (string)file_get_contents(dirname(__DIR__) . '/public/static/app.js');
$this->assert(
str_contains($skript, "querySelector('[data-seitenleiste-schalter]')"),
'Das Skript findet den Schalter'
);
$this->assert(
str_contains($skript, "rahmen.classList.toggle('eingeklappt')"),
'Ein Klick klappt die Leiste um'
);
$this->assert(
(bool)preg_match("#document\.cookie = 'seitenleiste='#", $skript),
'Der Zustand landet im Cookie, damit der Server ihn kennt'
);
$this->assert(
str_contains($skript, 'samesite=lax'),
'Das Cookie reist nicht zu fremden Seiten mit'
);
$this->assert(
str_contains($skript, "schalter.setAttribute('aria-expanded'"),
'Der Schalter meldet den neuen Zustand'
);
}
private function stilblatt(): string
{
return (string)file_get_contents(dirname(__DIR__) . '/public/static/app.css');
}
private function rendere(string $pfad, ?string $cookie = null): string
{
$vorher = $_COOKIE['seitenleiste'] ?? null;
if ($cookie === null) {
unset($_COOKIE['seitenleiste']);
} else {
$_COOKIE['seitenleiste'] = $cookie;
}
$titel = 'Test';
$flash = [];
$benutzer = null;
$csrfToken = 'x';
$aktuellerPfad = $pfad;
ob_start();
include dirname(__DIR__) . '/src/Views/layout/kopf.php';
$html = (string)ob_get_clean();
if ($vorher === null) {
unset($_COOKIE['seitenleiste']);
} else {
$_COOKIE['seitenleiste'] = $vorher;
}
return $html;
}
/** Nur die Seitenleiste – Verweise im Inhalt sollen nicht mitzählen. */
private function seitenleiste(string $html): string
{
$anfang = (int)strpos($html, '<nav');
$ende = (int)strpos($html, '</nav>');
return substr($html, $anfang, $ende - $anfang);
}
}

View file

@ -0,0 +1,72 @@
// @ts-check
const { test, expect } = require('@playwright/test')
const BENUTZER = process.env.E2E_USER || 'e2e'
const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort'
async function anmelden(page) {
await page.goto('/login')
test.skip(page.url().includes('/einrichtung'), 'Instanz noch nicht eingerichtet')
await page.fill('#benutzername', BENUTZER)
await page.fill('#passwort', PASSWORT)
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/$/)
}
test.describe('Seitenleiste ein- und ausklappen', () => {
test('Klick auf die Marke schiebt die Leiste auf Symbolbreite und zurück', async ({ page }) => {
await anmelden(page)
const rahmen = page.locator('.rahmen')
const marke = page.locator('[data-seitenleiste-schalter]')
const symbol = page.locator('.marken-symbol')
const beschriftung = page.locator('.seitenleiste .beschriftung').first()
await expect(symbol).toBeVisible()
await expect(beschriftung).toBeVisible()
const breitOffen = (await page.locator('.seitenleiste').boundingBox()).width
await marke.click()
await expect(rahmen).toHaveClass(/eingeklappt/)
await expect(marke).toHaveAttribute('aria-expanded', 'false')
// Das Markensymbol und die Symbole der Menüpunkte bleiben stehen,
// nur die Beschriftungen verschwinden.
await expect(symbol).toBeVisible()
await expect(beschriftung).toBeHidden()
await expect(page.locator('.seitenleiste .icon').first()).toBeVisible()
const breitEingeklappt = (await page.locator('.seitenleiste').boundingBox()).width
expect(breitEingeklappt).toBeLessThan(breitOffen)
// Der Hauptbereich bekommt den gewonnenen Platz.
const hauptOffen = breitOffen
expect((await page.locator('.hauptbereich').boundingBox()).width).toBeGreaterThan(0)
await marke.click()
await expect(rahmen).not.toHaveClass(/eingeklappt/)
await expect(marke).toHaveAttribute('aria-expanded', 'true')
await expect(beschriftung).toBeVisible()
expect((await page.locator('.seitenleiste').boundingBox()).width).toBe(hauptOffen)
})
test('Die Symbole führen eingeklappt zu denselben Seiten', async ({ page }) => {
await anmelden(page)
await page.locator('[data-seitenleiste-schalter]').click()
await expect(page.locator('.rahmen')).toHaveClass(/eingeklappt/)
await page.locator('.seitenleiste a[href="/konten"]').click()
await expect(page).toHaveURL(/\/konten/)
await expect(page.locator('h1')).toContainText('Kontenübersicht')
// Der Zustand hält über den Seitenwechsel hinweg – ohne Aufblitzen,
// weil der Server ihn schon aus dem Cookie kennt.
await expect(page.locator('.rahmen')).toHaveClass(/eingeklappt/)
await expect(page.locator('.seitenleiste .beschriftung').first()).toBeHidden()
await page.locator('[data-seitenleiste-schalter]').click()
await expect(page.locator('.rahmen')).not.toHaveClass(/eingeklappt/)
})
})