54
54
@after-leave =" $emit('after-close')" >
55
55
<div v-show =" isOpen"
56
56
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 "
58
58
:class =" dropdownClasses"
59
59
:style =" dropdownStyle"
60
60
role =" listbox"
61
61
:aria-labelledby =" buttonId" >
62
+
62
63
<!-- 搜索框 (可选) -->
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 " >
64
65
<input v-model =" searchQuery"
65
66
type =" text"
66
67
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"
67
68
:placeholder =" searchPlaceholder"
68
69
@click.stop
70
+ @keydown.stop
69
71
ref =" searchInput" />
70
72
</div >
71
73
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 }}
104
114
</div >
105
- </template >
106
-
107
- <!-- 无选项提示 -->
108
- <div v-else class =" px-3 py-2 text-gray-500 text-sm" >
109
- {{ noOptionsText }}
110
115
</div >
111
116
</div >
112
117
</Transition >
@@ -229,28 +234,57 @@ const updateDropdownPosition = async () => {
229
234
230
235
const buttonRect = selectButton .value .getBoundingClientRect ()
231
236
const viewportHeight = window .innerHeight
237
+ const viewportWidth = window .innerWidth
232
238
233
239
// 计算下方可用空间
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边距
236
242
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
240
249
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
+ }
245
278
}
279
+ }
246
280
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 )
253
286
}
287
+ updateTimer = window .setTimeout (updateDropdownPosition , 10 )
254
288
}
255
289
256
290
// 方法
@@ -287,9 +321,9 @@ const openDropdown = async () => {
287
321
// 更新位置
288
322
await updateDropdownPosition ()
289
323
290
- // 监听滚动和窗口大小变化
291
- window .addEventListener (' scroll' , updateDropdownPosition , true )
292
- window .addEventListener (' resize' , updateDropdownPosition )
324
+ // 监听滚动和窗口大小变化,使用防抖
325
+ window .addEventListener (' scroll' , debouncedUpdatePosition , true )
326
+ window .addEventListener (' resize' , debouncedUpdatePosition )
293
327
294
328
if (props .searchable ) {
295
329
nextTick (() => {
@@ -305,9 +339,15 @@ const closeDropdown = () => {
305
339
searchQuery .value = ' '
306
340
highlightedIndex .value = - 1
307
341
342
+ // 清理定时器
343
+ if (updateTimer ) {
344
+ clearTimeout (updateTimer )
345
+ updateTimer = null
346
+ }
347
+
308
348
// 移除监听器
309
- window .removeEventListener (' scroll' , updateDropdownPosition , true )
310
- window .removeEventListener (' resize' , updateDropdownPosition )
349
+ window .removeEventListener (' scroll' , debouncedUpdatePosition , true )
350
+ window .removeEventListener (' resize' , debouncedUpdatePosition )
311
351
}
312
352
313
353
const selectOption = (option : Option ) => {
@@ -382,9 +422,14 @@ onMounted(() => {
382
422
})
383
423
384
424
onUnmounted (() => {
425
+ // 清理定时器
426
+ if (updateTimer ) {
427
+ clearTimeout (updateTimer )
428
+ }
429
+
385
430
document .removeEventListener (' click' , handleClickOutside )
386
431
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 )
389
434
})
390
435
</script >
0 commit comments