From ba6c0c50d15e0d6b212703a1a794dae0d60de94b Mon Sep 17 00:00:00 2001 From: Gerd Date: Mon, 31 Aug 2026 18:23:55 +0200 Subject: [PATCH] Select-Widget verfeinert: ClosureComponent-Muster, Tastatur-Navigation, Clear-Button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select auf sauberes ClosureComponent-Muster umgestellt (Closure-Parameter weggelassen, attrs nur über Hook-vnode gelesen). Auswahl per Maus über mousedown umgestellt, damit das Dropdown zuverlässig geschlossen wird. Zusätzlich Clear-Button (Material-Icon "clear") im Eingabefeld, absolut positioniert und vertikal zentriert, leert das Suchfeld. readonly-Modus unterstützt. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- Client/Components/widgets/select.ts | 80 +++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 22 deletions(-) diff --git a/Client/Components/widgets/select.ts b/Client/Components/widgets/select.ts index cb895da..f6c6aac 100644 --- a/Client/Components/widgets/select.ts +++ b/Client/Components/widgets/select.ts @@ -40,33 +40,29 @@ export interface iSelect { * 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) => { +export const Select: m.ClosureComponent = () => { // 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 + // 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 = (): iSelectOption[] => { + const getFilteredOptions = (options: iSelectOption[]): iSelectOption[] => { const searchLower = search.toLowerCase() if (searchLower === "") { - return vnode.attrs.options + return options } - return vnode.attrs.options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower)) + 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) => { + const selectOption = (option: iSelectOption, vnode: m.Vnode) => { vnode.attrs.value = option.value search = option.label isOpen = false @@ -80,20 +76,29 @@ export const Select: m.ClosureComponent = (vnode) => { * Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown * geöffnet wird. */ - const openDropdown = () => { + const openDropdown = (options: iSelectOption[], isReadonly: boolean) => { if (!isReadonly) { isOpen = true - highlightIndex = getFilteredOptions().length > 0 ? 0 : -1 + 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 filteredOptions = getFilteredOptions() + 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%'}, + 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 @@ -101,7 +106,7 @@ export const Select: m.ClosureComponent = (vnode) => { highlightIndex = filteredOptions.length > 0 ? 0 : -1 }, onfocus: (e: Event) => { - openDropdown() + openDropdown(vnode.attrs.options, isReadonly) }, onblur: (e: Event) => { // Verzögert schließen, damit ein Klick auf einen Eintrag @@ -113,16 +118,16 @@ export const Select: m.ClosureComponent = (vnode) => { if (filteredOptions.length === 0) { return } if (e.key === "ArrowDown") { e.preventDefault() - if (!isOpen) { openDropdown(); return } + if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } highlightIndex = (highlightIndex + 1) % filteredOptions.length } else if (e.key === "ArrowUp") { e.preventDefault() - if (!isOpen) { openDropdown(); return } + 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]) + selectOption(filteredOptions[highlightIndex], vnode) } } else if (e.key === "Escape") { isOpen = false @@ -140,8 +145,33 @@ export const Select: m.ClosureComponent = (vnode) => { 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), + // 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. @@ -162,7 +192,13 @@ export const Select: m.ClosureComponent = (vnode) => { 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) + // 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) ) )