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 @@
-
selectRadioItem(item)"
- >
-
-
- {{ text }}
- {{ text }}
-
-
- {{ item.label }}
-
+
+
+ selectRadioItem(item)"
+ >
+
+
+ {{ text }}
+ {{ text }}
+
+
+ {{ item.label }}
+
+
+
-
-
+
@@ -248,23 +261,36 @@
-
{{
- t("tvp.tvpSearchbox.tagKey")
- }}
-
{{
- t("tvp.tvpSearchbox.tagValue")
- }}
-
selectFirstMap(item, state.isShowTagKey)"
+
-
{{ item.label }}
-
+
{{
+ t("tvp.tvpSearchbox.tagKey")
+ }}
+
{{
+ t("tvp.tvpSearchbox.tagValue")
+ }}
+
+
+ selectFirstMap(item, state.isShowTagKey)"
+ >
+ {{ 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"
>