diff --git a/Client/Components/widgets/select.ts b/Client/Components/widgets/select.ts index a63a3b0..247b6c5 100644 --- a/Client/Components/widgets/select.ts +++ b/Client/Components/widgets/select.ts @@ -47,6 +47,15 @@ export const Select: m.ClosureComponent = () => { 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 + // 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. @@ -59,13 +68,25 @@ export const Select: m.ClosureComponent = () => { 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 + } + /** * 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 + syncSearchZumValue(option.value, vnode.attrs.options) isOpen = false + showAll = false highlightIndex = -1 if (vnode.attrs.onchanged != undefined) { vnode.attrs.onchanged(option.value) @@ -86,22 +107,33 @@ export const Select: m.ClosureComponent = () => { 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 : "" + 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) { + 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': '32px'}, + 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 + // Tippen sucht wieder im Suchtext-Basis. + showAll = false isOpen = true highlightIndex = filteredOptions.length > 0 ? 0 : -1 }, @@ -115,22 +147,23 @@ export const Select: m.ClosureComponent = () => { }, onkeydown: (e: KeyboardEvent) => { if (isReadonly) { return } - if (filteredOptions.length === 0) { return } + if (angezeigteOptionen.length === 0) { return } if (e.key === "ArrowDown") { e.preventDefault() if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } - highlightIndex = (highlightIndex + 1) % filteredOptions.length + highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length } else if (e.key === "ArrowUp") { e.preventDefault() if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } - highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length + highlightIndex = (highlightIndex - 1 + angezeigteOptionen.length) % angezeigteOptionen.length } else if (e.key === "Enter") { - if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) { + if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) { e.preventDefault() - selectOption(filteredOptions[highlightIndex], vnode) + selectOption(angezeigteOptionen[highlightIndex], vnode) } } else if (e.key === "Escape") { isOpen = false + showAll = false highlightIndex = -1 } } @@ -145,31 +178,68 @@ export const Select: m.ClosureComponent = () => { 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. + // Sichtbares Such-/Anzeigefeld mit Öffnen- und 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")) + ? [ + // Ö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, @@ -186,9 +256,9 @@ export const Select: m.ClosureComponent = () => { 'box-shadow': '2px 2px 4px darkgrey' } }, - filteredOptions.length === 0 + angezeigteOptionen.length === 0 ? m("div.w3-padding-small", "Keine Treffer") - : filteredOptions.map((option: iSelectOption, index: number) => + : 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",