Teleported panels read zoom from #app and apply it to their own style, with position coordinates divided by the zoom factor so they align correctly with the zoomed trigger elements.
279 lines
7.2 KiB
Vue
279 lines
7.2 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 getZoomFactor(): number {
|
|
const app = document.getElementById('app')
|
|
if (!app) return 1
|
|
const zoom = (app.style as any).zoom
|
|
return zoom ? parseFloat(zoom) / 100 : 1
|
|
}
|
|
|
|
function updatePosition() {
|
|
if (!triggerRef.value) return
|
|
const rect = triggerRef.value.getBoundingClientRect()
|
|
const zoom = getZoomFactor()
|
|
panelStyle.value = {
|
|
position: 'fixed',
|
|
top: `${(rect.bottom + 4) / zoom}px`,
|
|
left: `${rect.left / zoom}px`,
|
|
width: `${rect.width / zoom}px`,
|
|
zIndex: '9999',
|
|
zoom: `${zoom * 100}%`,
|
|
}
|
|
}
|
|
|
|
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>
|