feat(ui): Meldungen mit Rückgängig, dazu jsdom und ein Fehler in der letzten Behebung

Vorher bestätigte nichts irgendetwas, und Fehler verschwanden still: Die
Handler fingen sie mit `catch { /* bleibt stehen */ }` ab. Wer eine Datei
löschen wollte und nicht durfte, sah genau dasselbe wie jemand, bei dem es
klappte – nichts.

Neu: components/Toasts.tsx. Kein Context, sondern Modulzustand, damit auch
api/client.ts melden kann, ohne Komponente zu sein. Löschen, Umbenennen und
Verschieben bekommen ein „Undo" – beim Löschen ist das der Papierkorb plus
einen Aufruf, kostet also fast nichts. Ordner löschen bekommt bewusst keines:
Der Ordner ist wirklich weg, nur seine Dateien liegen im Papierkorb, und ein
Knopf, der etwas anderes tut als er verspricht, wäre schlimmer als keiner.

Dazu die erste Animation im Projekt – und gleich die Regel dazu:
prefers-reduced-motion wird respektiert.

**jsdom ist jetzt in den devDependencies**, und der erste DOM-Test hat sofort
einen Fehler in der Behebung von vorgestern gefunden:

Der Zähler für selbst ausgelöste Verlaufsschritte setzt voraus, dass jedes
`back()` auch ein `popstate` nach sich zieht. Das gilt nicht – folgt im selben
Durchlauf ein `pushState`, verwirft der Browser den Sprung. Der Zähler blieb
dann stehen, und die nächste echte Zurück-Geste wurde verschluckt: Statt die
Ansicht zu schließen, hätte man die Anwendung verlassen.

Jetzt wird erst im nächsten Durchlauf entschieden und geprüft, ob der eigene
Eintrag noch obenauf liegt. Nur dann kommt es zum Sprung – und dann folgt das
`popstate` garantiert. Der Zuhörer hängt am Modul statt an der Ansicht; vorher
entfernte ihn deren Aufräumer, bevor der eigene Sprung ausgeführt war.

Ehrlich dokumentiert: Den ursprünglichen Fehler bilden diese Tests **nicht**
ab. jsdom verwirft den Sprung, das verhängnisvolle popstate entsteht dort nie
– mit der alten, kaputten Fassung blieben alle Tests grün. Dafür bräuchte es
einen echten Browser. Steht so in TESTING.md und im Test selbst.

Geprüft: tsc sauber, 41 Vitest-Tests (4 neue unter Mutation nachgewiesen),
alle 234 Prüfungen der sieben Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-18 14:05:12 +02:00
1 parent 7d45941c03
commit 94a803bf7a
9 files changed
+1131 -51

No files matched your search

