refactor(docs): virtualize icon overviews with @tanstack/vue-virtual

Replace @vueuse/core's useVirtualList, which assumed a fixed 64px row
height, with useWindowVirtualizer from @tanstack/vue-virtual in the icons
and categories overviews.

- Rows are measured after render, so stretched grid cells and category
  headings no longer make the content jump while scrolling.
- Scroll against the window with the list's page offset as scroll margin,
  instead of forwarding window scroll events by hand.
- Match the column count to the CSS auto-fill grid.
- Scroll to a category below the sticky search bar.

Shared logic lives in composables/useIconGridVirtualizer.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G9u8FiKAWTfN2uj8sqzqSi
This commit is contained in:
Claude
2026-10-31 14:07:21 +00:00
parent a630cec77e
commit 419733e19e
6 changed files with 291 additions and 67 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, defineAsyncComponent, onMounted } from 'vue';
import { ref, computed, watch, defineAsyncComponent } from 'vue';
import type { IconEntity, Category } from '../../types';
import useSearch from '../../composables/useSearch';
import InputSearch from '../base/InputSearch.vue';
@@ -9,15 +9,17 @@ import StickyBar from './StickyBar.vue';
import IconsCategory, { CategoryRow } from './IconsCategory.vue';
import useFetchTags from '../../composables/useFetchTags';
import useFetchCategories from '../../composables/useFetchCategories';
import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
import chunkArray from '../../utils/chunkArray';
import useScrollToCategory from '../../composables/useScrollToCategory';
import useIconGridVirtualizer, {
useIconGridColumns,
} from '../../composables/useIconGridVirtualizer';
import { useCategoryView } from '../../composables/useCategoryView';
import CarbonAdOverlay from './CarbonAdOverlay.vue';
import useSearchPlaceholder from '../../utils/useSearchPlaceholder.ts';
const ICON_SIZE = 56;
const ICON_GRID_GAP = 8;
/** Estimated height of a category heading row. Rows are measured once rendered. */
const CATEGORY_HEADING_HEIGHT = 64;
const props = defineProps<{
icons: IconEntity[];
@@ -52,11 +54,10 @@ const {
} = useFetchCategories();
const overviewEl = ref<HTMLElement | null>(null);
const { width: containerWidth } = useElementSize(overviewEl);
const columnSize = computed(() => {
return Math.floor(containerWidth.value / (ICON_SIZE + ICON_GRID_GAP));
});
const listEl = ref<HTMLElement | null>(null);
const stickyBar = ref<InstanceType<typeof StickyBar> | null>(null);
const gridColumns = useIconGridColumns(overviewEl);
const { columnSize } = gridColumns;
const mappedIcons = computed(() => {
if (tags.value == null) {
@@ -125,23 +126,25 @@ const isSearchMetadataLoading = computed(
() => searchQuery.value.length > 0 && !isFetchingTags && !isFetchingCategories,
);
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(categoriesList, {
itemHeight: ICON_SIZE + ICON_GRID_GAP,
overscan: 10,
});
const { virtualRows, totalSize, offsetTop, measureElement, scrollToIndex, scrollToTop } =
useIconGridVirtualizer({
columns: gridColumns,
listEl,
stickyEl: stickyBar,
rows: categoriesList,
estimateRowSize: (row, iconRowHeight) =>
row.type === 'category' ? CATEGORY_HEADING_HEIGHT : iconRowHeight,
getRowKey: (row, index) => (row.type === 'category' ? `category-${row.name}` : index),
});
useScrollToCategory({
categories,
categoriesList,
scrollTo,
scrollToIndex,
scrollToTop,
searchQueryDebounced,
});
onMounted(() => {
containerProps.ref.value = document.documentElement;
useEventListener(window, 'scroll', containerProps.onScroll);
});
function loadSearchMetadata() {
if (tags.value == null && !isFetchingTags.value) {
void fetchTags();
@@ -184,7 +187,10 @@ function handleCloseDrawer() {
class="overview-container"
:class="{ 'icon-drawer-open': activeIconName }"
>
<StickyBar class="category-search">
<StickyBar
ref="stickyBar"
class="category-search"
>
<InputSearch
:placeholder="`Search ${icons.length} icons…`"
v-model="searchQuery"
@@ -200,14 +206,29 @@ function handleCloseDrawer() {
:isBrandSearch="searchPlaceholder.isBrand"
@clear="searchQuery = ''"
/>
<div v-bind="wrapperProps">
<IconsCategory
v-for="{ index, data } in list"
:categoryRow="data"
:activeIconName="activeIconName"
@setActiveIcon="setActiveIconName"
:key="index"
/>
<div
ref="listEl"
class="virtual-list"
:style="{ height: `${totalSize}px` }"
>
<div
class="virtual-list-rows"
:style="{ transform: `translateY(${offsetTop}px)` }"
>
<div
v-for="row in virtualRows"
:key="String(row.key)"
:ref="measureElement"
:data-index="row.index"
class="virtual-row"
>
<IconsCategory
:categoryRow="categoriesList[row.index]"
:activeIconName="activeIconName"
@setActiveIcon="setActiveIconName"
/>
</div>
</div>
</div>
</div>
<IconDetailOverlay
@@ -220,6 +241,23 @@ function handleCloseDrawer() {
</template>
<style scoped>
.virtual-list {
position: relative;
width: 100%;
}
.virtual-list-rows {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
/* Contain the row's margins, so the measured height includes them. */
.virtual-row {
display: flow-root;
}
.input-wrapper {
width: 100%;
}
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { ref, computed, defineAsyncComponent, onMounted, watch } from 'vue';
import type { IconEntity } from '../../types';
import { useElementSize, useEventListener, useVirtualList } from '@vueuse/core';
import { useRoute } from 'vitepress';
import IconGrid from './IconGrid.vue';
import Select from '../base/Select.vue';
import InputSearch from '../base/InputSearch.vue';
import useSearch from '../../composables/useSearch';
import useIconGridVirtualizer, {
ICON_GRID_GAP,
ICON_SIZE,
useIconGridColumns,
} from '../../composables/useIconGridVirtualizer';
import useSearchInput from '../../composables/useSearchInput';
import useSearchShortcut from '../../utils/useSearchShortcut';
import StickyBar from './StickyBar.vue';
@@ -19,8 +23,6 @@ import Icon from '@lucide/vue/src/Icon';
import { listSortDescending } from '~/.vitepress/data/iconNodes';
import { track, trackDebounced } from '../../utils/openpanel';
const ICON_SIZE = 56;
const ICON_GRID_GAP = 8;
const SORTING = [
{
name: 'Popularity',
@@ -60,11 +62,9 @@ const {
} = useFetchCategories();
const overviewEl = ref<HTMLElement | null>(null);
const { width: containerWidth } = useElementSize(overviewEl);
const columnSize = computed(() => {
return Math.floor(containerWidth.value / (ICON_SIZE + ICON_GRID_GAP));
});
const listEl = ref<HTMLElement | null>(null);
const gridColumns = useIconGridColumns(overviewEl);
const { containerWidth, columnSize } = gridColumns;
const sortedIcons = computed(() => {
switch (selectedSort.value.value) {
@@ -128,15 +128,13 @@ const chunkedIcons = computed(() => {
return chunkArray(searchResults.value, columnSize.value);
});
const { list, containerProps, wrapperProps, scrollTo } = useVirtualList(chunkedIcons, {
itemHeight: ICON_SIZE + ICON_GRID_GAP,
overscan: 10,
const { virtualRows, totalSize, offsetTop, measureElement, scrollToTop } = useIconGridVirtualizer({
columns: gridColumns,
listEl,
rows: chunkedIcons,
});
onMounted(() => {
containerProps.ref.value = document.documentElement;
useEventListener(window, 'scroll', containerProps.onScroll);
// Check if we should focus the search input from URL parameter
const route = useRoute();
if (route.data?.relativePath && window.location.search.includes('focus')) {
@@ -168,7 +166,7 @@ const NoResults = defineAsyncComponent(() => import('./NoResults.vue'));
const IconDetailOverlay = defineAsyncComponent(() => import('./IconDetailOverlay.vue'));
watch(searchQueryDebounced, () => {
scrollTo(0);
scrollToTop();
});
let hasStartedSearching = false;
@@ -248,25 +246,37 @@ function handleCloseDrawer() {
@clear="searchQuery = ''"
/>
<IconGrid
v-else-if="list.length === 0"
v-else-if="virtualRows.length === 0"
overlayMode
:icons="searchResults.slice(0, initialGridItems)"
:activeIcon="activeIconName"
@setActiveIcon="setActiveIconName"
/>
<div
v-bind="wrapperProps"
class="icon"
v-else
ref="listEl"
class="virtual-list"
:style="{ height: `${totalSize}px` }"
>
<IconGrid
v-for="{ index, data: icons } in list"
:key="index"
overlayMode
:icons="icons"
:activeIcon="activeIconName"
@setActiveIcon="setActiveIconName"
/>
<div
class="virtual-list-rows"
:style="{ transform: `translateY(${offsetTop}px)` }"
>
<div
v-for="row in virtualRows"
:key="String(row.key)"
:ref="measureElement"
:data-index="row.index"
class="virtual-row"
>
<IconGrid
overlayMode
:icons="chunkedIcons[row.index]"
:activeIcon="activeIconName"
@setActiveIcon="setActiveIconName"
/>
</div>
</div>
</div>
</div>
@@ -287,6 +297,23 @@ function handleCloseDrawer() {
aspect-ratio: 1/1;
}
.virtual-list {
position: relative;
width: 100%;
}
.virtual-list-rows {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
/* Contain the row's bottom margin, so the measured height includes it. */
.virtual-row {
display: flow-root;
}
.input-wrapper {
width: 100%;
/* view-transition-name: icons-search-box; */
@@ -0,0 +1,153 @@
import { computed, onMounted, ref, watch, type ComponentPublicInstance, type Ref } from 'vue';
import { useWindowVirtualizer } from '@tanstack/vue-virtual';
import { useElementSize, useEventListener, useResizeObserver } from '@vueuse/core';
export const ICON_SIZE = 56;
export const ICON_GRID_GAP = 8;
interface UseIconGridVirtualizerOptions<Row> {
/** Grid columns of the overview, from `useIconGridColumns`. */
columns: ReturnType<typeof useIconGridColumns>;
/** Element the virtual rows are rendered in. Its offset in the page is the scroll margin. */
listEl: Ref<HTMLElement | null>;
/** Sticky element at the top of the list. Its height is kept clear when scrolling to a row. */
stickyEl?: Ref<HTMLElement | ComponentPublicInstance | null>;
/** Rows to virtualize, usually icons chunked by `columns.columnSize`. */
rows: Ref<Row[]>;
/** Estimated row height. Rows are measured once rendered, so this only needs to be close. */
estimateRowSize?: (row: Row, iconRowHeight: number) => number;
getRowKey?: (row: Row, index: number) => string | number;
overscan?: number;
}
/**
* Number of icon columns that matches the CSS grid used by `IconGrid`:
* `grid-template-columns: repeat(auto-fill, minmax(ICON_SIZE, 1fr))` with `gap: ICON_GRID_GAP`.
*/
export function getColumnCount(width: number) {
if (width <= 0) return 0;
return Math.max(1, Math.floor((width + ICON_GRID_GAP) / (ICON_SIZE + ICON_GRID_GAP)));
}
export function useIconGridColumns(containerEl: Ref<HTMLElement | null>) {
const { width: containerWidth } = useElementSize(containerEl);
const columnSize = computed(() => getColumnCount(containerWidth.value));
/** Height of one row of icons: square cells that stretch to fill the row, plus the row gap. */
const iconRowHeight = computed(() => {
const columns = columnSize.value;
if (!columns) return ICON_SIZE + ICON_GRID_GAP;
const cellSize = (containerWidth.value - ICON_GRID_GAP * (columns - 1)) / columns;
return cellSize + ICON_GRID_GAP;
});
return { containerWidth, columnSize, iconRowHeight };
}
function resolveElement(target: HTMLElement | ComponentPublicInstance | null | undefined) {
if (target == null) return null;
if (target instanceof HTMLElement) return target;
return (target.$el as HTMLElement | null) ?? null;
}
/**
* Virtualizes rows of icons against the window scroll, using `@tanstack/vue-virtual`.
* Rows are measured after rendering, so rows of different heights (like category headings)
* are positioned correctly.
*/
export default function useIconGridVirtualizer<Row>({
columns,
listEl,
stickyEl,
rows,
estimateRowSize,
getRowKey,
overscan = 5,
}: UseIconGridVirtualizerOptions<Row>) {
const { containerWidth, iconRowHeight } = columns;
const scrollMargin = ref(0);
const scrollPaddingStart = ref(0);
function updateLayoutMetrics() {
const list = listEl.value;
if (list) {
scrollMargin.value = Math.round(list.getBoundingClientRect().top + window.scrollY);
}
const sticky = resolveElement(stickyEl?.value);
if (sticky) {
const stickyTop = parseFloat(getComputedStyle(sticky).top) || 0;
scrollPaddingStart.value = Math.round(stickyTop + sticky.offsetHeight);
}
}
const virtualizer = useWindowVirtualizer<HTMLElement>(
computed(() => {
const rowList = rows.value;
const rowHeight = iconRowHeight.value;
return {
count: rowList.length,
estimateSize: (index: number) =>
estimateRowSize ? estimateRowSize(rowList[index], rowHeight) : rowHeight,
getItemKey: getRowKey ? (index: number) => getRowKey(rowList[index], index) : undefined,
overscan,
scrollMargin: scrollMargin.value,
scrollPaddingStart: scrollPaddingStart.value,
};
}),
);
const virtualRows = computed(() => virtualizer.value.getVirtualItems());
const totalSize = computed(() => virtualizer.value.getTotalSize());
/** Offset of the first rendered row, relative to the top of the list element. */
const offsetTop = computed(() => (virtualRows.value[0]?.start ?? 0) - scrollMargin.value);
function measureElement(el: Element | ComponentPublicInstance | null) {
if (el instanceof HTMLElement) {
virtualizer.value.measureElement(el);
}
}
function scrollToIndex(index: number) {
updateLayoutMetrics();
virtualizer.value.scrollToIndex(index, { align: 'start' });
}
function scrollToTop() {
window.scrollTo({ top: 0 });
}
// Rows change completely when the width or the list changes, so drop cached row sizes.
watch([containerWidth, rows], () => {
updateLayoutMetrics();
virtualizer.value.measure();
});
// The list element is only rendered once there are rows to show.
watch(listEl, updateLayoutMetrics, { flush: 'post' });
useEventListener('resize', updateLayoutMetrics, { passive: true });
// Content above the list (page header, banners) can change height after mount.
useResizeObserver(
() => (typeof document !== 'undefined' ? document.body : null),
updateLayoutMetrics,
);
onMounted(updateLayoutMetrics);
return {
virtualizer,
virtualRows,
totalSize,
offsetTop,
measureElement,
scrollToIndex,
scrollToTop,
};
}
@@ -6,14 +6,16 @@ import { useCategoryView } from './useCategoryView';
interface UseScrollToCategory {
categories: Ref<Pick<Category, 'name' | 'icons'>[]>;
categoriesList: Ref<CategoryRow[]>;
scrollTo: (index: number) => void;
scrollToIndex: (index: number) => void;
scrollToTop: () => void;
searchQueryDebounced: Ref<string>;
}
export default function useScrollToCategory({
categories,
categoriesList,
scrollTo,
scrollToIndex,
scrollToTop,
searchQueryDebounced,
}: UseScrollToCategory) {
const { selectedCategory, categoryCounts } = useCategoryView();
@@ -28,7 +30,7 @@ export default function useScrollToCategory({
if (categoryRowIndex !== -1) {
setTimeout(() => {
scrollTo(categoryRowIndex);
scrollToIndex(categoryRowIndex);
}, 0);
}
}
@@ -38,7 +40,7 @@ export default function useScrollToCategory({
if (value) {
scrollToSelectedCategory(value);
} else {
scrollTo(0);
scrollToTop();
}
});
@@ -52,7 +54,7 @@ export default function useScrollToCategory({
if (selectedCategory.value) {
scrollToSelectedCategory(selectedCategory.value);
} else {
scrollTo(0);
scrollToTop();
}
});
+1
View File
@@ -63,6 +63,7 @@
"@headlessui/vue": "^1.7.23",
"@lucide/vue": "workspace:*",
"@openpanel/web": "^1.4.2",
"@tanstack/vue-virtual": "3.13.47",
"@vueuse/components": "^14.4.0",
"@vueuse/core": "^14.4.0",
"@vueuse/integrations": "^14.4.0",
+11 -8
View File
@@ -164,6 +164,9 @@ importers:
'@openpanel/web':
specifier: ^1.4.2
version: 1.4.2
'@tanstack/vue-virtual':
specifier: 3.13.47
version: 3.13.47(vue@3.5.44(typescript@6.0.3))
'@vueuse/components':
specifier: ^14.4.0
version: 14.4.0(vue@3.5.44(typescript@6.0.3))
@@ -6221,15 +6224,15 @@ packages:
'@tanstack/store@0.9.3':
resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==}
'@tanstack/virtual-core@3.17.8':
resolution: {integrity: sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==}
'@tanstack/virtual-core@3.19.6':
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
'@tanstack/virtual-file-routes@1.162.0':
resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==}
engines: {node: '>=20.19'}
'@tanstack/vue-virtual@3.13.36':
resolution: {integrity: sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw==}
'@tanstack/vue-virtual@3.13.47':
resolution: {integrity: sha512-6HzdYK9bDyDoD9Q/NjepnC4wGDaZ7mFl5zz8tbn3Fmy7FDicI98ZJ+R37ztFssnoTMoCQhY3GfUhlz+pW/vOTg==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
@@ -16027,7 +16030,7 @@ snapshots:
'@headlessui/vue@1.7.23(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@tanstack/vue-virtual': 3.13.36(vue@3.5.44(typescript@6.0.3))
'@tanstack/vue-virtual': 3.13.47(vue@3.5.44(typescript@6.0.3))
vue: 3.5.44(typescript@6.0.3)
'@html-eslint/core@0.65.0':
@@ -18921,13 +18924,13 @@ snapshots:
'@tanstack/store@0.9.3': {}
'@tanstack/virtual-core@3.17.8': {}
'@tanstack/virtual-core@3.19.6': {}
'@tanstack/virtual-file-routes@1.162.0': {}
'@tanstack/vue-virtual@3.13.36(vue@3.5.44(typescript@6.0.3))':
'@tanstack/vue-virtual@3.13.47(vue@3.5.44(typescript@6.0.3))':
dependencies:
'@tanstack/virtual-core': 3.17.8
'@tanstack/virtual-core': 3.19.6
vue: 3.5.44(typescript@6.0.3)
'@testing-library/dom@10.4.2':