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 ) } } }