| <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 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" |
| > |
| |
| <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> |
| |
| |
| <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)"> |
| <Icon |
| v-if="option._creatable" |
| name="search" |
| size="sm" |
| class="flex-shrink-0 text-gray-400" |
| /> |
| <span class="select-option-label" :class="option._creatable && 'italic text-gray-500 dark:text-dark-300'">{{ getOptionLabel(option) }}</span> |
| <Icon |
| v-if="isSelected(option)" |
| name="check" |
| size="sm" |
| class="text-primary-500" |
| :stroke-width="2" |
| /> |
| </slot> |
| </div> |
| |
| |
| <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() |
| |
| |
| 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 |
| creatable?: boolean |
| creatablePrefix?: 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, |
| creatable: false, |
| creatablePrefix: '', |
| 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) |
| |
| |
| const placeholderText = computed(() => props.placeholder ?? t('common.selectOption')) |
| const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder')) |
| const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound')) |
| |
| |
| 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) |
| } |
| |
| if (props.creatable && props.modelValue) { |
| return String(props.modelValue) |
| } |
| 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) => { |
| |
| if (getOptionLabel(opt).toLowerCase().includes(query)) return true |
| |
| if (opt.description && String(opt.description).toLowerCase().includes(query)) return true |
| return false |
| }) |
| |
| if (props.creatable && searchQuery.value.trim()) { |
| const trimmed = searchQuery.value.trim() |
| const prefix = props.creatablePrefix || t('common.search') |
| opts = [{ [props.valueKey]: trimmed, [props.labelKey]: `${prefix} "${trimmed}"`, _creatable: true }, ...opts] |
| } |
| } |
| 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 |
| } |
| |
| |
| 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() |
| |
| 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()) |
| } |
| |
| 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() |
| } |
| |
| |
| 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 |
| |
| 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-[200px]; |
| @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> |
| |