filters
Filters with virtualized large lists
Filters with virtualized large lists
Загрузка превью…
src/filters/c-filters-10.vue
<!--
Порт c-filters-10.tsx ("Filters with virtualized large lists",
registry-reui/bases/radix/components/filters/c-filters-10.tsx, MIT).
Виртуализация — `field.renderOptionList` (bring-your-own windowing render
prop, см. packages/ui/src/reui/filters/context.ts:
`FilterOptionListRenderProps`). Примитив сам не тянет `@tanstack/*-virtual`
зависимость: он только вызывает переданный колбэк с уже отфильтрованными
`options`/`highlightedIndex`/`renderOption` (SelectOptionsPopover.vue,
FilterSubmenuContent.vue) — та же архитектура, что в оригинале
(`@tanstack/react-virtual`). Здесь используется `@tanstack/vue-virtual`
(тот же `virtual-core` движок), уже зависимость `packages/blocks`
(см. `data-grid`/`c-data-grid-27.vue`, `DataGridTableVirtual.vue`).
ЧЕСТНОЕ ОГРАНИЧЕНИЕ (не исправлено, зафиксировано): оба примитива
(React и Vue) рендерят `renderOptionList` через обёртку, пересоздаваемую
на каждый ре-рендер родителя — React: `<VirtualizedOptions {...props} />`
внутри `.map`/JSX, пересобираемого при каждом рендере `Pattern`; Vue:
`<component :is="() => field.renderOptionList!(...)" />`
(SelectOptionsPopover.vue/FilterSubmenuContent.vue) — новый инлайн-объект
функции на каждый вызов родительского рендера. Для Vue это означает, что
`VirtualizedOptions` как компонент (со своим `setup()`/`ref`) размонти
руется и монтируется заново при каждом изменении `highlightedIndex`
(перерисовка родителя из-за клавиатурной навигации/поиска), а не переис
пользуется как persistent-инстанс: `useVirtualizer` создаётся заново,
теряя `scrollEl`/позицию скролла между такими ре-рендерами. Тот же класс
проблемы верно и для оригинала (React тоже создаёт новый элемент
`VirtualizedOptions` в дереве при каждом рендере `Pattern`, хотя React
умеет матчить по типу компонента и в норме не размонтирует, если элемент
остаётся на той же позиции — в отличие от Vue `:is` с инлайн-функцией,
чья ссылочная идентичность меняется каждый раз, что для Vue гарантированно
форсирует remount). Практически это не проявляется в кейсе/дифе: попапы
значений `filters` намеренно не раскрываются на статичном скриншоте
(docs/PORTING.md, раздел про `filters`), так что визуально ничего не
ломается — но виртуализация как таковая эксплуатационно ненадёжна при
реальном использовании внутри Vue-порта примитива и не проверена ни одним
кейсом визуального дифа.
`IconPlaceholder` -> инлайновый `<svg>` (lucide-react v0.545.0: "package").
Демо-данные (5000 опций) сгенерированы детерминированно (`Array.from`, без
`Math.random()`), как в оригинале.
-->
<script setup lang="ts">
import {
computed,
defineComponent,
h,
ref,
watch,
type PropType,
type VNodeChild,
type VNodeRef,
} from "vue"
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/vue-virtual"
import { createFilter, Filters, type Filter, type FilterFieldConfig, type FilterOption, type FilterOptionListRenderProps } from "@/components/reui/filters"
const ROW_HEIGHT = 32
function PackageIcon() {
return h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
class: "size-3.5",
},
[
h("path", {
d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z",
}),
h("path", { d: "M12 22V12" }),
h("polyline", { points: "3.29 7 12 12 20.71 7" }),
h("path", { d: "m7.5 4.27 9 5.15" }),
]
)
}
// Consumer-owned virtualization wired through the primitive's `renderOptionList`
// slot. The Filters primitive ships no windowing dependency - you bring your own
// (here @tanstack/vue-virtual) and stay bound to its selection/keyboard logic
// via `renderOption` and `highlightedIndex`.
const VirtualizedOptions = defineComponent({
name: "VirtualizedOptions",
props: {
options: { type: Array as PropType<FilterOption<string>[]>, required: true },
highlightedIndex: { type: Number, required: true },
renderOption: {
type: Function as PropType<(option: FilterOption<string>, index: number) => VNodeChild>,
required: true,
},
},
setup(props) {
const scrollEl = ref<HTMLDivElement | null>(null)
// Keep the highlighted row mounted even when scrolled away, so the
// combobox's aria-activedescendant never points at an unmounted node.
function rangeExtractor(range: Range) {
const indices = new Set(defaultRangeExtractor(range))
if (props.highlightedIndex >= 0 && props.highlightedIndex < props.options.length) {
indices.add(props.highlightedIndex)
}
return Array.from(indices).sort((a, b) => a - b)
}
const virtualizer = useVirtualizer(
computed(() => ({
count: props.options.length,
getScrollElement: () => scrollEl.value,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
getItemKey: (index: number) => String(props.options[index]?.value),
rangeExtractor,
}))
)
watch(
() => [props.highlightedIndex, props.options.length] as const,
([index, length]) => {
if (index >= 0 && index < length) {
virtualizer.value.scrollToIndex(index, { align: "auto" })
}
}
)
return () =>
h(
"div",
{
ref: scrollEl,
class: "max-h-[300px] overflow-y-auto overscroll-contain px-1",
},
[
h(
"div",
{
class: "relative w-full",
style: { height: `${virtualizer.value.getTotalSize()}px` },
},
virtualizer.value.getVirtualItems().map((row) =>
h(
"div",
{
key: String(row.key),
"data-index": row.index,
ref: ((el: Element | null) => {
if (el) virtualizer.value.measureElement(el)
}) as VNodeRef,
class: "absolute top-0 left-0 w-full",
style: { transform: `translateY(${row.start}px)` },
},
[props.renderOption(props.options[row.index]!, row.index)]
)
)
),
]
)
},
})
// Deterministic large option list (no Math.random()), matching the original.
const products: FilterOption<string>[] = Array.from({ length: 5000 }, (_, index) => {
const id = index + 1
return {
value: `sku-${id}`,
label: `Product ${String(id).padStart(4, "0")}`,
}
})
const fields: FilterFieldConfig<string>[] = [
{
key: "product",
label: "Product",
icon: PackageIcon,
type: "multiselect",
options: products,
// Bring your own windowing for large lists.
renderOptionList: (props: FilterOptionListRenderProps<string>) => h(VirtualizedOptions, props),
},
]
const filters = ref<Filter<string>[]>([createFilter("product", "is_any_of", ["sku-42", "sku-1024"])])
function handleFiltersChange(next: Filter<string>[]) {
filters.value = next
}
</script>
<template>
<div class="flex grow content-start items-start self-start">
<Filters :filters="filters" :fields="fields" @change="handleFiltersChange" />
</div>
</template>
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-filters-10.jsonЗависимости реестра
npm-зависимости
- @tanstack/vue-virtual
Источник: порт из ReUI (Keenthemes, MIT)