feat(ui): Select 组件支持可选远程搜索(remote/loading props + search 事件)

- remote 模式始终显示搜索框且不做本地过滤,输入 300ms 防抖后 emit('search')
- 关闭/卸载时取消排队中的防抖定时器,避免尾随空 query
- loading 时空态显示加载文案;未设 remote 时行为不变(回归用例保障)
This commit is contained in:
Randark
2026-08-18 09:14:31 +00:00
parent 22df600d09
commit 03c3f3b6fc
2 changed files with 140 additions and 3 deletions
+36 -3
View File
@@ -111,7 +111,7 @@
<!-- Empty state -->
<div v-if="filteredOptions.length === 0" class="select-empty">
{{ emptyTextDisplay }}
{{ props.loading ? t('common.loading') : emptyTextDisplay }}
</div>
</div>
</div>
@@ -154,11 +154,16 @@ interface Props {
id?: string
ariaLabel?: string
ariaDescribedby?: string
/** 远程搜索模式:输入不在本地过滤 options,而是防抖后 emit('search', query),由父组件请求数据更新 options */
remote?: boolean
/** 远程搜索模式下的加载态:options 为空时下拉显示 loading 文案 */
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string | number | boolean | null): void
(e: 'change', value: string | number | boolean | null, option: SelectOption | null): void
(e: 'search', query: string): void
}
const props = withDefaults(defineProps<Props>(), {
@@ -169,7 +174,9 @@ const props = withDefaults(defineProps<Props>(), {
creatablePrefix: '',
clearable: false,
valueKey: 'value',
labelKey: 'label'
labelKey: 'label',
remote: false,
loading: false
})
const emit = defineEmits<Emits>()
@@ -192,7 +199,13 @@ const placeholderText = computed(() => props.placeholder ?? t('common.selectOpti
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
// 远程搜索的防抖间隔(对齐 OpenAIFastPolicyUserSelector 的 300ms 惯例)。
const REMOTE_SEARCH_DEBOUNCE_MS = 300
let remoteSearchTimer: ReturnType<typeof setTimeout> | null = null
const isSearchable = computed(() => {
// 远程搜索模式始终显示搜索框(选项只是服务端结果的一页)。
if (props.remote) return true
if (props.searchable === 'auto') return props.options.length > 5
return props.searchable
})
@@ -276,7 +289,8 @@ const hasValue = computed(
const filteredOptions = computed(() => {
let opts = props.options as any[]
if (isSearchable.value && searchQuery.value) {
// 远程搜索模式不在本地过滤(选项即服务端搜索结果的一页)。
if (isSearchable.value && searchQuery.value && !props.remote) {
const query = searchQuery.value.toLowerCase()
opts = opts.filter((opt) => {
// Match label
@@ -377,11 +391,26 @@ watch(isOpen, (open) => {
} else {
searchQuery.value = ''
focusedIndex.value = -1
// 关闭时取消仍在排队的远程搜索(避免关闭后尾随 emit 一次 search(''))。
if (remoteSearchTimer) {
clearTimeout(remoteSearchTimer)
remoteSearchTimer = null
}
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
}
})
// 远程搜索:输入防抖后交给父组件请求(!isOpen 抑制关闭重置 searchQuery 触发的空 query)。
watch(searchQuery, (query) => {
if (!props.remote || !isOpen.value) return
if (remoteSearchTimer) clearTimeout(remoteSearchTimer)
remoteSearchTimer = setTimeout(() => {
remoteSearchTimer = null
emit('search', query.trim())
}, REMOTE_SEARCH_DEBOUNCE_MS)
})
const selectOption = (option: any) => {
const value = getOptionValue(option) ?? null
emit('update:modelValue', value)
@@ -467,6 +496,10 @@ onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
window.removeEventListener('resize', calculateDropdownPosition)
if (remoteSearchTimer) {
clearTimeout(remoteSearchTimer)
remoteSearchTimer = null
}
})
</script>
@@ -61,6 +61,7 @@ afterEach(() => {
unmountWrapper = undefined
document.body.innerHTML = ''
setViewportWidth(originalInnerWidth)
vi.useRealTimers()
vi.restoreAllMocks()
})
@@ -113,3 +114,106 @@ describe('Select dropdown viewport constraints', () => {
expect(dropdown?.style.maxWidth).toBe('0px')
})
})
describe('Select remote search', () => {
const mountRemoteSelect = (props: Record<string, unknown> = {}) => {
const wrapper = mount(Select, {
props: {
modelValue: null,
remote: true,
options: [
{ value: 'alpha', label: 'Alpha account' },
{ value: 'beta', label: 'Beta account' },
],
...props,
},
})
unmountWrapper = () => wrapper.unmount()
return wrapper
}
const openDropdown = async () => {
const dropdown = document.body.querySelector<HTMLElement>('.select-dropdown-portal')
expect(dropdown).not.toBeNull()
return dropdown as HTMLElement
}
const typeSearchQuery = async (query: string) => {
const dropdown = await openDropdown()
const input = dropdown.querySelector<HTMLInputElement>('.select-search-input')
expect(input).not.toBeNull()
input!.value = query
input!.dispatchEvent(new Event('input'))
await nextTick()
}
it('emits debounced search events and skips local filtering in remote mode', async () => {
vi.useFakeTimers()
const wrapper = mountRemoteSelect()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('zzz')
// 防抖窗口内不触发。
expect(wrapper.emitted('search')).toBeUndefined()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toEqual([['zzz']])
// 远程模式不做本地过滤:无命中的 query 下选项仍完整展示(由父组件更新 options)。
const dropdown = await openDropdown()
const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent)
expect(labels).toContain('Alpha account')
expect(labels).toContain('Beta account')
})
it('does not emit search when the dropdown closes and the query resets', async () => {
vi.useFakeTimers()
const wrapper = mountRemoteSelect()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('hidden')
// 关闭下拉:排队中的防抖定时器应被取消,也不应因 query 重置而尾随 emit。
await wrapper.get('button').trigger('click')
await nextTick()
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toBeUndefined()
})
it('shows the loading text instead of empty text while loading with no options', async () => {
const wrapper = mountRemoteSelect({ options: [], loading: true })
await wrapper.get('button').trigger('click')
await nextTick()
const dropdown = await openDropdown()
expect(dropdown.querySelector('.select-empty')?.textContent).toContain('common.loading')
})
it('keeps local filtering and emits nothing when remote is not set', async () => {
vi.useFakeTimers()
const wrapper = mount(Select, {
props: {
modelValue: null,
searchable: true,
options: [
{ value: 'alpha', label: 'Alpha account' },
{ value: 'beta', label: 'Beta account' },
],
},
})
unmountWrapper = () => wrapper.unmount()
await wrapper.get('button').trigger('click')
await nextTick()
await typeSearchQuery('alpha')
await vi.advanceTimersByTimeAsync(300)
expect(wrapper.emitted('search')).toBeUndefined()
const dropdown = await openDropdown()
const labels = [...dropdown.querySelectorAll('.select-option-label')].map((el) => el.textContent)
expect(labels).toEqual(['Alpha account'])
})
})