Files
xinghuoapi/frontend/src/components/common/Select.vue
2026-01-11 20:56:27 +08:00

515 lines
15 KiB
Vue

<template>
<div class="relative" ref="containerRef">
<button
ref="triggerRef"
type="button"
@click="toggle"
:disabled="disabled"
:aria-expanded="isOpen"
:aria-haspopup="true"
aria-label="Select option"
:class="[
'select-trigger',
isOpen && 'select-trigger-open',
error && 'select-trigger-error',
disabled && 'select-trigger-disabled'
]"
@keydown.down.prevent="onTriggerKeyDown"
@keydown.up.prevent="onTriggerKeyDown"
>
<span class="select-value">
<slot name="selected" :option="selectedOption">
{{ selectedLabel }}
</slot>
</span>
<span class="select-icon">
<Icon
name="chevronDown"
size="md"
:class="['transition-transform duration-200', isOpen && 'rotate-180']"
/>
</span>
</button>
<!-- Teleport dropdown to body to escape stacking context -->
<Teleport to="body">
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
class="select-dropdown-portal"
:class="[instanceId]"
:style="dropdownStyle"
role="listbox"
@click.stop
@mousedown.stop
@keydown="onDropdownKeyDown"
>
<!-- Search input -->
<div v-if="searchable" class="select-search">
<Icon name="search" size="sm" class="text-gray-400" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholderText"
class="select-search-input"
@click.stop
/>
</div>
<!-- Options list -->
<div class="select-options" ref="optionsListRef">
<div
v-for="(option, index) in filteredOptions"
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
role="option"
:aria-selected="isSelected(option)"
:aria-disabled="isOptionDisabled(option)"
@click.stop="!isOptionDisabled(option) && selectOption(option)"
@mouseenter="handleOptionMouseEnter(option, index)"
:class="[
'select-option',
isGroupHeaderOption(option) && 'select-option-group',
isSelected(option) && 'select-option-selected',
isOptionDisabled(option) && !isGroupHeaderOption(option) && 'select-option-disabled',
focusedIndex === index && !isGroupHeaderOption(option) && 'select-option-focused'
]"
>
<slot name="option" :option="option" :selected="isSelected(option)">
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
<Icon
v-if="isSelected(option)"
name="check"
size="sm"
class="text-primary-500"
:stroke-width="2"
/>
</slot>
</div>
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
// Instance ID for unique click-outside detection
const instanceId = `select-${Math.random().toString(36).substring(2, 9)}`
export interface SelectOption {
value: string | number | boolean | null
label: string
disabled?: boolean
[key: string]: unknown
}
interface Props {
modelValue: string | number | boolean | null | undefined
options: SelectOption[] | Array<Record<string, unknown>>
placeholder?: string
disabled?: boolean
error?: boolean
searchable?: boolean
searchPlaceholder?: string
emptyText?: string
valueKey?: string
labelKey?: string
}
interface Emits {
(e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
error: false,
searchable: false,
valueKey: 'value',
labelKey: 'label'
})
const emit = defineEmits<Emits>()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const optionsListRef = ref<HTMLElement | null>(null)
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
const triggerRect = ref<DOMRect | null>(null)
// i18n placeholders
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
// Computed style for teleported dropdown
const dropdownStyle = computed(() => {
if (!triggerRect.value) return {}
const rect = triggerRect.value
const style: Record<string, string> = {
position: 'fixed',
left: `${rect.left}px`,
minWidth: `${rect.width}px`,
zIndex: '100000020'
}
if (dropdownPosition.value === 'top') {
style.bottom = `${window.innerHeight - rect.top + 4}px`
} else {
style.top = `${rect.bottom + 4}px`
}
return style
})
const getOptionValue = (option: any): any => {
if (typeof option === 'object' && option !== null) {
return option[props.valueKey]
}
return option
}
const getOptionLabel = (option: any): string => {
if (typeof option === 'object' && option !== null) {
return String(option[props.labelKey] ?? '')
}
return String(option ?? '')
}
const isOptionDisabled = (option: any): boolean => {
if (typeof option === 'object' && option !== null) {
return !!option.disabled
}
return false
}
const isGroupHeaderOption = (option: any): boolean => {
if (typeof option === 'object' && option !== null) {
return option.kind === 'group'
}
return false
}
const selectedOption = computed(() => {
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
})
const selectedLabel = computed(() => {
if (selectedOption.value) {
return getOptionLabel(selectedOption.value)
}
return placeholderText.value
})
const filteredOptions = computed(() => {
let opts = props.options as any[]
if (props.searchable && searchQuery.value) {
const query = searchQuery.value.toLowerCase()
opts = opts.filter((opt) => getOptionLabel(opt).toLowerCase().includes(query))
}
return opts
})
const isSelected = (option: any): boolean => {
return getOptionValue(option) === props.modelValue
}
const findNextEnabledIndex = (startIndex: number): number => {
const opts = filteredOptions.value
if (opts.length === 0) return -1
for (let offset = 0; offset < opts.length; offset++) {
const idx = (startIndex + offset) % opts.length
if (!isOptionDisabled(opts[idx])) return idx
}
return -1
}
const findPrevEnabledIndex = (startIndex: number): number => {
const opts = filteredOptions.value
if (opts.length === 0) return -1
for (let offset = 0; offset < opts.length; offset++) {
const idx = (startIndex - offset + opts.length) % opts.length
if (!isOptionDisabled(opts[idx])) return idx
}
return -1
}
const handleOptionMouseEnter = (option: any, index: number) => {
if (isOptionDisabled(option) || isGroupHeaderOption(option)) return
focusedIndex.value = index
}
// Update trigger rect periodically while open to follow scroll/resize
const updateTriggerRect = () => {
if (containerRef.value) {
triggerRect.value = containerRef.value.getBoundingClientRect()
}
}
const calculateDropdownPosition = () => {
if (!containerRef.value) return
updateTriggerRect()
nextTick(() => {
if (!dropdownRef.value || !triggerRect.value) return
const dropdownHeight = dropdownRef.value.offsetHeight || 240
const spaceBelow = window.innerHeight - triggerRect.value.bottom
const spaceAbove = triggerRect.value.top
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
dropdownPosition.value = 'top'
} else {
dropdownPosition.value = 'bottom'
}
})
}
const toggle = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
}
watch(isOpen, (open) => {
if (open) {
calculateDropdownPosition()
// Reset focused index to current selection or first item
if (filteredOptions.value.length === 0) {
focusedIndex.value = -1
} else {
const selectedIdx = filteredOptions.value.findIndex(isSelected)
const initialIdx = selectedIdx >= 0 ? selectedIdx : 0
focusedIndex.value = isOptionDisabled(filteredOptions.value[initialIdx])
? findNextEnabledIndex(initialIdx + 1)
: initialIdx
}
if (props.searchable) {
nextTick(() => searchInputRef.value?.focus())
}
// Add scroll listener to update position
window.addEventListener('scroll', updateTriggerRect, { capture: true, passive: true })
window.addEventListener('resize', calculateDropdownPosition)
} else {
searchQuery.value = ''
focusedIndex.value = -1
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
}
})
const selectOption = (option: any) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
emit('change', value, option)
isOpen.value = false
triggerRef.value?.focus()
}
// Keyboards
const onTriggerKeyDown = () => {
if (!isOpen.value) {
isOpen.value = true
}
}
const onDropdownKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
focusedIndex.value = findNextEnabledIndex(focusedIndex.value + 1)
if (focusedIndex.value >= 0) scrollToFocused()
break
case 'ArrowUp':
e.preventDefault()
focusedIndex.value = findPrevEnabledIndex(focusedIndex.value - 1)
if (focusedIndex.value >= 0) scrollToFocused()
break
case 'Enter':
e.preventDefault()
if (focusedIndex.value >= 0 && focusedIndex.value < filteredOptions.value.length) {
const opt = filteredOptions.value[focusedIndex.value]
if (!isOptionDisabled(opt)) selectOption(opt)
}
break
case 'Escape':
e.preventDefault()
isOpen.value = false
triggerRef.value?.focus()
break
case 'Tab':
isOpen.value = false
break
}
}
const scrollToFocused = () => {
nextTick(() => {
const list = optionsListRef.value
if (!list) return
const focusedEl = list.children[focusedIndex.value] as HTMLElement
if (!focusedEl) return
if (focusedEl.offsetTop < list.scrollTop) {
list.scrollTop = focusedEl.offsetTop
} else if (focusedEl.offsetTop + focusedEl.offsetHeight > list.scrollTop + list.offsetHeight) {
list.scrollTop = focusedEl.offsetTop + focusedEl.offsetHeight - list.offsetHeight
}
})
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
// Check if click is inside THIS specific instance's dropdown or trigger
const isInDropdown = !!target.closest(`.${instanceId}`)
const isInTrigger = containerRef.value?.contains(target)
if (!isInDropdown && !isInTrigger && isOpen.value) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
})
</script>
<style scoped>
.select-trigger {
@apply flex w-full items-center justify-between gap-2;
@apply rounded-xl px-4 py-2.5 text-sm;
@apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer;
}
.select-trigger-open {
@apply border-primary-500 ring-2 ring-primary-500/30;
}
.select-trigger-error {
@apply border-red-500 focus:border-red-500 focus:ring-red-500/30;
}
.select-trigger-disabled {
@apply cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900;
}
.select-value {
@apply flex-1 truncate text-left;
}
.select-icon {
@apply flex-shrink-0 text-gray-400 dark:text-dark-400;
}
</style>
<style>
.select-dropdown-portal {
@apply w-max min-w-[160px] max-w-[320px];
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
@apply overflow-hidden;
pointer-events: auto !important;
}
.select-dropdown-portal .select-search {
@apply flex items-center gap-2 px-3 py-2;
@apply border-b border-gray-100 dark:border-dark-700;
}
.select-dropdown-portal .select-search-input {
@apply flex-1 bg-transparent text-sm;
@apply text-gray-900 dark:text-gray-100;
@apply placeholder:text-gray-400 dark:placeholder:text-dark-400;
@apply focus:outline-none;
}
.select-dropdown-portal .select-options {
@apply max-h-60 overflow-y-auto py-1 outline-none;
}
.select-dropdown-portal .select-option {
@apply flex items-center justify-between gap-2;
@apply px-4 py-2.5 text-sm;
@apply text-gray-700 dark:text-gray-300;
@apply cursor-pointer transition-colors duration-150;
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
pointer-events: auto !important;
}
.select-dropdown-portal .select-option-selected {
@apply bg-primary-50 dark:bg-primary-900/20;
@apply text-primary-700 dark:text-primary-300;
}
.select-dropdown-portal .select-option-focused {
@apply bg-gray-100 dark:bg-dark-700;
}
.select-dropdown-portal .select-option-disabled {
@apply cursor-not-allowed opacity-40;
}
.select-dropdown-portal .select-option-group {
@apply cursor-default select-none;
@apply bg-gray-50 dark:bg-dark-900;
@apply text-[11px] font-bold uppercase tracking-wider;
@apply text-gray-500 dark:text-gray-400;
}
.select-dropdown-portal .select-option-group:hover {
@apply bg-gray-50 dark:bg-dark-900;
}
.select-dropdown-portal .select-option-label {
@apply flex-1 min-w-0 truncate text-left;
}
.select-dropdown-portal .select-empty {
@apply px-4 py-8 text-center text-sm;
@apply text-gray-500 dark:text-dark-400;
}
.select-dropdown-enter-active,
.select-dropdown-leave-active {
transition: all 0.2s ease;
}
.select-dropdown-enter-from,
.select-dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>