Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/apis/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface ISearchBoxItem {

type ISearchBoxTagType =
| 'radio'
| 'noValue'
| 'input'
| 'checkbox'
| 'map'
| 'numRange'
Expand Down
5 changes: 5 additions & 0 deletions packages/docs/examples/input-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## input 输入型

`type: 'input'` 为输入型,等同于不设置 type。支持配置 options(展示二级面板选择)或不配置 options(直接输入值)。

<preview path="../search-box/input-type.vue"></preview>
2 changes: 1 addition & 1 deletion packages/docs/guide/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ app.mount('#app')
组件支持多种搜索项类型:

- `radio` - 单选(默认)
- `input` - 输入型(等同于不设置 type)
- `checkbox` - 多选
- `noValue` - 无值类型
- `map` - 键值对类型
- `numRange` - 数字区间
- `dateRange` - 日期区间
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/search-box.js
Original file line number Diff line number Diff line change
Expand Up @@ -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新增
`
},
{
Expand Down
125 changes: 125 additions & 0 deletions packages/docs/search-box/input-type.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
36 changes: 36 additions & 0 deletions packages/docs/search-box/input-type.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<template>
<tiny-search-box v-model="tags" :items="items" />
</template>

<script setup lang="ts">
import { ref } from "vue";

const tags = ref([]);

const items = ref([
{
label: '备注',
field: 'remark',
type: 'input',
options: [
{ label: ' urgent' },
{ label: 'normal' },
{ label: 'low' }
]
},
{
label: '描述',
field: 'description',
type: 'input'
},
{
label: '单选字段',
field: 'radioField',
type: 'radio',
options: [
{ label: '选项A' },
{ label: '选项B' }
]
}
]);
</script>
135 changes: 135 additions & 0 deletions packages/search-box/__tests__/dropdown.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
8 changes: 5 additions & 3 deletions packages/search-box/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/search-box/src/components/first-level-panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
})

Expand Down
2 changes: 1 addition & 1 deletion packages/search-box/src/components/second-level-panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
</tiny-dropdown-item>
</div>
<div
v-else-if="!state.prevItem.type || state.prevItem.type === 'radio'"
v-else-if="!state.prevItem.type || ['radio', 'input'].includes(state.prevItem.type)"
v-loading="isLoading"
:class="['tvp-search-box__radio-wrap', 'tvp-search-box__virtual-list', isLoading && 'tvp-search-box__loading-box']"
:style="{ maxHeight: panelMaxHeight }"
Expand Down
Loading
Loading