feat: add AppSelect custom dropdown component
This commit is contained in:
269
src/components/AppSelect.vue
Normal file
269
src/components/AppSelect.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<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>
|
||||
@@ -119,6 +119,22 @@
|
||||
animation: modal-enter 200ms ease-out;
|
||||
}
|
||||
|
||||
/* Dropdown animations */
|
||||
@keyframes dropdown-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-dropdown-enter {
|
||||
animation: dropdown-enter 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes toast-enter {
|
||||
from {
|
||||
|
||||
Reference in New Issue
Block a user