diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts
index 8bb8503..0c287be 100644
--- a/packages/docs/.vitepress/config.mts
+++ b/packages/docs/.vitepress/config.mts
@@ -89,6 +89,7 @@ export default defineConfig({
text: '案例',
items: [
{ text: '基本用法', link: '/examples/basic-usage' },
+ { text: 'input 输入型', link: '/examples/input-type' },
{ text: '默认包含筛选项', link: '/examples/v-model' },
{ text: '自动匹配', link: '/examples/auto-match' },
{ text: '自定义二级下拉面板', link: '/examples/custom-panel' },
diff --git a/packages/docs/apis/types.md b/packages/docs/apis/types.md
index 41b4c92..fe630f9 100644
--- a/packages/docs/apis/types.md
+++ b/packages/docs/apis/types.md
@@ -30,7 +30,7 @@ interface ISearchBoxItem {
type ISearchBoxTagType =
| 'radio'
- | 'noValue'
+ | 'input'
| 'checkbox'
| 'map'
| 'numRange'
diff --git a/packages/docs/examples/input-type.md b/packages/docs/examples/input-type.md
new file mode 100644
index 0000000..df62a60
--- /dev/null
+++ b/packages/docs/examples/input-type.md
@@ -0,0 +1,5 @@
+## input 输入型
+
+`type: 'input'` 为输入型,等同于不设置 type。支持配置 options(展示二级面板选择)或不配置 options(直接输入值)。
+
+
diff --git a/packages/docs/guide/usage.md b/packages/docs/guide/usage.md
index 9066043..55aae4c 100644
--- a/packages/docs/guide/usage.md
+++ b/packages/docs/guide/usage.md
@@ -177,8 +177,8 @@ app.mount('#app')
组件支持多种搜索项类型:
- `radio` - 单选(默认)
+- `input` - 输入型(等同于不设置 type)
- `checkbox` - 多选
-- `noValue` - 无值类型
- `map` - 键值对类型
- `numRange` - 数字区间
- `dateRange` - 日期区间
diff --git a/packages/docs/search-box.js b/packages/docs/search-box.js
index e2471fa..b2c839d 100644
--- a/packages/docs/search-box.js
+++ b/packages/docs/search-box.js
@@ -268,7 +268,7 @@ interface ISearchBoxItem {
[propName: string]: any;
}
-type ISearchBoxTagType = 'radio' | 'noValue' | 'checkbox' | 'map' | 'numRange' | 'dateRange' | 'datetimeRange' | 'custom'; // custom类型为3.16.0新增
+type ISearchBoxTagType = 'radio' | 'input' | 'checkbox' | 'map' | 'numRange' | 'dateRange' | 'datetimeRange' | 'custom'; // custom类型为3.16.0新增
`
},
{
diff --git a/packages/docs/search-box/input-type.spec.ts b/packages/docs/search-box/input-type.spec.ts
new file mode 100644
index 0000000..f58da89
--- /dev/null
+++ b/packages/docs/search-box/input-type.spec.ts
@@ -0,0 +1,125 @@
+import { expect, test } from '@playwright/test'
+
+test('input 输入型 - 有 options 时走 radio 面板选择', async ({ page }) => {
+ page.on('pageerror', (exception) => expect(exception).toBeNull())
+ await page.goto('/examples/input-type')
+
+ const tags = page.locator('.tvp-search-box__tag')
+ const input = page.getByRole('textbox', { name: '选择属性筛选,或输入关键字搜索' })
+
+ // 打开一级面板,选择 input 类型且有 options 的字段
+ await input.click()
+ await page.locator('div').filter({ hasText: /^备注$/ }).nth(2).click()
+
+ // 应该展示 radio 风格的二级面板(second-level-panel 中 input 类型走 radio 分支)
+ await expect(page.locator('.tvp-search-box__radio-wrap')).toBeVisible()
+
+ // 选择一个选项,生成标签
+ await page.locator('li').filter({ hasText: 'normal' }).click()
+ await expect(tags).toHaveCount(1)
+ await expect(tags.last()).toHaveText('备注 : normal')
+ await expect(tags.last()).toHaveAttribute('title', '备注 : normal')
+})
+
+test('input 输入型 - 无 options 时不显示 loading(回归 hasBackupList 误判)', async ({ page }) => {
+ page.on('pageerror', (exception) => expect(exception).toBeNull())
+ await page.goto('/examples/input-type')
+
+ const input = page.getByRole('textbox', { name: '选择属性筛选,或输入关键字搜索' })
+
+ // 选择无 options 的 input 字段
+ await input.click()
+ await page.locator('div').filter({ hasText: /^描述$/ }).nth(2).click()
+
+ // 不应出现 loading 加载状态(hasBackupList 对无 options 的 input 应为 false)
+ await expect(page.locator('.tvp-search-box__loading-box')).not.toBeVisible()
+})
+
+test('input 输入型 - 无 options 时直接输入值', async ({ page }) => {
+ page.on('pageerror', (exception) => expect(exception).toBeNull())
+ await page.goto('/examples/input-type')
+
+ const tags = page.locator('.tvp-search-box__tag')
+ const input = page.getByRole('textbox', { name: '选择属性筛选,或输入关键字搜索' })
+
+ // 选择 input 类型且无 options 的字段
+ await input.click()
+ await page.locator('div').filter({ hasText: /^描述$/ }).nth(2).click()
+
+ // 直接输入值并回车
+ await page.getByRole('textbox', { name: '添加筛选条件' }).fill('hello')
+ await page.getByRole('textbox', { name: '添加筛选条件' }).press('Enter')
+ await expect(tags).toHaveCount(1)
+ await expect(tags.last()).toHaveText('描述 : hello')
+})
+
+test('input 输入型 - 搜索选项(isShowSearchOption)对 input 类型生效', async ({ page }) => {
+ page.on('pageerror', (exception) => expect(exception).toBeNull())
+ await page.goto('/examples/input-type')
+
+ const tags = page.locator('.tvp-search-box__tag')
+ const input = page.getByRole('textbox', { name: '选择属性筛选,或输入关键字搜索' })
+
+ // 选择有 options 的 input 字段
+ await input.click()
+ await page.locator('div').filter({ hasText: /^备注$/ }).nth(2).click()
+
+ // 输入值后应出现「搜索"xxx"」选项(first-level-panel isShowSearchOption 对 input 生效)
+ const searchInput = page.getByRole('textbox', { name: '添加筛选条件' })
+ await searchInput.fill('custom-value')
+ await expect(page.locator('.tvp-search-box__dropdown-item-init')).toBeVisible()
+
+ // 点击搜索选项生成标签
+ await page.locator('.tvp-search-box__dropdown-item-init').click()
+ await expect(tags).toHaveCount(1)
+ await expect(tags.last()).toHaveText('备注 : custom-value')
+})
+
+test('input 输入型 - 创建标签后下拉框关闭(回归 inputValue watch 删除)', async ({ page }) => {
+ page.on('pageerror', (exception) => expect(exception).toBeNull())
+ await page.goto('/examples/input-type')
+
+ const tags = page.locator('.tvp-search-box__tag')
+ const input = page.getByRole('textbox', { name: '选择属性筛选,或输入关键字搜索' })
+
+ // 选择有 options 的 input 字段,从二级面板选择后下拉框应关闭
+ await input.click()
+ await page.locator('div').filter({ hasText: /^备注$/ }).nth(2).click()
+ await page.locator('li').filter({ hasText: 'urgent' }).click()
+ await expect(tags).toHaveCount(1)
+
+ // 下拉框应该已关闭(visible 状态为 false)
+ await expect(page.locator('.tvp-search-box__radio-wrap')).not.toBeVisible()
+
+ // 再次选择无 options 的 input 字段,输入后回车,下拉框也应关闭
+ await page.getByRole('textbox', { name: '添加筛选条件' }).click()
+ await page.locator('div').filter({ hasText: /^描述$/ }).nth(2).click()
+ await page.getByRole('textbox', { name: '添加筛选条件' }).fill('test')
+ await page.getByRole('textbox', { name: '添加筛选条件' }).press('Enter')
+ await expect(tags).toHaveCount(2)
+ await expect(page.locator('.tvp-search-box__dropdown-item-init')).not.toBeVisible()
+})
+
+test('input 输入型 - radio 类型与 input 类型行为一致(有 options)', async ({ page }) => {
+ page.on('pageerror', (exception) => expect(exception).toBeNull())
+ await page.goto('/examples/input-type')
+
+ const tags = page.locator('.tvp-search-box__tag')
+ const input = page.getByRole('textbox', { name: '选择属性筛选,或输入关键字搜索' })
+
+ // 选择 radio 类型字段
+ await input.click()
+ await page.locator('div').filter({ hasText: /^单选字段$/ }).nth(2).click()
+ await expect(page.locator('.tvp-search-box__radio-wrap')).toBeVisible()
+ await page.locator('li').filter({ hasText: '选项A' }).click()
+ await expect(tags).toHaveCount(1)
+ await expect(tags.last()).toHaveText('单选字段 : 选项A')
+
+ // 选择 input 类型字段(有 options),应展示同样的 radio 面板
+ await page.getByRole('textbox', { name: '添加筛选条件' }).click()
+ await page.locator('div').filter({ hasText: /^备注$/ }).nth(2).click()
+ await expect(page.locator('.tvp-search-box__radio-wrap')).toBeVisible()
+ await page.locator('li').filter({ hasText: 'normal' }).click()
+ await expect(tags).toHaveCount(2)
+ await expect(tags.last()).toHaveText('备注 : normal')
+})
diff --git a/packages/docs/search-box/input-type.vue b/packages/docs/search-box/input-type.vue
new file mode 100644
index 0000000..ecd090f
--- /dev/null
+++ b/packages/docs/search-box/input-type.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/packages/search-box/__tests__/dropdown.spec.ts b/packages/search-box/__tests__/dropdown.spec.ts
new file mode 100644
index 0000000..c719d3d
--- /dev/null
+++ b/packages/search-box/__tests__/dropdown.spec.ts
@@ -0,0 +1,135 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { showDropdown, showPopover } from '../src/utils/dropdown'
+
+describe('showDropdown', () => {
+ let state
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ state = {
+ visible: false,
+ visibleTimer: null
+ }
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('isShow=true 且当前不可见时:设置定时器并在下一 tick 显示', () => {
+ showDropdown(state)
+
+ expect(state.visibleTimer).not.toBeNull()
+ expect(state.visible).toBe(false)
+
+ vi.advanceTimersByTime(1)
+
+ expect(state.visible).toBe(true)
+ })
+
+ it('isShow=true 且当前已可见时:不重复设置定时器', () => {
+ state.visible = true
+ const spy = vi.spyOn(globalThis, 'setTimeout')
+
+ showDropdown(state)
+
+ expect(state.visibleTimer).toBeNull()
+ expect(spy).not.toHaveBeenCalled()
+ expect(state.visible).toBe(true)
+
+ spy.mockRestore()
+ })
+
+ it('isShow=false 时:清除定时器并立即隐藏', () => {
+ // 先模拟一个已设置的定时器
+ const timerId = setTimeout(() => {}, 1000)
+ state.visibleTimer = timerId
+ state.visible = true
+
+ const spy = vi.spyOn(globalThis, 'clearTimeout')
+
+ showDropdown(state, false)
+
+ expect(spy).toHaveBeenCalledWith(timerId)
+ expect(state.visibleTimer).toBeNull()
+ expect(state.visible).toBe(false)
+
+ spy.mockRestore()
+ })
+
+ it('连续调用 isShow=true 不产生多个定时器(回归:避免重复 setTimeout)', () => {
+ showDropdown(state)
+ const firstTimer = state.visibleTimer
+ expect(firstTimer).not.toBeNull()
+
+ // 不推进时间,再次调用
+ showDropdown(state)
+ // 之前的定时器应被清除
+ expect(state.visibleTimer).not.toBe(firstTimer)
+ // visible 仍为 false(定时器还没触发)
+ expect(state.visible).toBe(false)
+
+ vi.advanceTimersByTime(1)
+ expect(state.visible).toBe(true)
+ })
+
+ it('先 show 后 hide:清除未触发的定时器并隐藏', () => {
+ showDropdown(state) // 设置定时器
+ expect(state.visibleTimer).not.toBeNull()
+
+ showDropdown(state, false) // 在定时器触发前隐藏
+ expect(state.visibleTimer).toBeNull()
+ expect(state.visible).toBe(false)
+
+ // 推进时间后 visible 仍为 false(定时器已被清除)
+ vi.advanceTimersByTime(10)
+ expect(state.visible).toBe(false)
+ })
+})
+
+describe('showPopover', () => {
+ it('state.instance 为空时不执行任何操作', () => {
+ const state = { instance: null }
+ expect(() => showPopover(state, true)).not.toThrow()
+ })
+
+ it('isShow=true 且 dropdownRef 可见时:关闭 dropdownRef', () => {
+ const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
+ const state = {
+ instance: {
+ dropdownRef: {
+ state: {
+ visible: true,
+ timeout: setTimeout(() => {}, 1000)
+ }
+ }
+ },
+ popoverVisible: false
+ }
+
+ showPopover(state, true)
+
+ expect(state.popoverVisible).toBe(true)
+ expect(state.instance.dropdownRef.state.visible).toBe(false)
+ expect(state.instance.dropdownRef.state.timeout).toBeNull()
+
+ clearTimeoutSpy.mockRestore()
+ })
+
+ it('isShow=false 时:设置 popoverVisible 为 false', () => {
+ const state = {
+ instance: {
+ dropdownRef: {
+ state: {
+ visible: false,
+ timeout: null
+ }
+ }
+ },
+ popoverVisible: true
+ }
+
+ showPopover(state, false)
+ expect(state.popoverVisible).toBe(false)
+ })
+})
diff --git a/packages/search-box/package.json b/packages/search-box/package.json
index 4480ca0..412f1f7 100644
--- a/packages/search-box/package.json
+++ b/packages/search-box/package.json
@@ -1,6 +1,6 @@
{
"name": "@opentiny/vue-search-box",
- "version": "3.29.0",
+ "version": "3.29.1",
"description": "",
"homepage": "https://github.com/opentiny/tiny-search-box#readme",
"bugs": {
@@ -30,8 +30,8 @@
"@opentiny/vue-common": "~2.28.0",
"@opentiny/vue-icon": "~2.28.0",
"@opentiny/vue": "^2.28.0",
- "@opentiny/vue-theme": "^3.28.0",
- "@opentiny/vue-theme-saas": "^3.28.0"
+ "@opentiny/vue-theme": "^3.29.0",
+ "@opentiny/vue-theme-saas": "^3.29.0"
},
"scripts": {
"build:all": "pnpm build:vue2 && pnpm build:vue2-saas && pnpm build:vue3 && pnpm build:vue3-saas",
@@ -40,6 +40,7 @@
"build:vue2-saas": "vite build --config vite.config.vue2-saas.ts && node scripts/post-build.js vue2-saas",
"build:vue3": "vite build --config vite.config.vue3.ts && node scripts/post-build.js vue3",
"build:vue3-saas": "vite build --config vite.config.vue3-saas.ts && node scripts/post-build.js vue3-saas",
+ "test": "vitest run",
"build:aurora": "pnpm build:all && node scripts/releaseAurora.js",
"release:aurora": "node scripts/releaseAurora.js",
"publish:vue2": "node scripts/publish.js vue2",
@@ -57,6 +58,7 @@
"tailwindcss": "^3.4.14",
"typescript": "^5.8.2",
"vite": "^4.2.0",
+ "vitest": "^1.6.0",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-vue2": "^2.0.2",
"vue-template-compiler": "2.6.14",
diff --git a/packages/search-box/src/components/first-level-panel.vue b/packages/search-box/src/components/first-level-panel.vue
index bd46158..68627f8 100644
--- a/packages/search-box/src/components/first-level-panel.vue
+++ b/packages/search-box/src/components/first-level-panel.vue
@@ -138,7 +138,7 @@ const renderless = (props, hooks, { emit }) => {
inputValue.trim() &&
!popoverVisible &&
!currentOperators?.length &&
- (!propItem.label || [undefined, 'radio'].includes(prevItem.type))
+ (!propItem.label || [undefined, null, '', 'radio', 'input'].includes(prevItem.type))
)
})
diff --git a/packages/search-box/src/components/second-level-panel.vue b/packages/search-box/src/components/second-level-panel.vue
index 23ac008..c9c9caa 100644
--- a/packages/search-box/src/components/second-level-panel.vue
+++ b/packages/search-box/src/components/second-level-panel.vue
@@ -16,7 +16,7 @@
visible: false,
visibleTimer: null,
hasFormError: false, // 表单校验错误状态
- hasBackupList: computed(() => state.propItem.label && [undefined, 'radio', 'checkbox', 'map'].includes(state.prevItem.type)),
+ hasBackupList: computed(() => {
+ if (!state.propItem.label) return false
+ const { type, options } = state.prevItem
+ // input 类型需要 options 才算有 backupList,避免无 options 时卡在 loading
+ if (type === 'input') return !!options?.length
+ return [undefined, null, '', 'radio', 'checkbox', 'map'].includes(type)
+ }),
isIndeterminate: computed(() => state.checkboxGroup.length > 0 && state.checkboxGroup.length !== state.filterList.length),
checkAll: computed({
get: () => state.checkboxGroup.length && state.checkboxGroup.length === state.filterList.length,
@@ -362,18 +368,6 @@ const initWatch = ({ watch, state, props, api, nextTick, vm }) => {
}
)
- watch(
- () => state.inputValue,
- (newVal) => {
- if (!newVal && !state.propItem.type) {
- state.visible = false
- }
- },
- {
- immediate: true
- }
- )
-
watch(
() => state.popoverVisible,
(newVal) => {
diff --git a/packages/search-box/src/utils/dropdown.ts b/packages/search-box/src/utils/dropdown.ts
index f7878cd..c3ed3dd 100644
--- a/packages/search-box/src/utils/dropdown.ts
+++ b/packages/search-box/src/utils/dropdown.ts
@@ -4,13 +4,15 @@
* @param isShow 是否展示下拉框, 默认展示
*/
export const showDropdown = (state, isShow = true) => {
+ clearTimeout(state.visibleTimer)
+ state.visibleTimer = null
if (isShow) {
- state.visibleTimer = setTimeout(() => {
- state.visible = true
- }, 0)
+ if (!state.visible) {
+ state.visibleTimer = setTimeout(() => {
+ state.visible = true
+ }, 0)
+ }
} else {
- clearTimeout(state.visibleTimer)
- state.visibleTimer = null
state.visible = false
}
}