Files
zeroclock/src/components/AppSelect.vue
2026-02-17 22:22:43 +02:00

270 lines
7.0 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'
interface Props {
modelValue: any
options: any[]
labelKey?: string
valueKey?: string
placeholder?: string
disabled?: boolean
placeholderValue?: any
}
const props = withDefaults(defineProps<Props>(), {
labelKey: 'name',
valueKey: 'id',
placeholder: 'Select...',
disabled: false,
placeholderValue: undefined,
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const isOpen = ref(false)
const highlightedIndex = ref(-1)
const triggerRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLDivElement | null>(null)
const panelStyle = ref<Record<string, string>>({})
// Build the full list: placeholder + real options
const allItems = computed(() => {
const placeholderItem = {
_isPlaceholder: true,
[props.valueKey]: props.placeholderValue,
[props.labelKey]: props.placeholder,
}
return [placeholderItem, ...props.options]
})
const selectedLabel = computed(() => {
const option = props.options.find(
(o) => o[props.valueKey] === props.modelValue
)
return option ? option[props.labelKey] : null
})
const isPlaceholderSelected = computed(() => {
return selectedLabel.value === null
})
function getOptionValue(item: any): any {
return item._isPlaceholder ? props.placeholderValue : item[props.valueKey]
}
function getOptionLabel(item: any): string {
return item[props.labelKey]
}
function isSelected(item: any): boolean {
const val = getOptionValue(item)
return val === props.modelValue
}
function updatePosition() {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
panelStyle.value = {
position: 'fixed',
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
zIndex: '9999',
}
}
function toggle() {
if (props.disabled) return
if (isOpen.value) {
close()
} else {
open()
}
}
function open() {
if (props.disabled) return
isOpen.value = true
updatePosition()
// Set highlighted index to the currently selected item
const selectedIdx = allItems.value.findIndex((item) => isSelected(item))
highlightedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
nextTick(() => {
scrollHighlightedIntoView()
})
document.addEventListener('click', onClickOutside, true)
document.addEventListener('scroll', onScrollOrResize, true)
window.addEventListener('resize', onScrollOrResize)
}
function close() {
isOpen.value = false
highlightedIndex.value = -1
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
}
function select(item: any) {
emit('update:modelValue', getOptionValue(item))
close()
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (
triggerRef.value?.contains(target) ||
panelRef.value?.contains(target)
) {
return
}
close()
}
function onScrollOrResize() {
if (isOpen.value) {
updatePosition()
}
}
function scrollHighlightedIntoView() {
if (!panelRef.value) return
const items = panelRef.value.querySelectorAll('[data-option]')
const item = items[highlightedIndex.value] as HTMLElement | undefined
if (item) {
item.scrollIntoView({ block: 'nearest' })
}
}
function onKeydown(e: KeyboardEvent) {
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
open()
return
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
highlightedIndex.value = Math.min(
highlightedIndex.value + 1,
allItems.value.length - 1
)
nextTick(() => scrollHighlightedIntoView())
break
case 'ArrowUp':
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
nextTick(() => scrollHighlightedIntoView())
break
case 'Enter':
case ' ':
e.preventDefault()
if (highlightedIndex.value >= 0) {
select(allItems.value[highlightedIndex.value])
}
break
case 'Escape':
e.preventDefault()
close()
triggerRef.value?.focus()
break
case 'Tab':
close()
break
}
}
onMounted(() => {
// Nothing needed on mount since listeners are added when opened
})
onBeforeUnmount(() => {
// Clean up all listeners in case component is destroyed while open
document.removeEventListener('click', onClickOutside, true)
document.removeEventListener('scroll', onScrollOrResize, true)
window.removeEventListener('resize', onScrollOrResize)
})
</script>
<template>
<div class="relative">
<!-- Trigger button -->
<button
ref="triggerRef"
type="button"
:disabled="disabled"
@click="toggle"
@keydown="onKeydown"
class="w-full flex items-center justify-between gap-2 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left transition-colors"
:class="{
'opacity-40 cursor-not-allowed': disabled,
'cursor-pointer': !disabled,
}"
:style="
isOpen
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<span
:class="isPlaceholderSelected ? 'text-text-tertiary' : 'text-text-primary'"
class="truncate"
>
{{ selectedLabel ?? placeholder }}
</span>
<ChevronDown
class="w-4 h-4 text-text-secondary shrink-0 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
:stroke-width="2"
/>
</button>
<!-- Dropdown panel -->
<Teleport to="body">
<div
v-if="isOpen"
ref="panelRef"
:style="panelStyle"
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden animate-dropdown-enter"
>
<div class="max-h-[240px] overflow-y-auto py-1">
<div
v-for="(item, index) in allItems"
:key="item._isPlaceholder ? '__placeholder__' : item[valueKey]"
data-option
@click="select(item)"
@mouseenter="highlightedIndex = index"
class="flex items-center justify-between gap-2 px-3 py-2 text-[0.8125rem] cursor-pointer transition-colors"
:class="{
'bg-bg-elevated': highlightedIndex === index,
'text-text-tertiary': item._isPlaceholder,
'text-text-primary': !item._isPlaceholder,
}"
>
<span class="truncate">{{ getOptionLabel(item) }}</span>
<Check
v-if="isSelected(item)"
class="w-4 h-4 text-accent shrink-0"
:stroke-width="2"
/>
</div>
</div>
</div>
</Teleport>
</div>
</template>