feat: add maxColumns prop to VirtualGrid for responsive column capping

This commit is contained in:
Alexander Brown
2026-01-17 16:59:35 -08:00
parent 7af6603bf3
commit b388ba6fff
2 changed files with 58 additions and 24 deletions

View File

@@ -1,16 +1,20 @@
<template> <template>
<div ref="container" class="scroll-container"> <div
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" /> ref="container"
<div :style="gridStyle"> class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item> >
<div :style="topSpacerStyle" />
<div :style="mergedGridStyle">
<div
v-for="item in renderedItems"
:key="item.key"
class="transition-[width] duration-150 ease-out"
data-virtual-grid-item
>
<slot name="item" :item="item" /> <slot name="item" :item="item" />
</div> </div>
</div> </div>
<div <div :style="bottomSpacerStyle" />
:style="{
height: `${((items.length - state.end) / cols) * itemHeight}px`
}"
/>
</div> </div>
</template> </template>
@@ -28,19 +32,22 @@ type GridState = {
const { const {
items, items,
gridStyle,
bufferRows = 1, bufferRows = 1,
scrollThrottle = 64, scrollThrottle = 64,
resizeDebounce = 64, resizeDebounce = 64,
defaultItemHeight = 200, defaultItemHeight = 200,
defaultItemWidth = 200 defaultItemWidth = 200,
maxColumns = Infinity
} = defineProps<{ } = defineProps<{
items: (T & { key: string })[] items: (T & { key: string })[]
gridStyle: Partial<CSSProperties> gridStyle: CSSProperties
bufferRows?: number bufferRows?: number
scrollThrottle?: number scrollThrottle?: number
resizeDebounce?: number resizeDebounce?: number
defaultItemHeight?: number defaultItemHeight?: number
defaultItemWidth?: number defaultItemWidth?: number
maxColumns?: number
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true } eventListenerOptions: { passive: true }
}) })
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1) const cols = computed(() =>
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
)
const mergedGridStyle = computed<CSSProperties>(() => {
if (maxColumns === Infinity) return gridStyle
return {
...gridStyle,
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
}
})
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value)) const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value)) const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length) const isValidGrid = computed(() => height.value && width.value && items?.length)
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : [] isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
) )
function rowsToHeight(rows: number): string {
return `${(rows / cols.value) * itemHeight.value}px`
}
const topSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(state.value.start)
}))
const bottomSpacerStyle = computed<CSSProperties>(() => ({
height: rowsToHeight(items.length - state.value.end)
}))
whenever( whenever(
() => state.value.isNearEnd, () => state.value.isNearEnd,
() => { () => {
@@ -109,15 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' }) watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' }) whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => { onBeforeUnmount(() => {
onResize.cancel() // Clear pending debounced calls onResize.cancel()
}) })
</script> </script>
<style scoped>
.scroll-container {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--dialog-surface) transparent;
}
</style>

View File

@@ -26,9 +26,10 @@
<VirtualGrid <VirtualGrid
v-else v-else
:items="assetsWithKey" :items="assetsWithKey"
:grid-style="gridStyle" :grid-style
:default-item-height="320" :default-item-height="320"
:default-item-width="240" :default-item-width="240"
:max-columns
> >
<template #item="{ item }"> <template #item="{ item }">
<AssetCard <AssetCard
@@ -46,6 +47,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import type { CSSProperties } from 'vue' import type { CSSProperties } from 'vue'
import { computed } from 'vue' import { computed } from 'vue'
@@ -70,7 +72,20 @@ const assetsWithKey = computed(() =>
assets.map((asset) => ({ ...asset, key: asset.id })) assets.map((asset) => ({ ...asset, key: asset.id }))
) )
const gridStyle: Partial<CSSProperties> = { const breakpoints = useBreakpoints(breakpointsTailwind)
const is2Xl = breakpoints.greaterOrEqual('2xl')
const isXl = breakpoints.greaterOrEqual('xl')
const isLg = breakpoints.greaterOrEqual('lg')
const isMd = breakpoints.greaterOrEqual('md')
const maxColumns = computed(() => {
if (is2Xl.value) return 5
if (isXl.value) return 4
if (isLg.value) return 3
if (isMd.value) return 2
return 1
})
const gridStyle: CSSProperties = {
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
gap: '1rem', gap: '1rem',