Async Select
Select 항목을 비동기 mock으로 불러오며 로딩 상태와 재시도 흐름을 함께 보여줍니다.
담당 팀 목록을 불러오고 있어요 팀 목록을 불러오고 있어요
<script lang="ts">
import { Button, Spinner } from '@odbd/svelte'
import * as Select from '@odbd/svelte/select'
import { createListCollection } from '@odbd/svelte/select'
interface TeamOption {
label: string
value: string
description: string
}
const loadedTeams: TeamOption[] = [
{ label: '고객 지원', value: 'support', description: '문의와 SLA 상태를 처리해요.' },
{ label: '성장 운영', value: 'growth', description: '캠페인과 실험 큐를 관리해요.' },
{ label: '정산 검토', value: 'billing', description: '청구, 환불, 보류 건을 확인해요.' },
]
let teams = $state<TeamOption[]>([])
let selectedTeam = $state<string[]>([])
let loading = $state(true)
let loadingTimer: ReturnType<typeof setTimeout> | null = null
const collection = $derived(
createListCollection({
items: teams,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
}),
)
const selectedOption = $derived(
teams.find((team) => team.value === selectedTeam[0]) ?? loadedTeams[0],
)
const updateSelection = (details: { value: string[] }) => {
selectedTeam = details.value
}
const loadTeams = () => {
if (loadingTimer) clearTimeout(loadingTimer)
loading = true
teams = []
selectedTeam = []
loadingTimer = setTimeout(() => {
teams = loadedTeams
selectedTeam = ['support']
loading = false
loadingTimer = null
}, 900)
}
$effect(() => {
loadTeams()
return () => {
if (loadingTimer) clearTimeout(loadingTimer)
}
})
</script>
<div class="async-select">
<div class="async-select__header">
<div>
<span class="async-select__eyebrow">담당 팀</span>
<strong>{loading ? '목록을 불러오고 있어요' : selectedOption.label}</strong>
<!-- 팝업 안의 live region은 닫혀 있으면 안 읽힌다 — 상태 알림은 항상
마운트된 sr-only status로 내보낸다 (4.1.3) -->
<span class="sr-only" role="status">
{loading ? '팀 목록을 불러오고 있어요' : `팀 목록 ${teams.length}개 로드 완료`}
</span>
</div>
<Button variant="outline" size="sm" disabled={loading} onclick={loadTeams}>다시 불러오기</Button
>
</div>
<Select.Root {collection} value={selectedTeam} onValueChange={updateSelection}>
<Select.Label>큐 소유자</Select.Label>
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder={loading ? '팀 목록을 불러오고 있어요' : '팀을 선택하세요'} />
{#if loading}
<Spinner size="sm" label="불러오는 중" />
{:else}
<Select.Indicator>▾</Select.Indicator>
{/if}
</Select.Trigger>
</Select.Control>
<Select.Positioner>
<Select.Content>
{#if loading}
<div class="async-select__loading" aria-live="polite">
<Spinner size="sm" label="불러오는 중" />
<span>팀 목록을 불러오고 있어요.</span>
</div>
{:else}
{#each collection.items as item}
<Select.Item {item} class="async-select__option">
<span>
<Select.ItemText>{item.label}</Select.ItemText>
<small>{item.description}</small>
</span>
<Select.ItemIndicator>✓</Select.ItemIndicator>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Positioner>
<Select.HiddenSelect />
</Select.Root>
</div>
<style>
.async-select {
display: grid;
width: min(100%, 28rem);
gap: var(--odbd-space-4);
}
.async-select__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--odbd-space-4);
}
.async-select__header > div {
display: grid;
min-width: 0;
gap: var(--odbd-space-1);
}
.async-select__eyebrow {
color: var(--odbd-color-muted-foreground);
font-size: var(--odbd-font-size-sm);
font-weight: 650;
}
.async-select__loading {
display: flex;
align-items: center;
gap: var(--odbd-space-2);
padding: var(--odbd-space-3);
color: var(--odbd-color-muted-foreground);
}
:global(.async-select__option) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--odbd-space-4);
}
:global(.async-select__option) span {
display: grid;
min-width: 0;
gap: var(--odbd-space-1);
}
:global(.async-select__option) small {
color: var(--odbd-color-muted-foreground);
font-size: var(--odbd-font-size-xs);
}
</style>