feat(ui): Select 组件支持可选远程搜索(remote/loading props + search 事件)
- remote 模式始终显示搜索框且不做本地过滤,输入 300ms 防抖后 emit('search')
- 关闭/卸载时取消排队中的防抖定时器,避免尾随空 query
- loading 时空态显示加载文案;未设 remote 时行为不变(回归用例保障)
This commit is contained in:
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user