Skip to content

Commit 8640ae3

Browse files
committed
fix (ui): 修复 Select 组件开启筛选导致内容重叠
1 parent 4971b12 commit 8640ae3

File tree

2 files changed

+107
-61
lines changed

2 files changed

+107
-61
lines changed

src/components/AppHeader.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<div class="flex items-center space-x-3">
44
<Select v-model="selectedLanguage"
55
class="w-48"
6+
searchable
67
:options="supportedLanguages as any"
78
:disabled="isRunning"
89
placeholder="选择语言"

src/ui/Select.vue

Lines changed: 106 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -54,59 +54,64 @@
5454
@after-leave="$emit('after-close')">
5555
<div v-show="isOpen"
5656
ref="dropdown"
57-
class="fixed z-[99999] max-h-60 overflow-auto rounded-md bg-white text-base shadow-lg ring-1 ring-blue-200 focus:outline-none"
57+
class="fixed z-[99999] bg-white text-base shadow-lg ring-1 ring-blue-200 focus:outline-none rounded-md overflow-hidden"
5858
:class="dropdownClasses"
5959
:style="dropdownStyle"
6060
role="listbox"
6161
:aria-labelledby="buttonId">
62+
6263
<!-- 搜索框 (可选) -->
63-
<div v-if="searchable" class="sticky top-0 bg-white p-2 border-b border-gray-100">
64+
<div v-if="searchable" class="p-2 border-b border-gray-100 bg-white">
6465
<input v-model="searchQuery"
6566
type="text"
6667
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
6768
:placeholder="searchPlaceholder"
6869
@click.stop
70+
@keydown.stop
6971
ref="searchInput"/>
7072
</div>
7173

