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 } }