Files
gg a67921ec74 feat(isdirty): Navigations-Guard gegen ungespeichertes Verlassen von Bearbeitungsmasken
Zentraler DirtyGuard (dirtyguard.ts) mit DirtyMaskHelper-Klasse und
3-Button-Bestätigungs-Modal (Speichern/Verwerden/Abbrechen). Form meldet
Dirty-State via onDirtyChange nach oben; input-Event ergänzt change für
frühzeitiges Dirty-Marking. Alle App-Navigationen (Breadcrumb, Menü,
In-Masken-Buttons, Abbrechen) laufen über guardedNavigate. Auto-Redraw
bei dirty per e.redraw=false unterbunden, damit DOM-Eingaben erhalten
bleiben (Input-Widget hat kein Live-Binding).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-09-19 18:21:58 +02:00

112 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import m from "mithril"
/** Ein Breadcrumb-Glied: Route, Filter-Params und Anzeige-Label einer
* Vorgänger-Ansicht in der Aufrufkette. */
export interface iBreadcrumb {
route: string
params: any
label: string
}
/** Attribute der BreadcrumbBar: die Vorgänger-Kette (ancestors) und das
* Label der aktuellen Ansicht (wird als letztes, nicht klickbares Glied
* angezeigt). Ist breadcrumbs leer, wird keine Leiste gerendert.
* onnavigate (optional): Navigations-Callback, der statt m.route.set
* verwendet wird — typischerweise guardedNavigate aus dirtyguard.ts,
* so dass bei ungespeicherten Änderungen das Bestätigungs-Modal erscheint. */
export interface iBreadcrumbBar {
breadcrumbs: iBreadcrumb[]
current: string
onnavigate?: (route: string, params: any, e?: Event) => void
}
/** Parst den Route-Param `bc` (JSON-String) in ein Breadcrumb-Array.
* Liefert bei fehlendem/ungültigem `bc` ein leeres Array. */
export function parseBc(bc: string | undefined): iBreadcrumb[] {
if (!bc) return []
try { return JSON.parse(bc) } catch { return [] }
}
/** Serialisiert ein Breadcrumb-Array in den Route-Param `bc`.
* Liefert undefined bei leerer Kette (kein bc in der Route). */
export function serializeBc(crumbs: iBreadcrumb[]): string | undefined {
return crumbs.length > 0 ? JSON.stringify(crumbs) : undefined
}
export const BreadcrumbBar: m.ClosureComponent<iBreadcrumbBar> = () => {
return {
view: (vnode) => {
const crumbs = vnode.attrs.breadcrumbs || []
const current = vnode.attrs.current
// Ohne Vorgänger und ohne aktuelles Label keine Leiste rendern
// (Top-Level-Ansichten ohne Hierarchie-Kontext).
if (crumbs.length === 0 && !current) return null
const items: any[] = []
crumbs.forEach((crumb: iBreadcrumb, i: number) => {
if (i > 0) {
items.push(m("span.w3-small", {
style: {margin: '0 2px', opacity: '0.6'}
}, "›"))
}
// Vorgänger sind klickbar: Navigation zur jeweiligen Ansicht
// mit deren Filter-Params und der auf dieses Glied gekürzten
// Kette als neuem bc.
items.push(m("a.w3-button.w3-small.w3-theme-l2", {
style: {
padding: '2px 8px',
'border-radius': '4px',
'text-decoration': 'none',
'white-space': 'nowrap'
},
title: "Zurück zu " + crumb.label,
onclick: (e: MouseEvent) => {
const subChain = crumbs.slice(0, i)
const params: any = {...crumb.params}
const bc = serializeBc(subChain)
if (bc) params.bc = bc
// Navigation über den Guard, so dass bei dirty-Maske
// das Bestätigungs-Modal erscheint. Event wird
// durchgereicht, damit guardedNavigate den auto-redraw
// unterbinden kann.
if (vnode.attrs.onnavigate) {
vnode.attrs.onnavigate(crumb.route, params, e)
} else {
m.route.set(crumb.route, params)
}
}
}, crumb.label))
})
// Aktuelle Ansicht als letztes, nicht klickbares Glied (fett).
if (current) {
if (crumbs.length > 0) {
items.push(m("span.w3-small", {
style: {margin: '0 2px', opacity: '0.6'}
}, "›"))
}
items.push(m("span.w3-small.w3-theme-d3", {
style: {
padding: '2px 8px',
'border-radius': '4px',
'font-weight': 'bold',
'white-space': 'nowrap'
}
}, current))
}
return m("div.w3-theme-l1", {
style: {
display: 'flex',
'flex-wrap': 'nowrap',
'align-items': 'center',
padding: '4px 8px',
'border-radius': '4px',
'margin-bottom': '8px',
'overflow-x': 'auto',
'overflow-y': 'hidden'
}
}, items)
}
}
}