feat(select): Öffnen-Button für ungefilterte Liste und Sync externer value-Änderungen

Der neue Öffnen-Button blendet die Optionsliste ungefiltert ein, ohne das
Feld zu leeren. Der Anzeigetext wird zudem im view nachgezogen, wenn der
Eltern-View den value ändert (z.B. Zurücksetzen eines Filters).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
gg committed 2026-09-24 18:53:44 +02:00
1 parent 977b64292c
commit 3a6732e36b
1 file changed
+103 -33
+103 -33
View File
@@ -47,6 +47,15 @@ export const Select: m.ClosureComponent<iSelect> = () => {
let highlightIndex: number = -1 let highlightIndex: number = -1
// Referenz auf das Eingabefeld für Fokus nach Clear. // Referenz auf das Eingabefeld für Fokus nach Clear.
let inputfield: HTMLInputElement | null = null 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. * Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
@@ -59,13 +68,25 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower)) 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. * Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
*/ */
const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => { const selectOption = (option: iSelectOption, vnode: m.Vnode<iSelect>) => {
vnode.attrs.value = option.value vnode.attrs.value = option.value
search = option.label syncSearchZumValue(option.value, vnode.attrs.options)
isOpen = false isOpen = false
showAll = false
highlightIndex = -1 highlightIndex = -1
if (vnode.attrs.onchanged != undefined) { if (vnode.attrs.onchanged != undefined) {
vnode.attrs.onchanged(option.value) vnode.attrs.onchanged(option.value)
@@ -86,22 +107,33 @@ export const Select: m.ClosureComponent<iSelect> = () => {
return { return {
oninit: (vnode) => { oninit: (vnode) => {
// Aktuelles label zum value ermitteln (für die Anzeige im Feld). // Aktuelles label zum value ermitteln (für die Anzeige im Feld).
const initialOption = vnode.attrs.options.find((o: iSelectOption) => o.value === vnode.attrs.value) syncSearchZumValue(vnode.attrs.value, vnode.attrs.options)
search = initialOption ? initialOption.label : ""
}, },
view: (vnode) => { 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 isReadonly = vnode.attrs.readonly === true
const filteredOptions = getFilteredOptions(vnode.attrs.options) 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 = { const inputAttrs: any = {
type: "text", type: "text",
value: search, value: search,
style: {'margin-bottom': '5px', width: '100%', 'padding-right': '32px'}, style: {'margin-bottom': '5px', width: '100%', 'padding-right': '58px'},
oncreate: (vnode: m.VnodeDOM) => { oncreate: (vnode: m.VnodeDOM) => {
inputfield = vnode.dom as HTMLInputElement 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
// Tippen sucht wieder im Suchtext-Basis.
showAll = false
isOpen = true isOpen = true
highlightIndex = filteredOptions.length > 0 ? 0 : -1 highlightIndex = filteredOptions.length > 0 ? 0 : -1
}, },
@@ -115,22 +147,23 @@ export const Select: m.ClosureComponent<iSelect> = () => {
}, },
onkeydown: (e: KeyboardEvent) => { onkeydown: (e: KeyboardEvent) => {
if (isReadonly) { return } if (isReadonly) { return }
if (filteredOptions.length === 0) { return } if (angezeigteOptionen.length === 0) { return }
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault() e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return }
highlightIndex = (highlightIndex + 1) % filteredOptions.length highlightIndex = (highlightIndex + 1) % angezeigteOptionen.length
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault() e.preventDefault()
if (!isOpen) { openDropdown(vnode.attrs.options, isReadonly); return } 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") { } else if (e.key === "Enter") {
if (isOpen && highlightIndex >= 0 && highlightIndex < filteredOptions.length) { if (isOpen && highlightIndex >= 0 && highlightIndex < angezeigteOptionen.length) {
e.preventDefault() e.preventDefault()
selectOption(filteredOptions[highlightIndex], vnode) selectOption(angezeigteOptionen[highlightIndex], vnode)
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
isOpen = false isOpen = false
showAll = false
highlightIndex = -1 highlightIndex = -1
} }
} }
@@ -145,31 +178,68 @@ export const Select: m.ClosureComponent<iSelect> = () => {
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 mit Clear-Button darin. // Sichtbares Such-/Anzeigefeld mit Öffnen- und Clear-Button darin.
m("div", {style: {position: 'relative', width: '100%'}}, m("div", {style: {position: 'relative', width: '100%'}},
m("input.w3-input.w3-border", inputAttrs), 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 !isReadonly
? m("button.w3-button", { ? [
style: { // Öffnen-Button: blendet die Optionliste ungefiltert
position: 'absolute', // ein, ohne das Feld zu leeren — der aktuelle Wert
right: '4px', // bleibt stehen, bis eine neue Option gewählt wird.
top: '50%', m("button.w3-button", {
transform: 'translateY(-50%)', type: "button",
'min-width': '24px', title: "Liste öffnen (alle Optionen anzeigen)",
height: '24px', style: {
padding: '2px', position: 'absolute',
'line-height': '1' right: '28px',
}, top: '50%',
onclick: () => { transform: 'translateY(-50%)',
search = "" 'min-width': '24px',
isOpen = true height: '24px',
highlightIndex = filteredOptions.length > 0 ? 0 : -1 padding: '2px',
if (inputfield) { inputfield.focus() } 'line-height': '1'
} },
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear")) // 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 : null
), ),
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung, // Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
@@ -186,9 +256,9 @@ export const Select: m.ClosureComponent<iSelect> = () => {
'box-shadow': '2px 2px 4px darkgrey' 'box-shadow': '2px 2px 4px darkgrey'
} }
}, },
filteredOptions.length === 0 angezeigteOptionen.length === 0
? m("div.w3-padding-small", "Keine Treffer") ? 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", { 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",