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:
1 parent
977b64292c
commit
3a6732e36b
1 file changed
+85
-15
@@ -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,14 +178,50 @@ 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).
|
!isReadonly
|
||||||
|
? [
|
||||||
|
// Ö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
|
// Absolut positioniert, sodass der Button optisch im
|
||||||
// Eingabefeld liegt und vertikal zentriert ist.
|
// Eingabefeld liegt und vertikal zentriert ist.
|
||||||
!isReadonly
|
m("button.w3-button", {
|
||||||
? m("button.w3-button", {
|
type: "button",
|
||||||
style: {
|
style: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: '4px',
|
right: '4px',
|
||||||
@@ -170,6 +239,7 @@ export const Select: m.ClosureComponent<iSelect> = () => {
|
|||||||
if (inputfield) { inputfield.focus() }
|
if (inputfield) { inputfield.focus() }
|
||||||
}
|
}
|
||||||
}, m("i.material-icons", {style: {'font-size': '20px'}}, "clear"))
|
}, 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",
|
||||||
|
|||||||
Reference in new issue
Block a user