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 = (vnode) => { // Interner Zustand: Suchtext, Dropdown geöffnet?, markierter Eintrag. let search: string = "" let isOpen: boolean = false let highlightIndex: number = -1 // Aktuelles label zum value ermitteln (für die Anzeige im Eingabefeld). const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value) search = initialOption ? initialOption.label : "" const isReadonly = vnode.attrs.readonly === true /** * Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext. */ const getFilteredOptions = (): iSelectOption[] => { const searchLower = search.toLowerCase() if (searchLower === "") { return vnode.attrs.options } return vnode.attrs.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.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 = () => { if (!isReadonly) { isOpen = true highlightIndex = getFilteredOptions().length > 0 ? 0 : -1 } } return { view: (vnode) => { const filteredOptions = getFilteredOptions() const inputAttrs: any = { type: "text", value: search, style: {'margin-bottom': '5px', 'width': '100%'}, oninput: (e: Event) => { if (isReadonly) { return } search = (e.target as HTMLInputElement).value isOpen = true highlightIndex = filteredOptions.length > 0 ? 0 : -1 }, onfocus: (e: Event) => { openDropdown() }, 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(); return } highlightIndex = (highlightIndex + 1) % filteredOptions.length } else if (e.key === "ArrowUp") { e.preventDefault() if (!isOpen) { openDropdown(); 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]) } } 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. m("input.w3-input.w3-border", inputAttrs), // 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", onclick: () => selectOption(option) }, option.label) ) ) : null ) } } }