Select-Widget verfeinert: ClosureComponent-Muster, Tastatur-Navigation, Clear-Button

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 <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-08-31 18:23:55 +02:00
1 parent de7e6948f8
commit ba6c0c50d1
1 file changed
+58 -22
+58 -22
View File
@@ -40,33 +40,29 @@ export interface iSelect {
* gerendert, das den echten value trägt und über FormData ausgelesen werden * gerendert, das den echten value trägt und über FormData ausgelesen werden
* kann. So funktioniert die Suche unabhängig vom übergebenen Wert. * kann. So funktioniert die Suche unabhängig vom übergebenen Wert.
*/ */
export const Select: m.ClosureComponent<iSelect> = (vnode) => { export const Select: m.ClosureComponent<iSelect> = () => {
// Interner Zustand: Suchtext, Dropdown geöffnet?, markierter Eintrag. // Interner Zustand: Suchtext, Dropdown geöffnet?, markierter Eintrag.
let search: string = "" let search: string = ""
let isOpen: boolean = false let isOpen: boolean = false
let highlightIndex: number = -1 let highlightIndex: number = -1
// Referenz auf das Eingabefeld für Fokus nach Clear.
// Aktuelles label zum value ermitteln (für die Anzeige im Eingabefeld). let inputfield: HTMLInputElement | null = null
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. * Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
*/ */
const getFilteredOptions = (): iSelectOption[] => { const getFilteredOptions = (options: iSelectOption[]): iSelectOption[] => {
const searchLower = search.toLowerCase() const searchLower = search.toLowerCase()
if (searchLower === "") { 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. * Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
*/ */
const selectOption = (option: iSelectOption) => { const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
vnode.attrs.value = option.value vnode.attrs.value = option.value
search = option.label search = option.label
isOpen = false isOpen = false
@@ -80,20 +76,29 @@ export const Select: m.ClosureComponent<iSelect> = (vnode) => {
* Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown * Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown
* geöffnet wird. * geöffnet wird.
*/ */
const openDropdown = () => { const openDropdown = (options: iSelectOption[], isReadonly: boolean) => {
if (!isReadonly) { if (!isReadonly) {
isOpen = true isOpen = true
highlightIndex = getFilteredOptions().length > 0 ? 0 : -1 highlightIndex = getFilteredOptions(options).length > 0 ? 0 : -1
} }
} }
return { 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) => { view: (vnode) => {
const filteredOptions = getFilteredOptions() const isReadonly = vnode.attrs.readonly === true
const filteredOptions = getFilteredOptions(vnode.attrs.options)
const inputAttrs: any = { const inputAttrs: any = {
type: "text", type: "text",
value: search, 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) => { oninput: (e: Event) => {
if (isReadonly) { return } if (isReadonly) { return }
search = (e.target as HTMLInputElement).value search = (e.target as HTMLInputElement).value
@@ -101,7 +106,7 @@ export const Select: m.ClosureComponent<iSelect> = (vnode) => {
highlightIndex = filteredOptions.length > 0 ? 0 : -1 highlightIndex = filteredOptions.length > 0 ? 0 : -1
}, },
onfocus: (e: Event) => { onfocus: (e: Event) => {
openDropdown() openDropdown(vnode.attrs.options, isReadonly)
}, },
onblur: (e: Event) => { onblur: (e: Event) => {
// Verzögert schließen, damit ein Klick auf einen Eintrag // Verzögert schließen, damit ein Klick auf einen Eintrag
@@ -113,16 +118,16 @@ export const Select: m.ClosureComponent<iSelect> = (vnode) => {
if (filteredOptions.length === 0) { return } if (filteredOptions.length === 0) { return }
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault() e.preventDefault()
if (!isOpen) { openDropdown(); return } if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex + 1) % filteredOptions.length highlightIndex = (highlightIndex + 1) % filteredOptions.length
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault() e.preventDefault()
if (!isOpen) { openDropdown(); return } if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length highlightIndex = (highlightIndex - 1 + filteredOptions.length) % filteredOptions.length
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) { if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) {
e.preventDefault() e.preventDefault()
selectOption(filteredOptions[highlightIndex]) selectOption(filteredOptions[highlightIndex], vnode)
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
isOpen = false isOpen = false
@@ -140,8 +145,33 @@ export const Select: m.ClosureComponent<iSelect> = (vnode) => {
return m("div", {style: {width: '100%', position: 'relative'}}, return m("div", {style: {width: '100%', position: 'relative'}},
// Verstecktes Feld für die Formularübergabe (echter value). // Verstecktes Feld für die Formularübergabe (echter value).
m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}), m("input", {type: "hidden", name: vnode.attrs.name, value: vnode.attrs.value}),
// Sichtbares Such-/Anzeigefeld. // Sichtbares Such-/Anzeigefeld mit Clear-Button darin.
m("input.w3-input.w3-border", inputAttrs), 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, // Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
// da die w3-dropdown-content-Klasse das Element per CSS // da die w3-dropdown-content-Klasse das Element per CSS
// ausblendet (display:none) und nur bei :hover anzeigt. // ausblendet (display:none) und nur bei :hover anzeigt.
@@ -162,7 +192,13 @@ export const Select: m.ClosureComponent<iSelect> = (vnode) => {
m("a.w3-bar-item.w3-button.w3-text-left", { m("a.w3-bar-item.w3-button.w3-text-left", {
style: {whiteSpace: 'normal'}, style: {whiteSpace: 'normal'},
class: index === highlightIndex ? "w3-theme" : "w3-hover-light-grey", 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) }, option.label)
) )
) )