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 } /** * 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 /** * 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)) } /** * Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu. */ const selectOption = (option: iSelectOption, vnode: m.Vnode) => { vnode.attrs.value = option.value search = option.label isOpen = 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). const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value) search = initialOption ? initialOption.label : "" }, view: (vnode) => { const isReadonly = vnode.attrs.readonly === true const filteredOptions = getFilteredOptions(vnode.attrs.options) const inputAttrs: any = { type: "text", value: search, style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'}, oncreate: (vnode: m.VnodeDOM) => { inputfield = vnode.dom as HTMLInputElement }, oninput: (e: Event) => { if (isReadonly) { return } search = (e.target as HTMLInputElement).value 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 (filteredOptions.length === 0) { return } if (e.key === "ArrowDown") { e.preventDefault() if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } highlightIndex = (highlightIndex + 1) % filteredOptions.length } else if (e.key === "ArrowUp") { e.preventDefault() if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length } else if (e.key === "Enter") { if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) { e.preventDefault() selectOption(filteredOptions[highlightIndex], vnode) } } else if (e.key === "Escape") { isOpen = 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 Clear-Button darin. m("div", {style: {position: 'relative', width: '100%'}}, m("input.w3-input.w3-border", inputAttrs), // Clear-Button: leert das Suchfeld (nur wenn nicht readonly). // Absolut positioniert, sodass der Button optisch im // Eingabefeld liegt und vertikal zentriert ist. !isReadonly ? m("button.w3-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' } }, filteredOptions.length === 0 ? m("div.w3-padding-small", "Keine Treffer") : filteredOptions.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. m("input.w3-input.w3-border", { type: "text", value: search, placeholder: "Suchbegriff …", style: {'margin-bottom': '10px', width: '100%'}, 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) } }), 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 ) } } }