diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts index f245671..a8ed61d 100644 --- a/packages/docs/.vitepress/config.mts +++ b/packages/docs/.vitepress/config.mts @@ -103,6 +103,7 @@ export default defineConfig({ { text: '时间长度限制', link: '/examples/max-time-length' }, { text: '合并多选标签', link: '/examples/merge-tag' }, { text: '面板最大高度', link: '/examples/panel-max-height' }, + { text: '虚拟滚动', link: '/examples/virtual-scroll' }, { text: '潜在匹配项', link: '/examples/potential-match' }, { text: '自定义默认搜索项', link: '/examples/default-field' }, { text: '切分输入值', link: '/examples/split-input-value' }, diff --git a/packages/docs/apis/props.md b/packages/docs/apis/props.md index 2efbdc3..c069d83 100644 --- a/packages/docs/apis/props.md +++ b/packages/docs/apis/props.md @@ -10,7 +10,7 @@ | items | [ISearchBoxItem[]](types.md#isearchboxitem) | [] | 数据项 | | maxlength | number | -- | input 元素的原生属性,限制输入框的长度,可配合 exceed 监听输入超出限定长度的事件 | | model-value/v-model | [ISearchBoxTag[]](types.md#isearchboxtag) | [] | 选中的标签列表 | -| panel-max-height | string | 999px | 设置下拉面板最大高度 | +| panel-max-height | string | 999px | 设置下拉面板最大高度。当数据量较大时,组件内置虚拟滚动会以该高度作为可视区域,只渲染可见选项节点,保证流畅滚动 | | append-to-body | boolean | true | 是否将下拉面板/弹窗挂载到 `body`,用于父容器有 `overflow` 时避免被裁剪 | | potential-options | getMatchList: (arg1: string) => [ISearchBoxMatchItem[]](types.md#isearchboxmatchitem) | {} | 潜在项匹配,接口返回潜在匹配项的数据列表,异步或同步皆可 | | show-help | boolean | true | 是否显示帮助图标。3.14.0 及以上版本默认显示;低于此版本默认隐藏 | diff --git a/packages/docs/examples/virtual-scroll.md b/packages/docs/examples/virtual-scroll.md new file mode 100644 index 0000000..95abfd6 --- /dev/null +++ b/packages/docs/examples/virtual-scroll.md @@ -0,0 +1,18 @@ +## 虚拟滚动 + +当数据选项数量较多时(如上千条),二级面板内置虚拟滚动机制,只渲染视口内可见的选项节点,保证滚动流畅不卡顿。 + +虚拟滚动对 `radio`(单选)、`checkbox`(多选)、`map`(标签)三种类型的二级面板均生效,无需额外配置。配合 `panel-max-height` 可控制下拉面板的滚动区域高度。 + + + +### 工作原理 + +- 固定行高(32px),通过滚动监听计算当前可视区域 +- 只渲染视口内 + 上下各 5 行缓冲项,DOM 节点数恒定(约 20~30 个) +- 使用占位元素撑开总高度,内容容器通过 `translateY` 定位到正确位置 +- 搜索输入变化时自动重置滚动位置到顶部 + +### Data Source + +<<< ../search-box/virtual-scroll-data.ts diff --git a/packages/docs/search-box/virtual-scroll-data.ts b/packages/docs/search-box/virtual-scroll-data.ts new file mode 100644 index 0000000..f12e92f --- /dev/null +++ b/packages/docs/search-box/virtual-scroll-data.ts @@ -0,0 +1,51 @@ +// 生成大数据量数据源,用于演示二级面板数据选项的虚拟滚动效果 + +// 生成 1000 个单选选项 +function generateRadioOptions(count: number) { + return Array.from({ length: count }, (_, i) => ({ + label: `选项-${String(i + 1).padStart(4, '0')}`, + id: `radio-${i + 1}` + })) +} + +// 生成 500 个多选选项 +function generateCheckboxOptions(count: number) { + return Array.from({ length: count }, (_, i) => ({ + label: `区域-${String(i + 1).padStart(4, '0')}`, + id: `region-${i + 1}` + })) +} + +// 生成 200 个标签(map)选项,每个含 20 个子值 +function generateMapOptions(groupCount: number, childCount: number) { + return Array.from({ length: groupCount }, (_, i) => ({ + label: `标签组-${String(i + 1).padStart(3, '0')}`, + id: `tag-group-${i + 1}`, + options: Array.from({ length: childCount }, (_, j) => ({ + label: `值-${i + 1}-${j + 1}`, + id: `tag-value-${i + 1}-${j + 1}` + })) + })) +} + +export const virtualScrollDataSource = [ + { + label: '单选项(1000条)', + field: 'radioLarge', + replace: true, + options: generateRadioOptions(1000) + }, + { + label: '多选项(500条)', + field: 'checkboxLarge', + type: 'checkbox', + options: generateCheckboxOptions(500) + }, + { + label: '标签(200组x20值)', + field: 'mapLarge', + type: 'map', + searchKeys: ['label', 'id'], + options: generateMapOptions(200, 20) + } +] diff --git a/packages/docs/search-box/virtual-scroll-options-api.vue b/packages/docs/search-box/virtual-scroll-options-api.vue new file mode 100644 index 0000000..2b14c2f --- /dev/null +++ b/packages/docs/search-box/virtual-scroll-options-api.vue @@ -0,0 +1,26 @@ + + + diff --git a/packages/docs/search-box/virtual-scroll.vue b/packages/docs/search-box/virtual-scroll.vue new file mode 100644 index 0000000..83a9a48 --- /dev/null +++ b/packages/docs/search-box/virtual-scroll.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/docs/search-box/webdoc/search-box.js b/packages/docs/search-box/webdoc/search-box.js index 01e3807..1526768 100644 --- a/packages/docs/search-box/webdoc/search-box.js +++ b/packages/docs/search-box/webdoc/search-box.js @@ -38,6 +38,18 @@ export default { }, codeFiles: ['panel-max-height.vue', 'data-source.ts'] }, + { + demoId: 'virtual-scroll', + name: { + 'zh-CN': '虚拟滚动', + 'en-US': 'Virtual Scroll' + }, + desc: { + 'zh-CN': ' 当数据选项数量较多时,二级面板内置虚拟滚动机制,只渲染视口内可见的选项节点,保证滚动流畅不卡顿。 ', + 'en-US': ' When the number of data options is large, the level-2 panel has a built-in virtual scroll mechanism that renders only the option nodes visible in the viewport, ensuring smooth scrolling without lag. ' + }, + codeFiles: ['virtual-scroll.vue', 'virtual-scroll-data.ts'] + }, { demoId: 'split-input-value', name: { diff --git a/packages/search-box/package.json b/packages/search-box/package.json index 4991d3e..4480ca0 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-alpha.0", + "version": "3.29.0", "description": "", "homepage": "https://github.com/opentiny/tiny-search-box#readme", "bugs": { diff --git a/packages/search-box/src/components/second-level-panel.vue b/packages/search-box/src/components/second-level-panel.vue index 8658eae..23ac008 100644 --- a/packages/search-box/src/components/second-level-panel.vue +++ b/packages/search-box/src/components/second-level-panel.vue @@ -18,41 +18,48 @@
- - - - {{ text }} - {{ text }} - - - {{ item.label }} - +
+
+ + + + {{ text }} + {{ text }} + + + {{ item.label }} + +
+
-
-
+
+
- + - - +
- {{ item.label }} - - + + + {{ item.label }} + + +
+
@@ -248,23 +261,36 @@
- {{ - t("tvp.tvpSearchbox.tagKey") - }} - {{ - t("tvp.tvpSearchbox.tagValue") - }} - - {{ item.label }} - + {{ + t("tvp.tvpSearchbox.tagKey") + }} + {{ + t("tvp.tvpSearchbox.tagValue") + }} +
+
+ + {{ item.label }} + +
+
+
@@ -283,10 +309,11 @@ import { TinyLoading } from '@opentiny/vue' import { t } from '../utils/i18n.ts' +import { useVirtualScroll } from '../composables/use-virtual-scroll.ts' // 简单的 renderless 函数 -const renderless = (props, hooks, { emit }) => { - const { computed } = hooks +const renderless = (props, hooks, { emit, nextTick, refs }) => { + const { computed, reactive, watch, onMounted } = hooks // 优先使用传入的 events/handleEvents 函数,如果没有则使用 emit const handleEvents = props.handleEvents || props.events || ((eventName, p1, p2) => { @@ -329,6 +356,71 @@ const renderless = (props, hooks, { emit }) => { return props.state.backupList?.find((item) => !item.isFilter) }) + // 虚拟滚动:根据当前类型计算过滤后的列表 + const currentFilteredList = computed(() => { + const type = props.state.prevItem.type + const backupList = props.state.backupList || [] + + // radio 类型无输入时显示全部 + if (!type || type === 'radio') { + if (!props.state.inputValue) return backupList + return backupList.filter((item) => !item.isFilter) + } + + // checkbox 和 map 始终过滤 isFilter + return backupList.filter((item) => !item.isFilter) + }) + + const vs = useVirtualScroll( + { reactive, computed }, + { + getList: () => currentFilteredList.value, + itemHeight: 32, + bufferSize: 5, + headerHeight: () => { + const type = props.state.prevItem.type + // checkbox 的"全选"和 map 的标题在滚动容器内,占 32px + if (type === 'checkbox' || type === 'map') return 32 + return 0 + } + } + ) + + // 输入变化时重置滚动位置 + watch( + () => props.state.inputValue, + () => { + vs.vsState.scrollTop = 0 + nextTick(() => { + if (refs.vsScrollEl) { + refs.vsScrollEl.scrollTop = 0 + } + }) + } + ) + + // 切换面板类型时重置滚动位置并重新测量视口高度 + watch( + () => props.state.prevItem, + () => { + vs.vsState.scrollTop = 0 + nextTick(() => { + if (refs.vsScrollEl) { + refs.vsScrollEl.scrollTop = 0 + vs.handleScroll({ target: refs.vsScrollEl }) + } + }) + }, + { deep: true } + ) + + // 挂载后测量实际视口高度,确保首次渲染数量正确 + onMounted(() => { + if (refs.vsScrollEl) { + vs.handleScroll({ target: refs.vsScrollEl }) + } + }) + return { setOperator, selectRadioItem, @@ -339,6 +431,11 @@ const renderless = (props, hooks, { emit }) => { handleDateShow, isLoading, showCheckBoxList, + vsTotalHeight: vs.totalHeight, + vsStartIndex: vs.startIndex, + vsOffsetY: vs.offsetY, + vsVisibleItems: vs.visibleItems, + vsHandleScroll: vs.handleScroll, t } } @@ -353,6 +450,11 @@ const api = [ 'handleDateShow', 'isLoading', 'showCheckBoxList', + 'vsTotalHeight', + 'vsStartIndex', + 'vsOffsetY', + 'vsVisibleItems', + 'vsHandleScroll', 't' ] @@ -391,6 +493,10 @@ export default defineComponent({ type: Function, default: null }, + panelMaxHeight: { + type: String, + default: '999px' + } }, setup(props, context) { return setup({ props, context, renderless, api }) diff --git a/packages/search-box/src/composables/use-virtual-scroll.ts b/packages/search-box/src/composables/use-virtual-scroll.ts new file mode 100644 index 0000000..1a38ef2 --- /dev/null +++ b/packages/search-box/src/composables/use-virtual-scroll.ts @@ -0,0 +1,57 @@ +/** + * 轻量级一维列表虚拟滚动 composable + * 适用于固定行高的下拉列表场景 + * + * @param hooks - renderless hooks (提供 reactive, computed) + * @param options.getList - 返回完整列表的函数 + * @param options.itemHeight - 每行高度(默认 32px,与 tiny-dropdown-item 一致) + * @param options.bufferSize - 视口外额外渲染的行数(默认 5) + * @param options.headerHeight - 返回容器内非列表头部高度的函数(默认 () => 0) + */ +export function useVirtualScroll(hooks, options) { + const { reactive, computed } = hooks + const { getList, itemHeight = 32, bufferSize = 5, headerHeight = () => 0 } = options + + const vsState = reactive({ + scrollTop: 0, + viewportHeight: 300 + }) + + const totalHeight = computed(() => { + const list = getList() || [] + return list.length * itemHeight + }) + + const startIndex = computed(() => { + const adjustedScrollTop = Math.max(0, vsState.scrollTop - headerHeight()) + return Math.max(0, Math.floor(adjustedScrollTop / itemHeight) - bufferSize) + }) + + const endIndex = computed(() => { + const list = getList() || [] + const visibleCount = Math.ceil(vsState.viewportHeight / itemHeight) + return Math.min(list.length, startIndex.value + visibleCount + bufferSize * 2) + }) + + const offsetY = computed(() => startIndex.value * itemHeight) + + const visibleItems = computed(() => { + const list = getList() || [] + return list.slice(startIndex.value, endIndex.value) + }) + + const handleScroll = (e) => { + vsState.scrollTop = e.target.scrollTop + vsState.viewportHeight = e.target.clientHeight + } + + return { + vsState, + totalHeight, + startIndex, + endIndex, + offsetY, + visibleItems, + handleScroll + } +} diff --git a/packages/search-box/src/pc.vue b/packages/search-box/src/pc.vue index 985c3e9..623cf72 100644 --- a/packages/search-box/src/pc.vue +++ b/packages/search-box/src/pc.vue @@ -141,6 +141,7 @@ v-else-if="state.prevItem.type !== 'custom'" :state="state" :picker-options="pickerOptions" + :panel-max-height="panelMaxHeight" @events="handleEvents" >