+6
View File
@@ -4,6 +4,7 @@ import type { FolderItem, User } from '../api/client'
import { useNavHistory, type View } from '../hooks/useNavHistory'
import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
import { useBackClose } from '../hooks/useBackClose'
import { Toaster } from './Toasts'
import Sidebar from './Sidebar'
import FileBrowser from '../pages/FileBrowser'
import TrashPage from '../pages/TrashPage'
@@ -95,6 +96,11 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
onOpenSidebar: () => setSidebarOpen(true),
}}>
<div className="aether-vh" style={{ display: 'flex', overflow: 'hidden' }}>
{/* Die Meldungen liegen über allem und sind an genau einer Stelle
eingehängt – absetzen darf sie jedes Modul, anzeigen tut sie nur
diese eine Anzeige. */}
<Toaster />
{/* Der abdunkelnde Grund hinter der aufgeschobenen Leiste.
Die Klasse blendet ihn aus, sobald die Leiste fest im Bild steht:
Dort liegt sie im Fluss und **ohne** z-index, ein Grund darüber
+206
View File
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { APIError } from '../api/client'
/**
* Kurze Rückmeldungen am unteren Rand – und, wo es geht, ein „Rückgängig".
*
* Vorher bestätigte **nichts** irgendetwas: Verschieben, Umbenennen, Teilen
* geschah, und die Oberfläche schwieg. Schlimmer noch das Scheitern – die
* Handler fingen ihre Fehler mit `catch { /* bleibt stehen *\/ }` ab. Wer eine
* Datei löschen wollte und keine Berechtigung hatte, sah exakt dasselbe wie
* jemand, bei dem es geklappt hat: nichts.
*
* **Kein Context, sondern Modulzustand.** Eine Meldung soll von überall
* abgesetzt werden können – auch aus `api/client.ts`, das keine Komponente
* ist und in keinem Anbieter steckt. Derselbe Weg wie bei `useBackClose`:
* ein Zustand im Modul, und die Anzeige hört zu.
*/
export type ToastTone = 'info' | 'error'
export interface ToastAction {
label: string
/** Läuft genau einmal; danach schließt die Meldung. */
run: () => void | Promise<void>
}
interface Toast {
id: number
text: string
tone: ToastTone
action?: ToastAction
}
let toasts: Toast[] = []
let seq = 0
const hoerer = new Set<(t: Toast[]) => void>()
/** Höchstens so viele gleichzeitig. Darüber stapelt sich Lärm, kein Inhalt. */
const maxSichtbar = 4
/**
* Wie lange eine Meldung steht.
*
* Mit Knopf deutlich länger: Man muss sie lesen, verstehen, *entscheiden* und
* dann treffen. Vier Sekunden reichen zum Lesen, nicht zum Entscheiden – ein
* „Rückgängig", das weg ist, bevor die Hand da war, ist schlimmer als keines,
* weil es die Erwartung geweckt hat.
*/
const dauerSchlicht = 4000
const dauerMitKnopf = 10000
function melden() {
for (const h of hoerer) h(toasts)
}
function schliessen(id: number) {
toasts = toasts.filter(t => t.id !== id)
melden()
}
function zeigen(text: string, tone: ToastTone, action?: ToastAction): number {
const id = ++seq
toasts = [...toasts, { id, text, tone, action }].slice(-maxSichtbar)
melden()
// Bewusst `setTimeout` statt `window.setTimeout`: Dieselbe Funktion, aber
// ohne `window` läuft der Zustandsteil dieser Datei auch im Test, der ohne
// DOM startet.
setTimeout(() => schliessen(id), action ? dauerMitKnopf : dauerSchlicht)
return id
}
export const toast = {
/** Es hat geklappt. */
info: (text: string) => zeigen(text, 'info'),
/** Es hat nicht geklappt – bleibt genauso lange stehen, das ist Absicht:
* Eine Fehlermeldung, die man festhalten will, steht in der Konsole. */
error: (text: string) => zeigen(text, 'error'),
/**
* Es hat geklappt, und man kann es zurücknehmen.
*
* Der eigentliche Grund, warum es diese Datei gibt. Löschen ist bei uns ein
* Papierkorb, Wiederherstellen ein einziger Aufruf – das Rückgängig kostet
* also fast nichts und nimmt dem Löschen den Schrecken.
*/
undo: (text: string, run: ToastAction['run'], label = 'Undo') =>
zeigen(text, 'info', { label, run }),
schliessen,
}
/** Einmal im Gerüst einhängen. */
export function Toaster() {
const [liste, setListe] = useState<Toast[]>(toasts)
const [laufend, setLaufend] = useState<number | null>(null)
useEffect(() => {
hoerer.add(setListe)
return () => { hoerer.delete(setListe) }
}, [])
if (liste.length === 0) return null
return (
<div
// `polite`, nicht `assertive`: Eine Bestätigung darf warten, bis der
// Vorleser den laufenden Satz beendet hat.
aria-live="polite"
style={{
position: 'fixed', zIndex: 300,
left: 'calc(12px + var(--safe-left))', right: 12,
bottom: 'calc(12px + var(--safe-bottom))',
display: 'flex', flexDirection: 'column', gap: 8,
alignItems: 'center',
// Der Bereich selbst fängt keine Klicks – darunter liegt die
// Dateiliste, und die soll bedienbar bleiben. Nur die Meldungen
// selbst nehmen wieder an.
pointerEvents: 'none',
}}
>
{liste.map(t => (
<div
key={t.id}
className="aether-toast"
style={{
pointerEvents: 'auto',
display: 'flex', alignItems: 'center', gap: 14,
maxWidth: 480, width: 'fit-content',
padding: '11px 14px',
borderRadius: 10,
background: 'var(--color-surface)',
border: `1px solid ${t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-border)'}`,
boxShadow: '0 10px 30px rgba(0,0,0,0.35)',
fontSize: 13,
color: t.tone === 'error' ? 'var(--color-danger)' : 'var(--color-text)',
}}
>
<span style={{ flex: 1, minWidth: 0 }}>{t.text}</span>
{t.action && (
<button
disabled={laufend === t.id}
onClick={async () => {
setLaufend(t.id)
try {
await t.action!.run()
} finally {
// Auch nach einem Fehler schließen: Der Aufrufer meldet ihn
// selbst, und eine stehengebliebene Meldung mit totem Knopf
// wäre die schlechtere Antwort.
setLaufend(null)
schliessen(t.id)
}
}}
style={{
flexShrink: 0,
border: 'none', background: 'none',
color: 'var(--color-primary)',
font: 'inherit', fontWeight: 600,
cursor: laufend === t.id ? 'default' : 'pointer',
padding: '2px 4px',
opacity: laufend === t.id ? 0.5 : 1,
}}
>
{t.action.label}
</button>
)}
<button
onClick={() => schliessen(t.id)}
aria-label="Dismiss"
style={{
flexShrink: 0,
border: 'none', background: 'none', cursor: 'pointer',
color: 'var(--color-text-muted)', display: 'flex', padding: 2,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
))}
</div>
)
}
/**
* Aus einem Fehler einen Satz machen, den man lesen kann.
*
* Der Server schreibt seine Meldungen selbst und in ganzen Sätzen („You only
* have read access to this file") – die ist besser als alles, was wir hier
* erfinden könnten, und wird deshalb bevorzugt. Der Rückfalltext greift nur,
* wenn gar nichts da ist.
*
* Zwei Fälle bekommen einen eigenen Satz, weil die Rohfassung nichts sagt:
* `403` ohne Text, und der Abbruch ohne Antwort (Status 0) – „Unknown error"
* hilft niemandem, „keine Verbindung" schon.
*/
export function fehlertext(err: unknown, rückfall: string): string {
if (err instanceof APIError) {
if (err.status === 0) return 'No connection to the server.'
if (err.message && err.message !== 'Unknown error') return err.message
if (err.status === 403) return 'You do not have permission for that.'
return rückfall
}
return err instanceof Error && err.message ? err.message : rückfall
}