18b61bed52
- Widen AdminCandidateEditorModal to size lg for better readability - Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link" - Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport - Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
436 lines
10 KiB
Vue
436 lines
10 KiB
Vue
<template>
|
|
<div ref="rootEl" class="native-select" :class="{ 'native-select--open': open, 'native-select--disabled': disabled }">
|
|
<select
|
|
class="native-select__native"
|
|
:value="selectedKey"
|
|
:disabled="disabled"
|
|
aria-hidden="true"
|
|
tabindex="-1"
|
|
>
|
|
<option
|
|
v-for="option in options"
|
|
:key="optionKey(option.value)"
|
|
:value="optionKey(option.value)"
|
|
:disabled="option.disabled"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
|
|
<button
|
|
ref="triggerEl"
|
|
type="button"
|
|
class="native-select__trigger"
|
|
:disabled="disabled"
|
|
:aria-expanded="open"
|
|
aria-haspopup="listbox"
|
|
:aria-controls="listboxId"
|
|
@click="toggleDropdown"
|
|
@keydown="onButtonKeydown"
|
|
>
|
|
<span class="native-select__value">{{ selectedOption?.label ?? placeholder }}</span>
|
|
<span class="native-select__chevron" aria-hidden="true">
|
|
<ChevronDown :size="20" :stroke-width="2.8" />
|
|
</span>
|
|
</button>
|
|
|
|
<Teleport to="body">
|
|
<div v-if="open" :id="listboxId" class="native-select__menu" :style="menuStyle" role="listbox">
|
|
<template v-for="(option, index) in options" :key="optionKey(option.value)">
|
|
<p v-if="option.group && option.group !== options[index - 1]?.group" class="native-select__group">
|
|
{{ option.group }}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
class="native-select__option"
|
|
:class="{
|
|
'native-select__option--active': index === activeIndex,
|
|
'native-select__option--selected': isSelected(option),
|
|
}"
|
|
:disabled="option.disabled"
|
|
role="option"
|
|
:aria-selected="isSelected(option)"
|
|
@mouseenter="setActiveIndex(index)"
|
|
@click="selectOption(option)"
|
|
>
|
|
<span class="native-select__option-label">{{ option.label }}</span>
|
|
<Check v-if="isSelected(option)" class="native-select__check" :size="18" :stroke-width="3" />
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { Check, ChevronDown } from '@lucide/vue'
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
|
|
|
|
type SelectOptionValue = string | number | null
|
|
|
|
type SelectOption = {
|
|
label: string
|
|
value: SelectOptionValue
|
|
disabled?: boolean
|
|
group?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<{
|
|
modelValue: SelectOptionValue
|
|
options: SelectOption[]
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
}>(), {
|
|
placeholder: 'Auswählen',
|
|
disabled: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: SelectOptionValue]
|
|
change: [value: SelectOptionValue, option: SelectOption]
|
|
}>()
|
|
|
|
const rootEl = ref<HTMLElement | null>(null)
|
|
const triggerEl = ref<HTMLButtonElement | null>(null)
|
|
const open = ref(false)
|
|
const activeIndex = ref(0)
|
|
const listboxId = `native-select-${Math.random().toString(36).slice(2)}`
|
|
const menuPos = ref({ top: 0, bottom: 'auto' as string | number, left: 0, width: 0, maxHeight: 286 })
|
|
|
|
const menuStyle = computed<CSSProperties>(() => ({
|
|
position: 'fixed',
|
|
top: menuPos.value.top !== 0 ? `${menuPos.value.top}px` : 'auto',
|
|
bottom: menuPos.value.bottom !== 'auto' ? `${menuPos.value.bottom}px` : 'auto',
|
|
left: `${menuPos.value.left}px`,
|
|
width: `${menuPos.value.width}px`,
|
|
maxHeight: `${menuPos.value.maxHeight}px`,
|
|
zIndex: 9999,
|
|
}))
|
|
|
|
function updateMenuRect() {
|
|
if (!triggerEl.value) return
|
|
const r = triggerEl.value.getBoundingClientRect()
|
|
const PADDING = 8
|
|
const MAX_H = 286
|
|
const spaceBelow = window.innerHeight - r.bottom - PADDING
|
|
const spaceAbove = r.top - PADDING
|
|
|
|
if (spaceBelow >= Math.min(MAX_H, 160) || spaceBelow >= spaceAbove) {
|
|
menuPos.value = {
|
|
top: r.bottom + PADDING,
|
|
bottom: 'auto',
|
|
left: r.left,
|
|
width: r.width,
|
|
maxHeight: Math.min(MAX_H, spaceBelow),
|
|
}
|
|
} else {
|
|
menuPos.value = {
|
|
top: 0,
|
|
bottom: window.innerHeight - r.top + PADDING,
|
|
left: r.left,
|
|
width: r.width,
|
|
maxHeight: Math.min(MAX_H, spaceAbove),
|
|
}
|
|
}
|
|
}
|
|
|
|
const selectedOption = computed(() =>
|
|
props.options.find((option) => isSameValue(option.value, props.modelValue)) ?? null,
|
|
)
|
|
|
|
const selectedKey = computed(() => optionKey(selectedOption.value?.value ?? null))
|
|
|
|
watch(
|
|
() => [props.modelValue, props.options] as const,
|
|
() => {
|
|
const selectedIndex = props.options.findIndex((option) => isSameValue(option.value, props.modelValue))
|
|
activeIndex.value = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
function optionKey(value: SelectOptionValue) {
|
|
return value === null ? 'null:' : `${typeof value}:${value}`
|
|
}
|
|
|
|
function isSameValue(left: SelectOptionValue, right: SelectOptionValue) {
|
|
return optionKey(left) === optionKey(right)
|
|
}
|
|
|
|
function isSelected(option: SelectOption) {
|
|
return isSameValue(option.value, props.modelValue)
|
|
}
|
|
|
|
function firstEnabledIndex() {
|
|
return Math.max(0, props.options.findIndex((option) => !option.disabled))
|
|
}
|
|
|
|
function setActiveIndex(index: number) {
|
|
if (!props.options[index]?.disabled) {
|
|
activeIndex.value = index
|
|
}
|
|
}
|
|
|
|
function toggleDropdown() {
|
|
if (props.disabled || props.options.length === 0) return
|
|
open.value = !open.value
|
|
if (open.value) {
|
|
updateMenuRect()
|
|
if (props.options[activeIndex.value]?.disabled) {
|
|
activeIndex.value = firstEnabledIndex()
|
|
}
|
|
}
|
|
}
|
|
|
|
function closeDropdown() {
|
|
open.value = false
|
|
}
|
|
|
|
function selectOption(option: SelectOption) {
|
|
if (props.disabled || option.disabled) return
|
|
emit('update:modelValue', option.value)
|
|
emit('change', option.value, option)
|
|
closeDropdown()
|
|
}
|
|
|
|
function selectActiveOption() {
|
|
const option = props.options[activeIndex.value]
|
|
if (option) selectOption(option)
|
|
}
|
|
|
|
function moveActive(delta: number) {
|
|
if (!props.options.length || props.disabled) return
|
|
if (!open.value) {
|
|
open.value = true
|
|
}
|
|
|
|
let nextIndex = activeIndex.value
|
|
for (let step = 0; step < props.options.length; step += 1) {
|
|
nextIndex = (nextIndex + delta + props.options.length) % props.options.length
|
|
if (!props.options[nextIndex]?.disabled) {
|
|
activeIndex.value = nextIndex
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
function onButtonKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
moveActive(1)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
moveActive(-1)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
open.value ? selectActiveOption() : toggleDropdown()
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
closeDropdown()
|
|
}
|
|
}
|
|
|
|
function onDocumentPointerDown(event: PointerEvent) {
|
|
if (!rootEl.value?.contains(event.target as Node)) {
|
|
closeDropdown()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('pointerdown', onDocumentPointerDown)
|
|
window.addEventListener('scroll', updateMenuRect, true)
|
|
window.addEventListener('resize', updateMenuRect)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
|
window.removeEventListener('scroll', updateMenuRect, true)
|
|
window.removeEventListener('resize', updateMenuRect)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.native-select{
|
|
position:relative;
|
|
width:100%;
|
|
min-width:0;
|
|
z-index:1;
|
|
}
|
|
|
|
.native-select--open{
|
|
z-index:80;
|
|
}
|
|
|
|
.native-select__native{
|
|
position:absolute;
|
|
width:1px;
|
|
height:1px;
|
|
opacity:0;
|
|
pointer-events:none;
|
|
}
|
|
|
|
.native-select__trigger{
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
gap:14px;
|
|
width:100%;
|
|
min-height:48px;
|
|
padding:8px 9px 8px 15px;
|
|
border:2px solid #e8def8;
|
|
border-radius:16px;
|
|
background:linear-gradient(180deg,#fff 0%,#fbf8ff 100%);
|
|
box-shadow:0 8px 22px rgba(139,108,219,.08), inset 0 1px 0 rgba(255,255,255,.9);
|
|
color:#1f2337;
|
|
cursor:pointer;
|
|
font-family:'Outfit',sans-serif;
|
|
font-size:14px;
|
|
font-weight:750;
|
|
line-height:1.2;
|
|
outline:none;
|
|
text-align:left;
|
|
transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease;
|
|
}
|
|
|
|
.native-select__trigger:hover,
|
|
.native-select__trigger:focus-visible,
|
|
.native-select--open .native-select__trigger{
|
|
border-color:#9b7ce4;
|
|
background:#fff;
|
|
transform:translateY(-2px);
|
|
box-shadow:0 12px 30px rgba(139,108,219,.16), 0 0 0 4px rgba(139,108,219,.12);
|
|
}
|
|
|
|
.native-select__trigger:active{
|
|
transform:translateY(0);
|
|
}
|
|
|
|
.native-select__trigger:disabled{
|
|
cursor:not-allowed;
|
|
border-color:#e5e7eb;
|
|
background:#f8fafc;
|
|
color:#94a3b8;
|
|
box-shadow:none;
|
|
}
|
|
|
|
.native-select__value{
|
|
min-width:0;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
|
|
.native-select__chevron{
|
|
display:grid;
|
|
place-items:center;
|
|
flex:none;
|
|
width:32px;
|
|
height:32px;
|
|
border-radius:12px;
|
|
background:#f1eafd;
|
|
color:#7c5bd2;
|
|
box-shadow:inset 0 0 0 1px rgba(139,108,219,.09);
|
|
transition:transform .18s ease, background .18s ease, color .18s ease;
|
|
}
|
|
|
|
.native-select--open .native-select__chevron{
|
|
transform:rotate(180deg);
|
|
background:#835fd8;
|
|
color:#fff;
|
|
}
|
|
|
|
.native-select--disabled .native-select__chevron{
|
|
background:#eef2f7;
|
|
color:#94a3b8;
|
|
}
|
|
|
|
.native-select__menu{
|
|
display:grid;
|
|
gap:4px;
|
|
max-height:286px;
|
|
padding:8px;
|
|
overflow:auto;
|
|
border:1px solid rgba(139,108,219,.18);
|
|
border-radius:18px;
|
|
background:rgba(255,255,255,.98);
|
|
box-shadow:0 24px 54px rgba(63,53,86,.22);
|
|
backdrop-filter:blur(14px);
|
|
-webkit-backdrop-filter:blur(14px);
|
|
}
|
|
|
|
.native-select__group{
|
|
margin:8px 8px 2px;
|
|
color:#7f728f;
|
|
font-size:10px;
|
|
font-weight:800;
|
|
letter-spacing:.16em;
|
|
line-height:1.2;
|
|
text-transform:uppercase;
|
|
}
|
|
|
|
.native-select__group:first-child{
|
|
margin-top:2px;
|
|
}
|
|
|
|
.native-select__option{
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:space-between;
|
|
gap:12px;
|
|
width:100%;
|
|
min-height:42px;
|
|
padding:9px 12px;
|
|
border:0;
|
|
border-radius:13px;
|
|
background:transparent;
|
|
color:#514765;
|
|
cursor:pointer;
|
|
font-family:'Outfit',sans-serif;
|
|
font-size:14px;
|
|
font-weight:650;
|
|
line-height:1.2;
|
|
text-align:left;
|
|
transition:background .16s ease, color .16s ease, transform .16s ease;
|
|
}
|
|
|
|
.native-select__option:hover,
|
|
.native-select__option--active{
|
|
background:#f6f0ff;
|
|
color:#4f3a8a;
|
|
transform:translateY(-1px);
|
|
}
|
|
|
|
.native-select__option--selected{
|
|
background:linear-gradient(135deg,#efe6ff,#e7dcfb);
|
|
color:#3f2f70;
|
|
font-weight:800;
|
|
}
|
|
|
|
.native-select__option:disabled{
|
|
cursor:not-allowed;
|
|
color:#a8a0b5;
|
|
background:#f8fafc;
|
|
}
|
|
|
|
.native-select__option-label{
|
|
min-width:0;
|
|
overflow:hidden;
|
|
text-overflow:ellipsis;
|
|
white-space:nowrap;
|
|
}
|
|
|
|
.native-select__check{
|
|
flex:none;
|
|
color:#7c5bd2;
|
|
}
|
|
</style>
|