Neues Select-Widget hinzugefügt: suchbares Dropdown mit Tastatur-Navigation
Erzeugt ein Combobox-Widget (widgets/select.ts) mit Suchfeld, gefilterter Optionliste, Maus-Auswahl und Tastaturbedienung (Pfeil-auf/ab, Enter, Escape). Verstecktes Feld für die FormData-Übergabe, readonly-Modus für voreingestellte Werte. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
1 parent
385892ac15
commit
d4319de445
1 file changed
+173
@@ -0,0 +1,173 @@
|
|||||||
|
import m from "mithril"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Option für das Select-Widget.
|
||||||
|
*/
|
||||||
|
export interface iSelectOption {
|
||||||
|
/** Wert, der im Formular übermittelt wird (z.B. kundenId). */
|
||||||
|
value: any
|
||||||
|
/** Anzeige im Feld bzw. in der Dropdown-Liste (z.B. Kundenname). */
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Vnode der Komponente 'Select' akzeptiert die folgenden Parameter:
|
||||||
|
* @property {string} name - Der Name des Feldes (für FormData)
|
||||||
|
* @property {any} value - Der aktuell gewählte Wert (value der Option)
|
||||||
|
* @property {iSelectOption[]} options - Die zur Auswahl stehenden Optionen
|
||||||
|
* @property {boolean} readonly (optional) - Auswahl gesperrt
|
||||||
|
* @property {boolean} required (optional) - Pflichtfeld
|
||||||
|
* @property {Function} onchanged (optional) - Callback bei Änderung
|
||||||
|
*/
|
||||||
|
export interface iSelect {
|
||||||
|
name: string
|
||||||
|
value: any
|
||||||
|
options: iSelectOption[]
|
||||||
|
readonly?: boolean
|
||||||
|
required?: boolean
|
||||||
|
onchanged?: Function
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suchbares Select-Widget (Combobox). Ein sichtbares Texteingabefeld dient
|
||||||
|
* gleichzeitig als Suchfeld: während der Eingabe wird die aufklappbare
|
||||||
|
* Optionliste gefiltert — nur Optionen, deren label die eingegebene
|
||||||
|
* Zeichenfolge (case-insensitiv, als Teilstring) enthält, bleiben sichtbar.
|
||||||
|
* Wählt der Benutzer einen Eintrag, wird der interne Wert auf dessen
|
||||||
|
* option.value gesetzt und das Eingabefeld zeigt option.label.
|
||||||
|
*
|
||||||
|
* Für die Formularübergabe wird zusätzlich ein verstecktes input-Element
|
||||||
|
* 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<iSelect> = (vnode) => {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die gefilterten Optionen abhängig vom aktuellen Suchtext.
|
||||||
|
*/
|
||||||
|
const getFilteredOptions = (): iSelectOption[] => {
|
||||||
|
const searchLower = search.toLowerCase()
|
||||||
|
if (searchLower === "") {
|
||||||
|
return vnode.attrs.options
|
||||||
|
}
|
||||||
|
return vnode.attrs.options.filter((o: iSelectOption) => o.label.toLowerCase().includes(searchLower))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wählt eine Option aus: value setzen, Suchtext = label, Dropdown zu.
|
||||||
|
*/
|
||||||
|
const selectOption = (option: iSelectOption) => {
|
||||||
|
vnode.attrs.value = option.value
|
||||||
|
search = option.label
|
||||||
|
isOpen = false
|
||||||
|
highlightIndex = -1
|
||||||
|
if (vnode.attrs.onchanged != undefined) {
|
||||||
|
vnode.attrs.onchanged(option.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Markierung auf den ersten Eintrag, wenn das Dropdown
|
||||||
|
* geöffnet wird.
|
||||||
|
*/
|
||||||
|
const openDropdown = () => {
|
||||||
|
if (!isReadonly) {
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = getFilteredOptions().length > 0 ? 0 : -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
view: (vnode) => {
|
||||||
|
const filteredOptions = getFilteredOptions()
|
||||||
|
const inputAttrs: any = {
|
||||||
|
type: "text",
|
||||||
|
value: search,
|
||||||
|
style: {'margin-bottom': '5px', 'width': '100%'},
|
||||||
|
oninput: (e: Event) => {
|
||||||
|
if (isReadonly) { return }
|
||||||
|
search = (e.target as HTMLInputElement).value
|
||||||
|
isOpen = true
|
||||||
|
highlightIndex = filteredOptions.length > 0 ? 0 : -1
|
||||||
|
},
|
||||||
|
onfocus: (e: Event) => {
|
||||||
|
openDropdown()
|
||||||
|
},
|
||||||
|
onblur: (e: Event) => {
|
||||||
|
// Verzögert schließen, damit ein Klick auf einen Eintrag
|
||||||
|
// vorher registriert wird.
|
||||||
|
setTimeout(() => { isOpen = false }, 150)
|
||||||
|
},
|
||||||
|
onkeydown: (e: KeyboardEvent) => {
|
||||||
|
if (isReadonly) { return }
|
||||||
|
if (filteredOptions.length === 0) { return }
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isOpen) { openDropdown(); return }
|
||||||
|
highlightIndex = (highlightIndex + 1) % filteredOptions.length
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isOpen) { openDropdown(); 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])
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
isOpen = false
|
||||||
|
highlightIndex = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isReadonly) {
|
||||||
|
inputAttrs.readonly = "true"
|
||||||
|
}
|
||||||
|
if (vnode.attrs.required === true) {
|
||||||
|
inputAttrs.required = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
// Gefilterte, aufklappbare Optionliste. Eigene Positionierung,
|
||||||
|
// da die w3-dropdown-content-Klasse das Element per CSS
|
||||||
|
// ausblendet (display:none) und nur bei :hover anzeigt.
|
||||||
|
isOpen && !isReadonly
|
||||||
|
? m("div.w3-bar-block.w3-border.w3-theme-l4", {
|
||||||
|
style: {
|
||||||
|
width: '95%',
|
||||||
|
'max-height': '220px',
|
||||||
|
'overflow-y': 'auto',
|
||||||
|
position: 'absolute',
|
||||||
|
'z-index': '1000',
|
||||||
|
'box-shadow': '2px 2px 4px darkgrey'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filteredOptions.length === 0
|
||||||
|
? m("div.w3-padding-small", "Keine Treffer")
|
||||||
|
: filteredOptions.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",
|
||||||
|
onclick: () => selectOption(option)
|
||||||
|
}, option.label)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user