Dev 4.9.0 #17
@@ -4,6 +4,7 @@ import {toast} from 'vue3-toastify'
|
|||||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||||
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
|
import LoadingModal from "../../../../Views/Components/LoadingModal.vue";
|
||||||
import Icon from "../../../../Views/Components/Icon.vue";
|
import Icon from "../../../../Views/Components/Icon.vue";
|
||||||
|
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
|
||||||
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
|
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +42,39 @@ const participantsByIdentifier = computed(
|
|||||||
() => Object.fromEntries(participants.value.map(participant => [participant.identifier, participant])),
|
() => Object.fromEntries(participants.value.map(participant => [participant.identifier, participant])),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Auswahlliste für alle Zeilen -- einmal gebaut, nicht je Zeile.
|
||||||
|
*
|
||||||
|
* Getrennt nach an- und abgemeldet, weil die Entscheidung unterschiedlich weit trägt: Eine Buchung
|
||||||
|
* auf eine abgemeldete Anmeldung zieht eine Erstattung nach sich. Als Fließtext in einer Zeile ging
|
||||||
|
* das unter, als Gruppenüberschrift steht es da.
|
||||||
|
*/
|
||||||
|
const participantOptions = computed(() => {
|
||||||
|
const options = [{value: '', label: '— ignorieren —'}]
|
||||||
|
|
||||||
|
for (const participant of participants.value) {
|
||||||
|
const state = participant.isSettled ? 'vollständig bezahlt' : participant.amountOpen + ' offen'
|
||||||
|
|
||||||
|
options.push(participant.isSignedOff
|
||||||
|
? {
|
||||||
|
value: participant.identifier,
|
||||||
|
label: participant.name,
|
||||||
|
description: 'abgemeldet am ' + participant.signedOffAt + ' · ' + state,
|
||||||
|
group: 'Abgemeldete Teilis',
|
||||||
|
icon: 'user-slash',
|
||||||
|
muted: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: participant.identifier,
|
||||||
|
label: participant.name,
|
||||||
|
description: state,
|
||||||
|
group: 'Angemeldete Teilis',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
function isAssigned(row) {
|
function isAssigned(row) {
|
||||||
return (assignment.value[row.rowNumber] ?? '') !== ''
|
return (assignment.value[row.rowNumber] ?? '') !== ''
|
||||||
}
|
}
|
||||||
@@ -140,16 +174,6 @@ function reset() {
|
|||||||
if (fileInput.value) fileInput.value.value = ''
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionLabel(participant) {
|
|
||||||
const state = participant.isSettled
|
|
||||||
? 'vollständig bezahlt'
|
|
||||||
: participant.amountOpen + ' offen'
|
|
||||||
|
|
||||||
return participant.isSignedOff
|
|
||||||
? participant.name + ' — abgemeldet, ' + state
|
|
||||||
: participant.name + ' — ' + state
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignedParticipant(row) {
|
function assignedParticipant(row) {
|
||||||
return participantsByIdentifier.value[assignment.value[row.rowNumber]] ?? null
|
return participantsByIdentifier.value[assignment.value[row.rowNumber]] ?? null
|
||||||
}
|
}
|
||||||
@@ -257,13 +281,10 @@ function signedOffOf(row) {
|
|||||||
<td class="purpose">{{ row.purpose }}</td>
|
<td class="purpose">{{ row.purpose }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="assignment">
|
<div class="assignment">
|
||||||
<select v-model="assignment[row.rowNumber]" class="assignment-select">
|
<RichSelectBox v-model="assignment[row.rowNumber]"
|
||||||
<option value="">— ignorieren —</option>
|
:options="participantOptions"
|
||||||
<option v-for="participant in participants" :key="participant.identifier"
|
placeholder="— ignorieren —"
|
||||||
:value="participant.identifier">
|
filterable/>
|
||||||
{{ optionLabel(participant) }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span class="pill" :class="'pill-' + rowState(row).key">
|
<span class="pill" :class="'pill-' + rowState(row).key">
|
||||||
<Icon :key="rowState(row).icon" :name="rowState(row).icon"/> {{ rowState(row).label }}
|
<Icon :key="rowState(row).icon" :name="rowState(row).icon"/> {{ rowState(row).label }}
|
||||||
</span>
|
</span>
|
||||||
@@ -458,7 +479,7 @@ function signedOffOf(row) {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
font-size: 0.9rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statement-table .right {
|
.statement-table .right {
|
||||||
@@ -476,7 +497,7 @@ function signedOffOf(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assignment-column {
|
.assignment-column {
|
||||||
width: 320px;
|
width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Zeilenfarbe sagt auf einen Blick, was passiert: grün wird gebucht, gelb will geprüft werden,
|
/* Zeilenfarbe sagt auf einen Blick, was passiert: grün wird gebucht, gelb will geprüft werden,
|
||||||
@@ -520,14 +541,6 @@ function signedOffOf(row) {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assignment-select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 5px 6px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: #ffffff;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|||||||
@@ -1,57 +1,229 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann
|
// 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
|
// 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
|
// positioniert, damit sie nicht von einem Eltern-Container mit overflow (z. B. .tab-content in
|
||||||
// TabbedPage) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
|
// 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({
|
const props = defineProps({
|
||||||
// Ausgewählter Wert (value einer Option).
|
// Ausgewählter Wert (value einer Option).
|
||||||
modelValue: {type: String, default: ''},
|
modelValue: {type: String, default: ''},
|
||||||
// [{ value, label, icon? }]
|
/*
|
||||||
|
* [{ 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: () => []},
|
options: {type: Array, default: () => []},
|
||||||
placeholder: {type: String, default: 'Auswählen…'},
|
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 emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const root = ref(null)
|
const root = ref(null)
|
||||||
const listRef = ref(null)
|
const listRef = ref(null)
|
||||||
|
const inputRef = ref(null)
|
||||||
const pos = ref({top: 0, left: 0, width: 0})
|
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)
|
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() {
|
function updatePosition() {
|
||||||
if (!root.value) return
|
if (!root.value) return
|
||||||
const rect = root.value.getBoundingClientRect()
|
const rect = root.value.getBoundingClientRect()
|
||||||
pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width}
|
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() {
|
async function toggle() {
|
||||||
open.value = !open.value
|
|
||||||
if (open.value) {
|
if (open.value) {
|
||||||
await nextTick()
|
closeList()
|
||||||
updatePosition()
|
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) {
|
function select(option) {
|
||||||
emit('update:modelValue', option.value)
|
emit('update:modelValue', option.value)
|
||||||
open.value = false
|
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) {
|
function onClickOutside(event) {
|
||||||
const inRoot = root.value && root.value.contains(event.target)
|
const inRoot = root.value && root.value.contains(event.target)
|
||||||
const inList = listRef.value && listRef.value.contains(event.target)
|
const inList = listRef.value && listRef.value.contains(event.target)
|
||||||
if (!inRoot && !inList) {
|
if (!inRoot && !inList) {
|
||||||
open.value = false
|
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) {
|
function onKeydown(event) {
|
||||||
if (event.key === 'Escape' || event.key === 'Esc') {
|
if (event.key === 'Escape' || event.key === 'Esc') {
|
||||||
open.value = false
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +231,14 @@ function onReposition() {
|
|||||||
if (open.value) updatePosition()
|
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(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onClickOutside)
|
document.addEventListener('click', onClickOutside)
|
||||||
document.addEventListener('keydown', onKeydown)
|
document.addEventListener('keydown', onKeydown)
|
||||||
@@ -76,7 +256,26 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="root" class="rich-select">
|
<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
|
<button
|
||||||
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="rich-select__trigger"
|
class="rich-select__trigger"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
@@ -100,18 +299,38 @@ onBeforeUnmount(() => {
|
|||||||
role="listbox"
|
role="listbox"
|
||||||
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }"
|
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }"
|
||||||
>
|
>
|
||||||
<li
|
<li v-if="flatOptions.length === 0" class="rich-select__empty">
|
||||||
v-for="option in options"
|
Kein Treffer für „{{ query }}"
|
||||||
:key="option.value"
|
|
||||||
class="rich-select__option"
|
|
||||||
:class="{ 'rich-select__option--active': option.value === modelValue }"
|
|
||||||
role="option"
|
|
||||||
:aria-selected="option.value === modelValue"
|
|
||||||
@click="select(option)"
|
|
||||||
>
|
|
||||||
<Icon v-if="option.icon" :name="option.icon"/>
|
|
||||||
<span>{{ option.label }}</span>
|
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,12 +348,12 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 6px 10px;
|
padding: 8px 12px;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +361,22 @@ onBeforeUnmount(() => {
|
|||||||
border-color: #2563eb;
|
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 {
|
.rich-select__value {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -168,26 +403,79 @@ onBeforeUnmount(() => {
|
|||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
max-height: 260px;
|
max-height: 320px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
box-sizing: border-box;
|
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 {
|
.rich-select__option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 10px;
|
padding: 10px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-select__option:hover {
|
.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;
|
background: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-select__option--active {
|
.rich-select__option--active {
|
||||||
background: #dbeafe;
|
background: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-select__option--active .rich-select__option-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user