Files
mareike/app/Views/Components/RichSelectBox.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>