Virtualized Select
Select collection은 1,000개 항목을 유지하고 스크롤 위치에 맞는 행만 렌더링합니다.
<script lang="ts">
import * as Select from '@odbd/svelte/select'
import { createListCollection } from '@odbd/svelte/select'
const rowHeight = 40
const visibleRows = 8
const overscan = 4
const regions = ['서울', '부산', '대구', '광주', '대전', '제주', '인천', '수원']
const branches = Array.from({ length: 1000 }, (_, index) => {
const number = String(index + 1).padStart(4, '0')
const region = regions[index % regions.length]
return {
value: `branch-${number}`,
label: `${region} ${number} 지점`,
team: `${(index % 12) + 1}팀`,
}
})
const collection = createListCollection({
items: branches,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
})
let value = $state(['branch-0001'])
let scrollTop = $state(0)
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - overscan))
const endIndex = $derived(Math.min(branches.length, startIndex + visibleRows + overscan * 2))
const visibleItems = $derived(branches.slice(startIndex, endIndex))
const totalHeight = $derived(branches.length * rowHeight)
let contentEl = $state<HTMLElement | null>(null)
const updateValue = (details: { value: string[] }) => {
value = details.value
}
const updateWindow = (event: Event) => {
contentEl = event.currentTarget as HTMLElement
scrollTop = contentEl.scrollTop
}
// 가상화 select의 정석 훅: scrollToIndexFn을 주면 zag가 자체 scrollIntoView
// 대신 이 함수에 스크롤을 전적으로 위임한다(키보드 Home/End/typeahead 포함).
// 창 밖 인덱스로 점프해도 여기서 윈도우를 옮겨 항목이 렌더·노출되게 한다.
const scrollToIndexFn = (details: { index: number }) => {
const content =
contentEl ?? document.querySelector<HTMLElement>('[data-scope="select"][data-part="content"]')
if (!content) return
const itemTop = details.index * rowHeight
const viewHeight = rowHeight * visibleRows
if (itemTop < content.scrollTop) content.scrollTop = itemTop
else if (itemTop + rowHeight > content.scrollTop + viewHeight)
content.scrollTop = itemTop - viewHeight + rowHeight
}
</script>
<Select.Root
{collection}
{value}
onValueChange={updateValue}
{scrollToIndexFn}
style="max-width: 22rem;"
>
<Select.Label>담당 지점</Select.Label>
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="지점을 선택하세요" />
<Select.Indicator>▾</Select.Indicator>
</Select.Trigger>
</Select.Control>
<Select.Positioner>
<Select.Content
onscroll={updateWindow}
style={`width: min(22rem, calc(100vw - var(--odbd-space-8))); max-height: ${
rowHeight * visibleRows
}px;`}
>
<!-- content는 flex column이라 List가 flex item으로 축소된다 — flex:none으로
전체 가상 높이를 유지해야 스크롤바가 1000개를 반영하고 키보드 점프도
해당 인덱스로 스크롤된다 -->
<Select.List style={`position: relative; flex: none; height: ${totalHeight}px;`}>
{#each visibleItems as item, index (item.value)}
<Select.Item
{item}
class="examples-menu-item"
style={`position: absolute; inset-inline: 0; top: ${
(startIndex + index) * rowHeight
}px; height: ${rowHeight}px;`}
>
<Select.ItemText>{item.label}</Select.ItemText>
<small>{item.team}</small>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
{/each}
</Select.List>
</Select.Content>
</Select.Positioner>
</Select.Root>