Die schließende Klammer fehlte im calc() der Scroll-Container-Höhe, wodurch die Höhe auf auto fiel und der Div nie selbst scrollte — Sticky bezog sich damit auf keinen funktionierenden Scroll-Container. Zusätzlich das overflow-y am table-Element entfernt (macht die Tabelle selbst zum Scroll-Container und bricht Sticky) und w3-theme als eigener Hintergrund auf die th gelegt, damit beim Scrollen keine Zeilen durchscheinen. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
340 lines
17 KiB
TypeScript
340 lines
17 KiB
TypeScript
import m from "mithril"
|
|
|
|
export interface iTreenode {
|
|
nodevalue: any
|
|
children: iTreenode[]
|
|
expand: boolean
|
|
hasChildren: boolean
|
|
isChecked?: boolean
|
|
}
|
|
|
|
export class Treenode implements iTreenode {
|
|
nodevalue:any = null
|
|
children: iTreenode[] = []
|
|
expand: boolean = true
|
|
isChecked?: boolean = false
|
|
constructor(nodevalue: any = null, expand: boolean = false, children: iTreenode[] = [], isChecked: boolean = false) {
|
|
this.nodevalue = nodevalue
|
|
this.children = children
|
|
this.expand = expand
|
|
this.isChecked = isChecked
|
|
}
|
|
get hasChildren() {
|
|
return (this.children.length > 0)
|
|
}
|
|
}
|
|
|
|
export interface Columnmapper {
|
|
(row:iTreenode) : any[]
|
|
}
|
|
|
|
export interface iTreetable {
|
|
treenodes: iTreenode[]
|
|
columnmapper: Columnmapper
|
|
onRowMarked?: (node: iTreenode) => void
|
|
filter?: (row:iTreenode, searchvalue:string) => boolean
|
|
/** Optional: Callback, der für eine Treenode bestimmt, ob die gesamte
|
|
* Tabellenzeile als markiert (selektiert) dargestellt werden soll.
|
|
* Wenn gesetzt, übernimmt das Widget die Hintergrund-Einfärbung der
|
|
* <tr>-Zeile; die columnmapper-Inhalte brauchen dann kein eigenes
|
|
* Selektions-Styling mehr. */
|
|
isSelected?: (node: iTreenode) => boolean
|
|
/** Optional: Spaltenüberschriften (ein Titel je columnmapper-Spalte,
|
|
* 1:1 von links nach rechts). Wenn gesetzt, rendert das Widget eine
|
|
* Kopfzeile über den Datenzeilen. Hinweis zur ersten Spalte: Die
|
|
* Einrückung/der Expand-Pfeil der Baumdarstellung liegt im selben
|
|
* td wie die ERSTE columnmapper-Zelle — der erste Titel steht
|
|
* daher direkt über der ersten Datenspalte. Die Kopfzeile bleibt
|
|
* beim Scrollen sticky sichtbar. */
|
|
header?: string[]
|
|
/** Optional: Ein-/Ausklappen-Buttons (unfold_more/unfold_less) in der
|
|
* Topbar anzeigen. Default true. Für flache Listen ohne Hierarchie
|
|
* auf false setzen — die Topbar entfällt dann ganz, sofern auch
|
|
* keine Children (z.B. TopbarButtons) übergeben und kein filter
|
|
* gesetzt ist. */
|
|
showExpandButtons?: boolean
|
|
}
|
|
|
|
export const Treetable: m.ClosureComponent<iTreetable> = (vnode) => {
|
|
let onRowMarked = vnode.attrs.onRowMarked === undefined ? (node: iTreenode) => {} : vnode.attrs.onRowMarked
|
|
let isSelected = vnode.attrs.isSelected
|
|
let expandtoggle: boolean = true
|
|
let searchvalue:string = ""
|
|
function setSearchvalue(v:string) {
|
|
searchvalue = v
|
|
}
|
|
return {
|
|
view: (vnode) => {
|
|
// Prüfen, ob mindestens ein Top-Level-Node Kinder hat.
|
|
// Wird benötigt, um bei kinderlosen Top-Level-Nodes den
|
|
// Platzhalter-Pfeil nur dann zu rendern, wenn es Geschwister
|
|
// mit Kindern (und damit Pfeil) gibt.
|
|
const topHasSiblingsWithChildren = vnode.attrs.treenodes.some(
|
|
(tn: iTreenode) => tn.hasChildren
|
|
)
|
|
// Topbar (Expand-Buttons, Children, Suchfeld) nur rendern, wenn
|
|
// tatsächlich Inhalt vorhanden ist — bei flachen Listen ohne
|
|
// Expand-Buttons, Children und filter bleibt sie komplett weg.
|
|
const showExpand = vnode.attrs.showExpandButtons !== false
|
|
// vnode.children kann ein Array oder ein Primitiv sein
|
|
// (Mithril-Typ ChildArrayOrPrimitive).
|
|
const hatChildren = Array.isArray(vnode.children)
|
|
? vnode.children.length > 0
|
|
: vnode.children != null
|
|
const hatTopbar = showExpand || hatChildren || vnode.attrs.filter != undefined
|
|
return m("div.w3-border-theme", {style: {height: '99%', overflow: 'hidden', 'border-style': 'solid', 'border-width': '1px'}},
|
|
hatTopbar
|
|
? m("div.w3-bar.w3-theme",
|
|
showExpand
|
|
? [
|
|
m(TopbarButton, {
|
|
onclick: () => {
|
|
expandtoggle = true; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
|
}},
|
|
m("i.material-icons", "unfold_more")
|
|
),
|
|
m(TopbarButton, {
|
|
onclick: () => {
|
|
expandtoggle = false; changeTreenodes(vnode.attrs.treenodes, expandtoggle)
|
|
}},
|
|
m("i.material-icons", "unfold_less")
|
|
)
|
|
]
|
|
: null,
|
|
vnode.children,
|
|
(vnode.attrs.filter != undefined) ? m(SearchField, {setSearchvalue: setSearchvalue} as iSearchfield) : null
|
|
)
|
|
: null,
|
|
m("div", {style: {height: '100%'}},
|
|
// Ohne Topbar nutzt der scrollbare Bereich die volle Höhe.
|
|
// Wichtig für den Sticky-Header: Dieser div muss selbst der
|
|
// Scroll-Container sein (feste Höhe + overflow), damit sich
|
|
// position:sticky der th-Zellen auf ihn bezieht.
|
|
m("div", {style: {height: hatTopbar ? 'calc(100% - 32px)' : '100%', overflow: 'auto', width: '100%'}},
|
|
// overflow darf hier NICHT auf dem table-Element gesetzt
|
|
// werden — sonst wird die Tabelle selbst zum Scroll-Container
|
|
// und der Sticky-Header bricht.
|
|
m("table.w3-table",
|
|
// Optionale Spaltenüberschriften: Ein Titel je
|
|
// columnmapper-Spalte, 1:1 von links nach rechts
|
|
// (die Einrückung teilt sich das td mit der
|
|
// ersten Datenspalte, keine Führungs-Spalte).
|
|
// Die Kopfzeile bleibt beim Scrollen sticky.
|
|
vnode.attrs.header
|
|
? m("thead",
|
|
m("tr.w3-theme",
|
|
vnode.attrs.header.map((titel: string) =>
|
|
m("th.w3-theme", {
|
|
// Sticky-Header: Eigener Hintergrund
|
|
// nötig, da der tr-Hintergrund beim
|
|
// Scrollen mit den Daten wegwandert
|
|
// und sonst Zeilen durchscheinen.
|
|
style: {
|
|
position: 'sticky',
|
|
top: '0',
|
|
'z-index': '1',
|
|
'text-align': 'left',
|
|
'white-space': 'nowrap'
|
|
},
|
|
title: titel
|
|
}, titel))))
|
|
: null,
|
|
vnode.attrs.treenodes.map((tn:iTreenode, z) => {
|
|
if (vnode.attrs.filter == undefined || (vnode.attrs.filter != undefined && vnode.attrs.filter(tn, searchvalue) == true)) {
|
|
return m(Treerow, {
|
|
// ACHTUNG: Änderungen hier müssen unten beim rekursiven Aufruf der Treerow-Funktion ebenfalls eingebaut werden
|
|
treenode: tn,
|
|
level: 0,
|
|
columnmapper1: vnode.attrs.columnmapper,
|
|
onRowMarked: onRowMarked,
|
|
isSelected: isSelected,
|
|
isEven: ((z % 2) == 0),
|
|
hasSiblingsWithChildren: topHasSiblingsWithChildren,
|
|
filter: vnode.attrs.filter,
|
|
searchvalue: searchvalue} as iTreerow)
|
|
}
|
|
else {
|
|
return null
|
|
}
|
|
})
|
|
)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
interface iTreerow {
|
|
treenode: iTreenode
|
|
level: number
|
|
columnmapper1: Columnmapper
|
|
onRowMarked: (node: iTreenode) => void
|
|
isSelected?: (node: iTreenode) => boolean
|
|
isEven?: boolean
|
|
/** True, wenn mindestens ein Geschwister-Node auf demselben Level
|
|
* Kinder hat. Wird verwendet, um bei kinderlosen Top-Level-Nodes
|
|
* den Platzhalter-Pfeil nur dann zu rendern, wenn es Nodes mit
|
|
* Pfeil gibt, an denen der Text ausgerichtet werden kann. */
|
|
hasSiblingsWithChildren?: boolean
|
|
/** Optional: Filter-Funktion, die auch auf Kind-Knoten angewendet wird.
|
|
* Wenn gesetzt, werden Kind-Knoten nur gerendert, wenn der Filter
|
|
* für sie true zurückgibt. */
|
|
filter?: (row: iTreenode, searchvalue: string) => boolean
|
|
/** Aktueller Suchwert, der an den Filter weitergereicht wird. */
|
|
searchvalue?: string
|
|
}
|
|
let Treerow: m.ClosureComponent<iTreerow> = (vnode) => {
|
|
|
|
return {
|
|
view: (vnode) => {
|
|
// Wenn ein isSelected-Callback gesetzt ist, prüfen, ob diese
|
|
// Zeile selektiert ist; in diesem Fall wird der Hintergrund der
|
|
// gesamten <tr>-Zeile eingefärbt.
|
|
const rowSelected = vnode.attrs.isSelected ? vnode.attrs.isSelected(vnode.attrs.treenode) : false
|
|
// Prüfen, ob mindestens ein Child-Node selbst Kinder hat.
|
|
const childHasSiblingsWithChildren = vnode.attrs.treenode.children.some(
|
|
(tn: iTreenode) => tn.hasChildren
|
|
)
|
|
return [
|
|
m("tr.w3-border-theme", {onmouseup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode),
|
|
onkeyup: () => vnode.attrs.onRowMarked(vnode.attrs.treenode),
|
|
class: (vnode.attrs.isEven ? "w3-tablerow-even" : "w3-tablerow-odd")
|
|
+ (rowSelected ? " w3-theme-l4" : ""),
|
|
style: {'border-top-style': 'solid', 'border-top-width': '1px'}
|
|
},
|
|
m("td", // Zunächst wird ein td-Element für die erste Spalte der Tabelle erzeugt, damit dort die
|
|
// Einrückung in Abhängigkeit vom evtl. Vorhandensein von Kind-Elementen erledigt wird
|
|
m("div", {style: {width: 'max-content', 'margin-left': `${(vnode.attrs.level * 25)}px`}} ,
|
|
vnode.attrs.treenode.hasChildren
|
|
// Wenn der Treenode Kinder hat, dann soll der "expand_more/expand_less"-Button
|
|
// angezeigt werden
|
|
? m("span.w3-button", {
|
|
onclick: () => {changeTreenode(vnode.attrs.treenode, !vnode.attrs.treenode.expand)},
|
|
style: {padding: '0px',
|
|
'margin-right': '8px',
|
|
width: '24px',
|
|
height: '24px'}},
|
|
m("i.material-icons", vnode.attrs.treenode.expand?"expand_more":"navigate_next")
|
|
)
|
|
// Wenn der Trennode keine Kinder hat, dann soll kein "expand_more/expand_less"-Button
|
|
// angezeigt werden, allerdings soll durch 'margin-left' der entsprechende Platz des
|
|
// Buttons eingerückt werden, damit die Einrückungs-Level auch bei "kinderlosen" Treenodes
|
|
// denen der Trennodes mit Kindern entsprechen
|
|
: vnode.attrs.level == 0
|
|
? (vnode.attrs.hasSiblingsWithChildren
|
|
? m("span", {
|
|
style: {'margin-left': '0px',
|
|
'margin-right': '32px'}
|
|
})
|
|
: null)
|
|
: m(
|
|
"span", {
|
|
style: {'margin-left': '24px',
|
|
'margin-right': '8px'}
|
|
}
|
|
),
|
|
// Nachdem die einrückung auf Grund der "hasChildren"-Abfrage erledigt ist, wird der Inhalt
|
|
// der ersten Spalte des Column-Mappers angezeigt
|
|
vnode.attrs.columnmapper1(vnode.attrs.treenode).splice(0,1)
|
|
)
|
|
),
|
|
// Dann werden die übrigen Spalten ganz normal als td-Elemente eingefügt
|
|
vnode.attrs.columnmapper1(vnode.attrs.treenode).splice(1).map((column) => {
|
|
return m("td", column)
|
|
})
|
|
),
|
|
// Falls der Trennode Kinder hat und expandiert werden soll, wird rekursiv die Trennode-Komponente für
|
|
// die Child-Nodes aufgerufen, ansonsten wir null zurückgegeben und die Einrückung hört auf
|
|
vnode.attrs.treenode.hasChildren && vnode.attrs.treenode.expand
|
|
? vnode.attrs.treenode.children
|
|
.filter(tn => !vnode.attrs.filter || vnode.attrs.filter(tn, vnode.attrs.searchvalue || ""))
|
|
.map((tn: iTreenode, z) => {
|
|
return m(Treerow, {
|
|
treenode: tn,
|
|
level: vnode.attrs.level + 1,
|
|
columnmapper1: vnode.attrs.columnmapper1,
|
|
onRowMarked: vnode.attrs.onRowMarked,
|
|
isSelected: vnode.attrs.isSelected,
|
|
isEven: ((z % 2) == 0),
|
|
hasSiblingsWithChildren: childHasSiblingsWithChildren,
|
|
filter: vnode.attrs.filter,
|
|
searchvalue: vnode.attrs.searchvalue
|
|
} as iTreerow)
|
|
})
|
|
: null
|
|
]
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface iTopbarButton {
|
|
onclick: Function
|
|
tooltip?: string
|
|
}
|
|
export const TopbarButton: m.ClosureComponent<iTopbarButton> = () => {
|
|
return {
|
|
view: (vnode) => {
|
|
return m("button.w3-button.w3-bar-item", {
|
|
style: {'min-width': '32px', height: '32px', padding: '4px'},
|
|
onclick: vnode.attrs.onclick,
|
|
title: vnode.attrs.tooltip?vnode.attrs.tooltip:null
|
|
},
|
|
vnode.children
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Die Funktion kann aufgerufen werden, wenn eine Treenode-Struktur hinsichtlich
|
|
* "Expand more/less" geändert werden soll.
|
|
*/
|
|
export function changeTreenodes(treenodes: Treenode[], expand: boolean) {
|
|
treenodes.forEach((treenode: Treenode) => {
|
|
changeTreenode(treenode, expand)
|
|
if (treenode.hasChildren) {
|
|
changeTreenodes(treenode.children, expand)
|
|
}
|
|
})
|
|
}
|
|
function changeTreenode(treenode: Treenode, expand: boolean) {
|
|
treenode.expand = expand
|
|
}
|
|
|
|
interface iSearchfield {
|
|
setSearchvalue: (v:string) => void
|
|
}
|
|
const SearchField:m.ClosureComponent<iSearchfield> = (vnode) => {
|
|
let inputfield: HTMLInputElement|null = null
|
|
function oninput(e:Event) {
|
|
if (inputfield) { // ✅ Null-Check
|
|
let v:string = inputfield.value
|
|
vnode.attrs.setSearchvalue(v)
|
|
}
|
|
}
|
|
return {
|
|
view: (vnode) => {
|
|
return m("div", {style: {display: "flex", border: "1px solid black", margin: '1px'}},
|
|
m("input.ggg-inputfocus", {
|
|
oncreate: (vnode) => {
|
|
inputfield = vnode.dom as HTMLInputElement
|
|
inputfield.focus()
|
|
},
|
|
type:"text",
|
|
style: {flex: '1', border: 'none'}, oninput: oninput
|
|
}),
|
|
m("button.w3-button", {
|
|
style: {'min-width': '28px', height: '28px', padding: '4px', border: 'none'},
|
|
onclick: () => {
|
|
if (inputfield) { // ✅ Null-Check
|
|
inputfield.value = ""
|
|
vnode.attrs.setSearchvalue("")
|
|
inputfield.focus()
|
|
}
|
|
}},
|
|
m("i.material-icons", {style: {'font-size': 'medium'}}, "clear")
|
|
)
|
|
)
|
|
}
|
|
}
|
|
} |