diff --git a/Client/Components/widgets/select.ts b/Client/Components/widgets/select.ts new file mode 100644 index 0000000..cb895da --- /dev/null +++ b/Client/Components/widgets/select.ts @@ -0,0 +1,173 @@ +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 + ) + } + } +}