72-
<!-- 选项列表 -->
73-
<template v-if="filteredOptions.length > 0">
74-
<div v-for="(option, index) in filteredOptions"
75-
:key="getOptionValue(option)"
76-
@click="selectOption(option)"
77-
@keydown.enter.prevent="selectOption(option)"
78-
@keydown.space.prevent="selectOption(option)"
79-
:class="[
80-
'relative flex space-x-3 items-center cursor-pointer select-none py-1 my-1 pl-3 pr-9 transition-colors duration-150',
81-
isSelected(option)
82-
? 'bg-blue-400 text-white'
83-
: 'text-gray-900 hover:bg-blue-50',
84-
highlightedIndex === index ? 'bg-blue-100' : ''
85-
]"
86-
:aria-selected="isSelected(option)"
87-
role="option"
88-
tabindex="-1">
89-
<!-- 如果是SVG字符串 -->
90-
<div v-if="getSvgIcon(option)" v-html="getSvgIcon(option)" class="w-6 h-6"/>
91-
92-
<!-- 如果是SVG URL -->
93-
<img v-else-if="getSvgUrl(option)" :src="getSvgUrl(option)" class="w-6 h-6" alt="icon"/>
94-
95-
<span :class="['block truncate', isSelected(option) ? 'font-medium' : 'font-normal']">
96-
{{ getOptionLabel(option) }}
97-
</span>
98-
99-
<!-- 选中图标 -->
100-
<span v-if="isSelected(option)"
101-
class="absolute inset-y-0 right-0 flex items-center pr-2">
102-
<CheckIcon class="h-5 w-5" aria-hidden="true"/>
103-
</span>
74+
<!-- 选项列表容器 -->
75+
<div class="max-h-60 overflow-auto" @scroll.stop>
76+
<!-- 选项列表 -->
77+
<template v-if="filteredOptions.length > 0">
78+
<div v-for="(option, index) in filteredOptions"
79+
:key="getOptionValue(option)"
80+
@click="selectOption(option)"
81+
@keydown.enter.prevent="selectOption(option)"
82+
@keydown.space.prevent="selectOption(option)"
83+
:class="[
84+
'relative flex space-x-3 items-center cursor-pointer select-none py-2 px-3 transition-colors duration-150',
85+
isSelected(option)
86+
? 'bg-blue-400 text-white'
87+
: 'text-gray-900 hover:bg-blue-50',
88+
highlightedIndex === index ? 'bg-blue-100' : ''
89+
]"
90+
:aria-selected="isSelected(option)"
91+
role="option"
92+
tabindex="-1">
93+
<!-- 如果是SVG字符串 -->
94+
<div v-if="getSvgIcon(option)" v-html="getSvgIcon(option)" class="w-6 h-6 flex-shrink-0"/>
95+
96+
<!-- 如果是SVG URL -->
97+
<img v-else-if="getSvgUrl(option)" :src="getSvgUrl(option)" class="w-6 h-6 flex-shrink-0" alt="icon"/>
98+
99+
<span :class="['block truncate flex-1', isSelected(option) ? 'font-medium' : 'font-normal']">
100+
{{ getOptionLabel(option) }}
101+
</span>
102+
103+
<!-- 选中图标 -->
104+
<span v-if="isSelected(option)"
105+
class="flex-shrink-0 ml-2">
106+
<CheckIcon class="h-5 w-5" aria-hidden="true"/>
107+
</span>
108+
</div>
109+
</template>
110+
111+
<!-- 无选项提示 -->
112+
<div v-else class="px-3 py-2 text-gray-500 text-sm">
113+
{{ noOptionsText }}
104114
</div>
105-
</template>
106-
107-
<!-- 无选项提示 -->
108-
<div v-else class="px-3 py-2 text-gray-500 text-sm">
109-
{{ noOptionsText }}
110115
</div>
111116
</div>
112117
</Transition>
@@ -229,28 +234,57 @@ const updateDropdownPosition = async () => {
229234
230235
const buttonRect = selectButton.value.getBoundingClientRect()
231236
const viewportHeight = window.innerHeight
237+
const viewportWidth = window.innerWidth
232238
233239
// 计算下方可用空间
234-
const spaceBelow = viewportHeight - buttonRect.bottom
235-
const dropdownHeight = 240 // max-h-60 对应约240px
240+
const spaceBelow = viewportHeight - buttonRect.bottom - 10 // 留10px边距
241+
const spaceAbove = buttonRect.top - 10 // 留10px边距
236242
237-
let top = buttonRect.bottom + 2 // 默认显示在下方
238-
let left = buttonRect.left
239-
let width = buttonRect.width
243+
// 搜索框高度(如果启用)
244+
const searchBoxHeight = props.searchable ? 60 : 0
245+
246+
// 基础下拉框高度
247+
const baseDropdownHeight = 240 // max-h-60 对应约240px
248+
const totalDropdownHeight = baseDropdownHeight + searchBoxHeight
240249
241-
// 如果下方空间不足,显示在上方
242-
if (spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight) {
243-
// 显示在上方时,让下拉框紧贴按钮顶部
244-
top = buttonRect.top - 2
250+
let top = buttonRect.bottom + 4 // 默认显示在下方,留4px间距
251+
let left = Math.max(10, Math.min(buttonRect.left, viewportWidth - buttonRect.width - 10)) // 确保不超出视口
252+
let width = buttonRect.width
253+
let maxHeight = Math.min(totalDropdownHeight, spaceBelow)
254+
255+
// 如果下方空间不足且上方空间更大,显示在上方
256+
if (spaceBelow < 150 && spaceAbove > spaceBelow) {
257+
top = buttonRect.top - 4 // 显示在上方,留4px间距
258+
maxHeight = Math.min(totalDropdownHeight, spaceAbove)
259+
260+
dropdownStyle.value = {
261+
top: `${ top }px`,
262+
left: `${ left }px`,
263+
width: `${ width }px`,
264+
minWidth: `${ width }px`,
265+
maxHeight: `${ maxHeight }px`,
266+
transform: 'translateY(-100%)'
267+
}
268+
}
269+
else {
270+
dropdownStyle.value = {
271+
top: `${ top }px`,
272+
left: `${ left }px`,
273+
width: `${ width }px`,
274+
minWidth: `${ width }px`,
275+
maxHeight: `${ maxHeight }px`,
276+
transform: 'none'
277+
}
245278
}
279+
}
246280
247-
dropdownStyle.value = {
248-
top: `${ top }px`,
249-
left: `${ left }px`,
250-
width: `${ width }px`,
251-
minWidth: `${ width }px`,
252-
transform: spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight ? 'translateY(-100%)' : 'none'
281+
// 防抖更新位置
282+
let updateTimer: number | null = null
283+
const debouncedUpdatePosition = () => {
284+
if (updateTimer) {
285+
clearTimeout(updateTimer)
253286
}
287+
updateTimer = window.setTimeout(updateDropdownPosition, 10)
254288
}
255289
256290
// 方法
@@ -287,9 +321,9 @@ const openDropdown = async () => {
287321
// 更新位置
288322
await updateDropdownPosition()
289323
290-
// 监听滚动和窗口大小变化
291-
window.addEventListener('scroll', updateDropdownPosition, true)
292-
window.addEventListener('resize', updateDropdownPosition)
324+
// 监听滚动和窗口大小变化,使用防抖
325+
window.addEventListener('scroll', debouncedUpdatePosition, true)
326+
window.addEventListener('resize', debouncedUpdatePosition)
293327
294328
if (props.searchable) {
295329
nextTick(() => {
@@ -305,9 +339,15 @@ const closeDropdown = () => {
305339
searchQuery.value = ''
306340
highlightedIndex.value = -1
307341
342+
// 清理定时器
343+
if (updateTimer) {
344+
clearTimeout(updateTimer)
345+
updateTimer = null
346+
}
347+
308348
// 移除监听器
309-
window.removeEventListener('scroll', updateDropdownPosition, true)
310-
window.removeEventListener('resize', updateDropdownPosition)
349+
window.removeEventListener('scroll', debouncedUpdatePosition, true)
350+
window.removeEventListener('resize', debouncedUpdatePosition)
311351
}
312352
313353
const selectOption = (option: Option) => {
@@ -382,9 +422,14 @@ onMounted(() => {
382422
})
383423
384424
onUnmounted(() => {
425+
// 清理定时器
426+
if (updateTimer) {
427+
clearTimeout(updateTimer)
428+
}
429+
385430
document.removeEventListener('click', handleClickOutside)
386431
document.removeEventListener('keydown', handleKeydown)
387-
window.removeEventListener('scroll', updateDropdownPosition, true)
388-
window.removeEventListener('resize', updateDropdownPosition)
432+
window.removeEventListener('scroll', debouncedUpdatePosition, true)
433+
window.removeEventListener('resize', debouncedUpdatePosition)
389434
})
390435
</script>

0 commit comments

Comments
 (0)