import m from "mithril" /** * Option für das Select-Widget. */ export interface iSelectOption { /** Wert, der im Formular übermittelt wird (z.B. kundenId). */ value: any /** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */ label: string /** Optionaler fertiger Darstellungs-Content (z.B. eine Badge-Komponente), * der im Auswahl-Modal des MultiSelect statt Farbpunkt und Label * angezeigt wird. Das label bleibt davon unberührt — es ist weiterhin * die Basis für die Suche im Modal. */ content?: m.Child } /** * Der Vnode der Komponente 'Select' akzeptiert die folgenden Parameter: * @property {string} name - Der Name des Feldes (für FormData) * @property {any} value - Der aktuell gewählte Wert (value der Option) * @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen * @property {boolean} readonly (optional) - Auswahl gesperrt * @property {boolean} required (optional) - Pflichtfeld * @property {Function} onchanged (optional) - Callback bei Änderung */ export interface iSelect { name: string value: any options: iSelectOption[] readonly?: boolean required?: boolean onchanged?: Function } /** * Suchbares Select-Widget (Combobox). Ein sichtbares Texteingabefeld dient * gleichzeitig als Suchfeld: während der Eingabe wird die aufklappbare * Optionliste gefiltert — nur Optionen, deren label die eingegebene * Zeichenfolge (case-insensitiv, als Teilstring) enthält, bleiben sichtbar. * Wählt der Benutzer einen Eintrag, wird der interne Wert auf dessen * option.value gesetzt und das Eingabefeld zeigt option.label. * * Für die Formularübergabe wird zusätzlich ein verstecktes input-Element * gerendert, das den echten value trägt und über FormData ausgelesen werden * kann. So funktioniert die Suche unabhängig vom übergebenen Wert. */ export const Select: m.ClosureComponent = () => { // Interner Zustand: Suchtext, Dropdown geöffnet?, markierter Eintrag. let search: string = "" let isOpen: boolean = false let highlightIndex: number = -1 // Referenz auf das Eingabefeld für Fokus nach Clear. let inputfield: HTMLInputElement | null = null // Zuletzt im Suchtext dargestellter value. Dient der Synchronisation: // Der Anzeigetext wird beim Initialisieren und bei der Optionsauswahl // gesetzt; ändert sich der attrs.value von außen (z.B. Zurücksetzen // eines Filters im Eltern-View), wird der Text im view nachgezogen. let lastSyncedValue: any = undefined // True, solange der attrs.value noch keinem label zugeordnet werden // konnte — typischerweise weil die Optionen asynchron geladen werden // und beim oninit noch leer waren. Der view wiederholt die Synchro- // nisierung dann, bis eine passende Option existiert. let syncPending: boolean = false // Zeigt die Optionliste ungefiltert an, unabhängig vom Suchtext. // Wird vom Öffnen-Button gesetzt (neben dem Clear-Button) und beim // Tippen, bei der Auswahl und mit Escape zurückgesetzt. let showAll: boolean = false /** * Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext. */ const getFilteredOptions = (options: iSelectOption[]): iSelectOption[] => { const searchLower = search.toLowerCase() if (searchLower === "") { return options } return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower)) } /** * Setzt den Suchtext auf das label des übergebenen Werts und merkt * sich diesen als synchronisiert. Wird beim Initialisieren, bei der * Optionsauswahl und bei externen value-Änderungen aufgerufen. */ const syncSearchZumValue = (value: any, options: iSelectOption[]) => { const option = options.find((o: iSelectOption) => o.value === value) search = option ? option.label : "" lastSyncedValue = value // Konnte der value keinem label zugeordnet werden, bleibt die // Synchronisierung offen und wird im view wiederholt, sobald // die Optionen (asynchron) nachgeladen wurden. syncPending = !option } /** * Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu. */ const selectOption = (option: iSelectOption, vnode: m.Vnode) => { vnode.attrs.value = option.value syncSearchZumValue(option.value, vnode.attrs.options) isOpen = false showAll = false highlightIndex = -1 if (vnode.attrs.onchanged != undefined) { vnode.attrs.onchanged(option.value) } } /** * Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown * geöffnet wird. */ const openDropdown = (options: iSelectOption[], isReadonly: boolean) => { if (!isReadonly) { isOpen = true highlightIndex = getFilteredOptions(options).length > 0 ? 0 : -1 } } return { oninit: (vnode) => { // Aktuelles label zum value ermitteln (für die Anzeige im Feld). syncSearchZumValue(vnode.attrs.value, vnode.attrs.options) }, view: (vnode) => { // Extern geänderte value nachziehen: Der Suchtext wird nur beim // Initialisieren und bei der Optionsauswahl gesetzt. Ändert der // Eltern-View den attrs.value (z.B. Zurücksetzen eines Filters), // wird der angezeigte Text hier auf das neue label synchronisiert. if (vnode.attrs.value !== lastSyncedValue || syncPending) { syncSearchZumValue(vnode.attrs.value, vnode.attrs.options) } const isReadonly = vnode.attrs.readonly === true const filteredOptions = getFilteredOptions(vnode.attrs.options) // Angezeigt wird die ungefilterte Liste, wenn der Öffnen-Button // genutzt wurde, sonst die vom Suchtext gefilterte. const angezeigteOptionen = showAll ? vnode.attrs.options : filteredOptions const inputAttrs: any = { type: "text", value: search, style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'}, oncreate: (vnode: m.VnodeDOM) => { inputfield = vnode.dom as HTMLInputElement }, oninput: (e: Event) => { if (isReadonly) { return } search = (e.target as HTMLInputElement).value // Der Nutzer tippt selbst — eine offene Synchronisierung // (asynchron nachgeladene Optionen) nicht mehr nachziehen. syncPending = false // Tippen sucht wieder im Suchtext-Basis. showAll = false isOpen = true highlightIndex = filteredOptions.length > 0 ? 0 : -1 }, onfocus: (e: Event) => { openDropdown(vnode.attrs.options, isReadonly) }, onblur: (e: Event) => { // Verzögert schließen, damit ein Klick auf einen Eintrag // vorher registriert wird. setTimeout(() => { isOpen = false }, 150) }, onkeydown: (e: KeyboardEvent) => { if (isReadonly) { return } if (angezeigteOptionen.length === 0) { return } if (e.key === "ArrowDown") { e.preventDefault() if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length } else if (e.key === "ArrowUp") { e.preventDefault() if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } highlightIndex = (highlightIndex - 1 + angezeigteOptionen.length) % angezeigteOptionen.length } else if (e.key === "Enter") { if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) { e.preventDefault() selectOption(angezeigteOptionen[highlightIndex], vnode) } } else if (e.key === "Escape") { isOpen = false showAll = false highlightIndex = -1 } } } if (isReadonly) { inputAttrs.readonly = "true" } if (vnode.attrs.required === true) { inputAttrs.required = true } return m("div", {style: {width: '100%', position: 'relative'}}, // Verstecktes Feld für die Formularübergabe (echter value). m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}), // Sichtbares Such-/Anzeigefeld mit Öffnen- und Clear-Button darin. m("div", {style: {position: 'relative', width: '100%'}}, m("input.w3-input.w3-border", inputAttrs), !isReadonly ? [ // Öffnen-Button: blendet die Optionliste ungefiltert // ein, ohne das Feld zu leeren — der aktuelle Wert // bleibt stehen, bis eine neue Option gewählt wird. m("button.w3-button", { type: "button", title: "Liste öffnen (alle Optionen anzeigen)", style: { position: 'absolute', right: '28px', top: '50%', transform: 'translateY(-50%)', 'min-width': '24px', height: '24px', padding: '2px', 'line-height': '1' }, // mousedown mit preventDefault: hält den Fokus // auf dem Eingabefeld (kein Blur-Rennen mit dem // verzögerten Schließen) und öffnet/schließt // die ungefilterte Liste. Klick bei offener // Gesamtliste schließt sie wieder (Toggle). onmousedown: (e: Event) => { e.preventDefault() if (isOpen && showAll) { isOpen = false showAll = false highlightIndex = -1 } else { showAll = true isOpen = true highlightIndex = 0 } } }, m("i.material-icons", {style: {'font-size': '20px'}}, "arrow_drop_down")), // Clear-Button: leert das Suchfeld. // Absolut positioniert, sodass der Button optisch im // Eingabefeld liegt und vertikal zentriert ist. m("button.w3-button", { type: "button", style: { position: 'absolute', right: '4px', top: '50%', transform: 'translateY(-50%)', 'min-width': '24px', height: '24px', padding: '2px', 'line-height': '1' }, onclick: () => { search = "" isOpen = true highlightIndex = filteredOptions.length > 0 ? 0 : -1 if (inputfield) { inputfield.focus() } } }, m("i.material-icons", {style: {'font-size': '20px'}}, "clear")) ] : null ), // Gefilterte, aufklappbare Optionliste. Eigene Positionierung, // da die w3-dropdown-content-Klasse das Element per CSS // ausblendet (display:none) und nur bei :hover anzeigt. isOpen && !isReadonly ? m("div.w3-bar-block.w3-border.w3-theme-l4", { style: { width: '95%', 'max-height': '220px', 'overflow-y': 'auto', position: 'absolute', 'z-index': '1000', 'box-shadow': '2px 2px 4px darkgrey' } }, angezeigteOptionen.length === 0 ? m("div.w3-padding-small", "Keine Treffer") : angezeigteOptionen.map((option: iSelectOption, index: number) => m("a.w3-bar-item.w3-button.w3-text-left", { style: {whiteSpace: 'normal'}, class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey", // mousedown statt click: verhindert, dass das // Eingabefeld den Fokus verliert, und schließt // das Dropdown zuverlässig nach der Auswahl. onmousedown: (e: Event) => { e.preventDefault() selectOption(option, vnode) } }, option.label) ) ) : null ) } } } /** * Der Vnode der Komponente 'MultiSelect' akzeptiert die folgenden Parameter: * @property {string} name - Der Name des Feldes (für FormData; pro gewähltem * Wert wird ein hidden input dieses Namens erzeugt) * @property {any[]} selected - Die aktuell gewählten Werte (values der Optionen) * @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen * @property {boolean} readonly (optional) - Auswahl gesperrt * @property {Map} colors (optional) - Wert -> Hexfarbe für * Farbpunkte/Chips (z.B. Tag-Farben) * @property {string} placeholder (optional) - Platzhalter-Badge-Text bei * leerer Auswahl (Default: "kein Wert") * @property {string} modalTitle (optional) - Titel des Auswahl-Modals * (Default: "Auswahl") * @property {string} editTooltip (optional) - Tooltip des Edit-Buttons * (Default: "Bearbeiten") * @property {Function} onchanged (optional) - Callback bei Änderung der Auswahl */ export interface iMultiSelect { name: string selected: any[] options: iSelectOption[] readonly?: boolean colors?: Map placeholder?: string modalTitle?: string editTooltip?: string onchanged?: Function } /** * Mehrfachauswahl-Widget mit Modal-Dialog. Im Formular werden nur die * ausgewählten Werte als farbige Badges (bzw. ein grauer Platzhalter bei * leerer Auswahl) zusammen mit einem Edit-Button angezeigt. Beim Klick * auf den Edit-Button öffnet sich ein Modal mit einem Suchfeld, der * durchsuchbaren Checkbox-Liste aller Optionen und einem OK-Button zum * Schließen. * * Das Widget ist generisch und nicht auf Tags beschränkt — es arbeitet * mit beliebigen iSelectOption-Listen. Tag-spezifische Texte (Platzhalter, * Modal-Titel, Tooltip) sind über die entsprechenden Attribute * konfigurierbar. * * Für die Formularübergabe wird pro gewähltem Wert ein verstecktes * input-Element erzeugt, sodass FormData.getAll(name) alle IDs liefert. */ export const MultiSelect: m.ClosureComponent = () => { // Suchtext und Modal-Sichtbarkeit. let search: string = "" let isModalOpen: boolean = false // Lokale Arbeitskopie der Auswahl während das Modal geöffnet ist. let draft: any[] = [] const getFilteredOptions = (options: iSelectOption[], searchText: string): iSelectOption[] => { const searchLower = searchText.toLowerCase() if (searchLower === "") { return options } return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower)) } return { oninit: (vnode) => { search = "" isModalOpen = false draft = [] }, view: (vnode) => { const isReadonly = vnode.attrs.readonly === true const colors = vnode.attrs.colors const placeholder = vnode.attrs.placeholder || "kein Wert" const modalTitle = vnode.attrs.modalTitle || "Auswahl" const editTooltip = vnode.attrs.editTooltip || "Bearbeiten" const selected: any[] = Array.isArray(vnode.attrs.selected) ? vnode.attrs.selected : [] const filteredOptions = getFilteredOptions(vnode.attrs.options, search) const isDraftSelected = (value: any) => draft.includes(value) const toggleDraft = (option: iSelectOption) => { if (isDraftSelected(option.value)) { const idx = draft.indexOf(option.value) if (idx >= 0) { draft.splice(idx, 1) } } else { draft.push(option.value) } } const removeChip = (value: any, e: Event) => { e.stopPropagation() e.preventDefault() const idx = vnode.attrs.selected.indexOf(value) if (idx >= 0) { vnode.attrs.selected.splice(idx, 1) } if (vnode.attrs.onchanged != undefined) { vnode.attrs.onchanged(vnode.attrs.selected.slice()) } } const openEditModal = (e: Event) => { e.preventDefault() // draft als Kopie der aktuellen Auswahl initialisieren. draft = selected.slice() search = "" isModalOpen = true } const closeModal = () => { // Auswahl aus draft in selected übernehmen. vnode.attrs.selected.splice(0, vnode.attrs.selected.length) Array.prototype.push.apply(vnode.attrs.selected, draft) if (vnode.attrs.onchanged != undefined) { vnode.attrs.onchanged(vnode.attrs.selected.slice()) } isModalOpen = false search = "" } // Badges für ausgewählte Werte. Bei leerer Auswahl wird ein // graues Platzhalter-Badge "kein Tag" angezeigt. const badges = m("div", { style: { display: 'flex', flexWrap: 'wrap', gap: '4px', 'margin-bottom': '5px', 'min-height': '20px', alignItems: 'center' } }, selected.length > 0 ? selected.map((value: any) => { const opt = vnode.attrs.options.find((o: iSelectOption) => o.value === value) const label = opt ? opt.label : String(value) const color = colors ? colors.get(value) : undefined return m("span.w3-tag.w3-small", { style: { backgroundColor: color || '#999', color: 'white', borderRadius: '3px', padding: '2px 4px', display: 'inline-flex', alignItems: 'center', gap: '2px', cursor: isReadonly ? 'default' : 'pointer' } }, m("span", label), !isReadonly ? m("button.w3-button", { style: { 'min-width': '16px', height: '16px', padding: '0', 'line-height': '1', color: 'white', background: 'transparent' }, onclick: (e: Event) => removeChip(value, e) }, "×") : null ) }) : m("span.w3-tag.w3-small", { style: { backgroundColor: '#bbb', color: 'white', borderRadius: '3px', padding: '2px 4px', display: 'inline-flex', alignItems: 'center' } }, placeholder) ) return m("div", {style: {width: '100%', position: 'relative'}}, // Versteckte Felder für die Formularübergabe (ein pro gewähltem Wert). selected.map((value: any) => m("input", {type: "hidden", name: vnode.attrs.name, value: value}) ), m("div", {style: {display: 'flex', alignItems: 'center', gap: '8px'}}, badges, !isReadonly ? m("button.w3-button.w3-small", { title: editTooltip, style: { 'min-width': '32px', height: '32px', padding: '0 4px', 'line-height': '1' }, onclick: openEditModal }, m("i.material-icons", {style: {'font-size': '20px'}}, "edit")) : null ), // Inline gerendertes Modal für die Tag-Auswahl. isModalOpen ? m("div.w3-modal", {style: {display: 'block'}}, m("div.w3-modal-content.w3-card-4", {style: {width: '500px', top: '10%'}}, m("header.w3-container.w3-theme", m("h2", modalTitle) ), m("div.w3-container.w3-theme-l1", {style: {padding: '16px'}}, // Suchfeld. Der oncreate-Hook fokussiert das // Feld direkt beim Öffnen des Modals — das // Input wird ja erst mit dem Modal erzeugt, // sodass der Nutzer sofort tippen kann. m("input.w3-input.w3-border", { type: "text", value: search, placeholder: "Suchbegriff …", style: {'margin-bottom': '10px', width: '100%'}, oncreate: (vnode: m.VnodeDOM) => { (vnode.dom as HTMLInputElement).focus() }, oninput: (e: Event) => { search = (e.target as HTMLInputElement).value } }), // Checkbox-Liste der (gefilterten) Tags. m("div.w3-bar-block.w3-border", { style: { 'max-height': '300px', 'overflow-y': 'auto', 'margin-bottom': '10px' } }, filteredOptions.length === 0 ? m("div.w3-padding-small", "Keine Treffer") : filteredOptions.map((option: iSelectOption) => { const color = colors ? colors.get(option.value) : undefined return m("label.w3-bar-item.w3-button.w3-text-left", { style: {whiteSpace: 'normal', display: 'flex', alignItems: 'center', gap: '6px'} }, m("input.w3-check", { type: "checkbox", checked: isDraftSelected(option.value), style: {'margin-right': '4px'}, onclick: (e: Event) => { if (isReadonly) { return } e.preventDefault() toggleDraft(option) } }), // Ist ein fertiger content gesetzt (z.B. eine // Badge-Komponente des Aufrufers), wird dieser // statt Farbpunkt und Label angezeigt — das // Widget selbst bleibt frei von Tag-Wissen. option.content ? m("span", { style: { display: 'inline-flex', alignItems: 'center', flex: '1 1 auto' } }, option.content) : [ color ? m("span", { style: { display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%', backgroundColor: color, flex: '0 0 auto' } }) : null, m("span", option.label) ] ) }) ), // OK-Button schließt das Modal. m("div.w3-bar", m("button.w3-button.w3-theme.w3-right", {type: "button", onclick: closeModal}, "OK" ) ) ) ) ) : null ) } } }