fix(frontend): comprehensive i18n cleanup and Select component hardening
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
Groups
|
||||
<span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
|
||||
{{ t('admin.users.groups') }}
|
||||
<span class="font-normal text-gray-400">{{ t('common.selectedCount', { count: modelValue.length }) }}</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800"
|
||||
@@ -32,7 +32,7 @@
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No groups available
|
||||
{{ t('common.noGroupsAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
<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">
|
||||
@@ -29,16 +35,19 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
|
||||
<!-- 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">
|
||||
@@ -66,12 +75,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Options list -->
|
||||
<div class="select-options">
|
||||
<div class="select-options" ref="optionsListRef">
|
||||
<div
|
||||
v-for="option in filteredOptions"
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
|
||||
@click.stop="selectOption(option)"
|
||||
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
:aria-disabled="isOptionDisabled(option)"
|
||||
@click.stop="!isOptionDisabled(option) && selectOption(option)"
|
||||
@mouseenter="focusedIndex = index"
|
||||
:class="[
|
||||
'select-option',
|
||||
isSelected(option) && 'select-option-selected',
|
||||
isOptionDisabled(option) && 'select-option-disabled',
|
||||
focusedIndex === index && 'select-option-focused'
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="isSelected(option)">
|
||||
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
|
||||
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
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
|
||||
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
labelKey: 'label'
|
||||
})
|
||||
|
||||
// Use computed for i18n default values
|
||||
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 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 {}
|
||||
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
|
||||
position: 'fixed',
|
||||
left: `${rect.left}px`,
|
||||
minWidth: `${rect.width}px`,
|
||||
zIndex: '100000020' // Higher than driver.js overlay (99999998)
|
||||
zIndex: '100000020'
|
||||
}
|
||||
|
||||
if (dropdownPosition.value === 'top') {
|
||||
style.bottom = `${window.innerHeight - rect.top + 8}px`
|
||||
style.bottom = `${window.innerHeight - rect.top + 4}px`
|
||||
} else {
|
||||
style.top = `${rect.bottom + 8}px`
|
||||
style.top = `${rect.bottom + 4}px`
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
const getOptionValue = (
|
||||
option: SelectOption | Record<string, unknown>
|
||||
): string | number | boolean | null | undefined => {
|
||||
const getOptionValue = (option: any): any => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option[props.valueKey] as string | number | boolean | null | undefined
|
||||
return option[props.valueKey]
|
||||
}
|
||||
return option as string | number | boolean | null
|
||||
return option
|
||||
}
|
||||
|
||||
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
||||
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 selectedOption = computed(() => {
|
||||
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
|
||||
})
|
||||
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!props.searchable || !searchQuery.value) {
|
||||
return props.options
|
||||
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))
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter((opt) => {
|
||||
const label = getOptionLabel(opt).toLowerCase()
|
||||
return label.includes(query)
|
||||
})
|
||||
return opts
|
||||
})
|
||||
|
||||
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
|
||||
const isSelected = (option: any): boolean => {
|
||||
return getOptionValue(option) === props.modelValue
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Update trigger rect for positioning
|
||||
triggerRect.value = containerRef.value.getBoundingClientRect()
|
||||
updateTriggerRect()
|
||||
|
||||
nextTick(() => {
|
||||
if (!containerRef.value || !dropdownRef.value) return
|
||||
if (!dropdownRef.value || !triggerRect.value) return
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight || 240
|
||||
const spaceBelow = window.innerHeight - triggerRect.value.bottom
|
||||
const spaceAbove = triggerRect.value.top
|
||||
|
||||
const rect = triggerRect.value!
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
// If not enough space below but enough space above, show dropdown on top
|
||||
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
|
||||
dropdownPosition.value = 'top'
|
||||
} else {
|
||||
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
calculateDropdownPosition()
|
||||
// Reset focused index to current selection or first item
|
||||
const selectedIdx = filteredOptions.value.findIndex(isSelected)
|
||||
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
|
||||
|
||||
if (props.searchable) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
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 selectOption = (option: SelectOption | Record<string, unknown>) => {
|
||||
const value = getOptionValue(option) ?? null
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, option as SelectOption)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
const onDropdownKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
focusedIndex.value = (focusedIndex.value + 1) % filteredOptions.value.length
|
||||
scrollToFocused()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
focusedIndex.value = (focusedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
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)
|
||||
|
||||
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
|
||||
if (target.closest('.select-dropdown-portal')) {
|
||||
return // 点击在下拉菜单内,不关闭
|
||||
}
|
||||
|
||||
// 检查是否点击在触发器内
|
||||
if (containerRef.value && containerRef.value.contains(target)) {
|
||||
return // 点击在触发器内,让 toggle 处理
|
||||
}
|
||||
|
||||
// 点击在外部,关闭下拉菜单
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
if (!isInDropdown && !isInTrigger && isOpen.value) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
|
||||
window.removeEventListener('resize', calculateDropdownPosition)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -339,16 +410,14 @@ onUnmounted(() => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Global styles for teleported dropdown -->
|
||||
<style>
|
||||
.select-dropdown-portal {
|
||||
@apply w-max max-w-[300px];
|
||||
@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;
|
||||
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
@@ -365,7 +434,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-options {
|
||||
@apply max-h-60 overflow-y-auto py-1;
|
||||
@apply max-h-60 overflow-y-auto py-1 outline-none;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option {
|
||||
@@ -374,7 +443,6 @@ onUnmounted(() => {
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -383,6 +451,14 @@ onUnmounted(() => {
|
||||
@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-label {
|
||||
@apply flex-1 min-w-0 truncate text-left;
|
||||
}
|
||||
@@ -392,7 +468,6 @@ onUnmounted(() => {
|
||||
@apply text-gray-500 dark:text-dark-400;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.select-dropdown-enter-active,
|
||||
.select-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
@@ -403,4 +478,4 @@ onUnmounted(() => {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user