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)