482 lines
15 KiB
Vue
482 lines
15 KiB
Vue
<script setup>
|
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
|
import Icon from './Icon.vue'
|
|
|
|
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann
|
|
// keine SVG-Icons rendern). Die Options-Liste wird per <Teleport> an <body> gehängt und fixed
|
|
// positioniert, damit sie nicht von einem Eltern-Container mit overflow (z. B. .tab-content in
|
|
// TabbedPage oder der FullScreenModal) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
|
|
//
|
|
// Optional: Gruppen (option.group), eine zweite Textzeile je Eintrag (option.description) und ein
|
|
// Tipp-Filter (filterable). Alles drei ist opt-in -- Bestandsaufrufe bleiben unverändert.
|
|
const props = defineProps({
|
|
// Ausgewählter Wert (value einer Option).
|
|
modelValue: {type: String, default: ''},
|
|
/*
|
|
* [{ value, label, description?, icon?, group?, muted? }]
|
|
*
|
|
* group -- Überschrift, unter der der Eintrag einsortiert wird. Einträge ohne group stehen
|
|
* ungruppiert oben (z. B. ein "— keine Auswahl —"-Eintrag).
|
|
* muted -- gedämpft dargestellt, für Einträge mit Sonderbedeutung.
|
|
*/
|
|
options: {type: Array, default: () => []},
|
|
placeholder: {type: String, default: 'Auswählen…'},
|
|
// Trigger wird zum Texteingabefeld: Tippen filtert die Liste. Übernommen wird trotzdem immer
|
|
// eine Option, nie der getippte Text.
|
|
filterable: {type: Boolean, default: false},
|
|
})
|
|
const emit = defineEmits(['update:modelValue'])
|
|
|
|
const open = ref(false)
|
|
const root = ref(null)
|
|
const listRef = ref(null)
|
|
const inputRef = ref(null)
|
|
const pos = ref({top: 0, left: 0, width: 0})
|
|
// Was im Eingabefeld steht, und getrennt davon, wonach gefiltert wird: Beim Öffnen zeigt das Feld
|
|
// das Label der Auswahl, gefiltert wird aber erst, wenn wirklich getippt wurde.
|
|
//
|
|
// Der Feldtext MUSS über einen eigenen Ref laufen und darf nicht direkt an `selected.label` hängen:
|
|
// Vue patcht `value` bei jedem Re-Render, auch wenn der gebundene Wert gleich geblieben ist -- die
|
|
// Eingabe würde sonst bei jedem Tastendruck wieder auf das alte Label zurückspringen.
|
|
const inputText = ref('')
|
|
const query = ref('')
|
|
const highlighted = ref(-1)
|
|
|
|
const selected = computed(() => props.options.find(o => o.value === props.modelValue) ?? null)
|
|
|
|
/**
|
|
* Kleingeschrieben und ohne Sonderzeichen, in zwei Formen: einmal mit ausgeschriebenem Umlaut
|
|
* (ü → ue), einmal mit bloßem Grundbuchstaben (ü → u).
|
|
*
|
|
* Beide, weil beide Schreibweisen vorkommen: Wer „Müller" sucht, tippt mal `mueller`, mal `muller`,
|
|
* mal mit Umlaut. Ein Treffer in einer der beiden Formen genügt.
|
|
*/
|
|
function normalizeForms(value) {
|
|
const base = String(value ?? '').toLowerCase()
|
|
|
|
return [
|
|
clean(base.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')),
|
|
clean(base.replace(/ß/g, 'ss')),
|
|
]
|
|
}
|
|
|
|
/** Akzente auf den Grundbuchstaben zurückführen, alles außer [a-z0-9] verwerfen. */
|
|
function clean(value) {
|
|
return String(value ?? '')
|
|
// Restliche Akzente (é, ñ, …) auf den Grundbuchstaben zurückführen.
|
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]/g, '')
|
|
}
|
|
|
|
const matches = computed(() => {
|
|
const [needleLong, needleShort] = normalizeForms(query.value)
|
|
if (!props.filterable || needleLong === '') {
|
|
return props.options
|
|
}
|
|
|
|
// Gesucht wird nur im Label, nicht in der Beschreibung: Die trägt den Zustand ("abgemeldet am
|
|
// …", "120,00 € offen") und liest sich bei allen ähnlich -- ein "me" träfe sonst jedes
|
|
// "abgemeldet" und die Liste stünde voller Zufallstreffer.
|
|
return props.options.filter(option => {
|
|
const [long, short] = normalizeForms(option.label)
|
|
|
|
return long.includes(needleLong) || short.includes(needleShort)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Die gefilterten Optionen nach Gruppen, in Reihenfolge des ersten Auftretens -- die Sortierung
|
|
* liegt damit beim Aufrufer. Einträge ohne `group` bilden den ersten, überschriftenlosen Block.
|
|
*/
|
|
const groups = computed(() => {
|
|
const result = []
|
|
const byLabel = new Map()
|
|
|
|
for (const option of matches.value) {
|
|
const label = option.group ?? null
|
|
if (!byLabel.has(label)) {
|
|
const group = {label, options: []}
|
|
byLabel.set(label, group)
|
|
result.push(group)
|
|
}
|
|
byLabel.get(label).options.push(option)
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
/** Flache Reihenfolge über alle Gruppen -- die Grundlage für die Pfeiltasten. */
|
|
const flatOptions = computed(() => groups.value.flatMap(group => group.options))
|
|
|
|
function updatePosition() {
|
|
if (!root.value) return
|
|
const rect = root.value.getBoundingClientRect()
|
|
pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width}
|
|
}
|
|
|
|
async function openList() {
|
|
if (open.value) return
|
|
open.value = true
|
|
query.value = ''
|
|
inputText.value = selected.value?.label ?? ''
|
|
highlighted.value = flatOptions.value.findIndex(o => o.value === props.modelValue)
|
|
await nextTick()
|
|
updatePosition()
|
|
}
|
|
|
|
/**
|
|
* Schließt die Liste und stellt den Feldtext auf die bestehende Auswahl zurück.
|
|
*
|
|
* Damit wird getippter Freitext nie zum Wert: Wer die Liste ohne Auswahl verlässt (wegklicken,
|
|
* Escape), sieht wieder das, was tatsächlich ausgewählt ist.
|
|
*/
|
|
function closeList() {
|
|
open.value = false
|
|
query.value = ''
|
|
highlighted.value = -1
|
|
inputText.value = selected.value?.label ?? ''
|
|
}
|
|
|
|
async function toggle() {
|
|
if (open.value) {
|
|
closeList()
|
|
return
|
|
}
|
|
await openList()
|
|
}
|
|
|
|
/**
|
|
* Klick ins Eingabefeld öffnet und markiert den Text, damit Tippen ihn ersetzt.
|
|
*
|
|
* Nur beim Öffnen: Ein zweiter Klick ins bereits offene Feld soll den Cursor setzen dürfen, statt
|
|
* die Markierung wiederherzustellen.
|
|
*/
|
|
async function onInputFocus() {
|
|
if (open.value) return
|
|
|
|
await openList()
|
|
inputRef.value?.select()
|
|
}
|
|
|
|
function onInput(event) {
|
|
inputText.value = event.target.value
|
|
query.value = event.target.value
|
|
// Nach dem Tippen steht die Markierung auf dem ersten Treffer -- Enter wählt damit das
|
|
// Naheliegende, ohne dass jemand erst die Pfeiltaste suchen muss.
|
|
highlighted.value = flatOptions.value.length > 0 ? 0 : -1
|
|
}
|
|
|
|
function select(option) {
|
|
emit('update:modelValue', option.value)
|
|
open.value = false
|
|
query.value = ''
|
|
highlighted.value = -1
|
|
// Nicht über closeList(): `selected` liest noch den alten Wert, der Emit oben wirkt erst mit
|
|
// dem nächsten Render zurück.
|
|
inputText.value = option.label
|
|
inputRef.value?.blur()
|
|
}
|
|
|
|
function onClickOutside(event) {
|
|
const inRoot = root.value && root.value.contains(event.target)
|
|
const inList = listRef.value && listRef.value.contains(event.target)
|
|
if (!inRoot && !inList) {
|
|
closeList()
|
|
}
|
|
}
|
|
|
|
function moveHighlight(step) {
|
|
const count = flatOptions.value.length
|
|
if (count === 0) return
|
|
|
|
const next = highlighted.value + step
|
|
highlighted.value = next < 0 ? count - 1 : next >= count ? 0 : next
|
|
|
|
nextTick(() => {
|
|
listRef.value
|
|
?.querySelector('.rich-select__option--highlighted')
|
|
?.scrollIntoView({block: 'nearest'})
|
|
})
|
|
}
|
|
|
|
function onKeydown(event) {
|
|
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
if (!open.value) return
|
|
// Ohne das schließt derselbe Escape auch die umgebende FullScreenModal (die lauscht am
|
|
// window, wir am document -- und document kommt zuerst). Im Zahlungsimport wären damit
|
|
// sämtliche Zuordnungen weg.
|
|
event.stopPropagation()
|
|
closeList()
|
|
return
|
|
}
|
|
|
|
if (!open.value) return
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
moveHighlight(1)
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
moveHighlight(-1)
|
|
} else if (event.key === 'Enter') {
|
|
const option = flatOptions.value[highlighted.value]
|
|
if (option) {
|
|
event.preventDefault()
|
|
select(option)
|
|
}
|
|
}
|
|
}
|
|
|
|
function onReposition() {
|
|
if (open.value) updatePosition()
|
|
}
|
|
|
|
// Setzt der Aufrufer den Wert von außen (oder werden die Optionen nachgeladen), zieht der Feldtext
|
|
// nach -- solange gerade niemand tippt.
|
|
watch([() => props.modelValue, () => props.options], () => {
|
|
if (!open.value) {
|
|
inputText.value = selected.value?.label ?? ''
|
|
}
|
|
}, {immediate: true})
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', onClickOutside)
|
|
document.addEventListener('keydown', onKeydown)
|
|
window.addEventListener('resize', onReposition)
|
|
// capture=true, damit auch Scrollen in inneren Containern die Position aktualisiert
|
|
window.addEventListener('scroll', onReposition, true)
|
|
})
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', onClickOutside)
|
|
document.removeEventListener('keydown', onKeydown)
|
|
window.removeEventListener('resize', onReposition)
|
|
window.removeEventListener('scroll', onReposition, true)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="rich-select">
|
|
<div v-if="filterable" class="rich-select__trigger rich-select__trigger--input">
|
|
<Icon v-if="selected?.icon && !open" :key="selected.value" :name="selected.icon"/>
|
|
<input
|
|
ref="inputRef"
|
|
type="text"
|
|
class="rich-select__input"
|
|
role="combobox"
|
|
autocomplete="off"
|
|
:aria-expanded="open"
|
|
:value="inputText"
|
|
:placeholder="placeholder"
|
|
@focus="onInputFocus"
|
|
@click="onInputFocus"
|
|
@input="onInput"
|
|
/>
|
|
<span class="rich-select__caret" aria-hidden="true">▾</span>
|
|
</div>
|
|
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="rich-select__trigger"
|
|
:aria-expanded="open"
|
|
@click="toggle"
|
|
>
|
|
<span class="rich-select__value">
|
|
<template v-if="selected">
|
|
<Icon v-if="selected.icon" :key="selected.value" :name="selected.icon"/>
|
|
<span>{{ selected.label }}</span>
|
|
</template>
|
|
<span v-else class="rich-select__placeholder">{{ placeholder }}</span>
|
|
</span>
|
|
<span class="rich-select__caret" aria-hidden="true">▾</span>
|
|
</button>
|
|
|
|
<Teleport to="body">
|
|
<ul
|
|
v-if="open"
|
|
ref="listRef"
|
|
class="rich-select__list"
|
|
role="listbox"
|
|
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }"
|
|
>
|
|
<li v-if="flatOptions.length === 0" class="rich-select__empty">
|
|
Kein Treffer für „{{ query }}"
|
|
</li>
|
|
|
|
<template v-for="group in groups" :key="group.label ?? '_'">
|
|
<li v-if="group.label" class="rich-select__group" role="presentation">
|
|
{{ group.label }} <span class="rich-select__group-count">({{ group.options.length }})</span>
|
|
</li>
|
|
|
|
<li
|
|
v-for="option in group.options"
|
|
:key="option.value"
|
|
class="rich-select__option"
|
|
:class="{
|
|
'rich-select__option--active': option.value === modelValue,
|
|
'rich-select__option--highlighted': option === flatOptions[highlighted],
|
|
'rich-select__option--muted': option.muted,
|
|
}"
|
|
role="option"
|
|
:aria-selected="option.value === modelValue"
|
|
@click="select(option)"
|
|
@mouseenter="highlighted = flatOptions.indexOf(option)"
|
|
>
|
|
<Icon v-if="option.icon" :name="option.icon" class="rich-select__option-icon"/>
|
|
<span class="rich-select__option-text">
|
|
<span class="rich-select__option-label">{{ option.label }}</span>
|
|
<span v-if="option.description" class="rich-select__option-description">
|
|
{{ option.description }}
|
|
</span>
|
|
</span>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rich-select {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.rich-select__trigger {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
gap: 10px;
|
|
padding: 8px 12px;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 6px;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.rich-select__trigger:hover {
|
|
border-color: #2563eb;
|
|
}
|
|
|
|
.rich-select__trigger--input:focus-within {
|
|
border-color: #2563eb;
|
|
}
|
|
|
|
.rich-select__input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: none;
|
|
outline: none;
|
|
padding: 0;
|
|
font-size: 1rem;
|
|
font-family: inherit;
|
|
color: inherit;
|
|
background: transparent;
|
|
}
|
|
|
|
.rich-select__value {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.rich-select__placeholder {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.rich-select__caret {
|
|
color: #6b7280;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.rich-select__list {
|
|
position: fixed;
|
|
z-index: 1000;
|
|
margin: 0;
|
|
padding: 4px;
|
|
list-style: none;
|
|
background: #fff;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 8px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
|
max-height: 320px;
|
|
overflow: auto;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.rich-select__group {
|
|
padding: 10px 10px 4px 10px;
|
|
color: #6b7280;
|
|
font-size: 0.72rem;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.rich-select__group-count {
|
|
font-weight: normal;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.rich-select__empty {
|
|
padding: 14px 10px;
|
|
color: #9ca3af;
|
|
text-align: center;
|
|
}
|
|
|
|
.rich-select__option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.rich-select__option-icon {
|
|
color: #6b7280;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.rich-select__option-text {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.rich-select__option-label {
|
|
font-size: 1rem;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.rich-select__option-description {
|
|
color: #6b7280;
|
|
font-size: 0.8rem;
|
|
line-height: 1.3;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.rich-select__option--muted .rich-select__option-label {
|
|
color: #6b7280;
|
|
}
|
|
|
|
/* Tastatur und Maus zeigen dieselbe Markierung -- sonst „wandert“ beim Wechsel der Eingabeart ein
|
|
zweiter Hinweis durch die Liste. */
|
|
.rich-select__option:hover,
|
|
.rich-select__option--highlighted {
|
|
background: #eff6ff;
|
|
}
|
|
|
|
.rich-select__option--active {
|
|
background: #dbeafe;
|
|
}
|
|
|
|
.rich-select__option--active .rich-select__option-label {
|
|
font-weight: 600;
|
|
}
|
|
</style>
|