hensei-web/src/lib/components/InfiniteScrollQuery.svelte
Justin Edmund aa16d58175 add tanstack query with infinite scroll support
- integrate @tanstack/svelte-query into layout
- add query client factory and query keys
- new InfiniteScrollQuery component for paginated data
- search query options for weapons/characters/summons
- update dev port to 5174
2025-11-28 11:00:57 -08:00

314 lines
6.6 KiB
Svelte

<script lang="ts">
import type { CreateInfiniteQueryResult, InfiniteData } from '@tanstack/svelte-query'
import type { Snippet } from 'svelte'
import { IsInViewport } from 'runed'
interface PageData {
results: unknown[]
page: number
totalPages: number
}
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: CreateInfiniteQueryResult<InfiniteData<PageData, any>, Error>
children: Snippet<[unknown[]]>
loadingSnippet?: Snippet
loadingMoreSnippet?: Snippet
errorSnippet?: Snippet<[Error]>
emptySnippet?: Snippet
endSnippet?: Snippet
class?: string
threshold?: number
}
let {
query,
children,
loadingSnippet,
loadingMoreSnippet,
errorSnippet,
emptySnippet,
endSnippet,
class: className = '',
threshold = 200
}: Props = $props()
// Sentinel element for intersection observation
let sentinel = $state<HTMLElement>()
// Use runed's IsInViewport for viewport detection
const inViewport = new IsInViewport(() => sentinel, {
rootMargin: `${threshold}px`
})
// Auto-fetch next page when sentinel is visible
$effect(() => {
if (
inViewport.current &&
query.hasNextPage &&
!query.isFetchingNextPage &&
!query.isLoading
) {
query.fetchNextPage()
}
})
// Flatten all pages into a single items array
const items = $derived(query.data?.pages.flatMap((page) => page.results) ?? [])
// Computed states
const isEmpty = $derived(items.length === 0 && !query.isLoading && !query.isError)
const isLoadingInitial = $derived(query.isLoading && !query.isFetchingNextPage)
const showSentinel = $derived(
!query.isLoading && query.hasNextPage && items.length > 0
)
const showEnd = $derived(
!query.hasNextPage && !isEmpty && !query.isLoading && items.length > 0
)
// Accessibility announcements
function announceToScreenReader(message: string) {
const announcement = document.createElement('div')
announcement.setAttribute('role', 'status')
announcement.setAttribute('aria-live', 'polite')
announcement.setAttribute('aria-atomic', 'true')
announcement.className = 'sr-only'
announcement.textContent = message
document.body.appendChild(announcement)
setTimeout(() => announcement.remove(), 1000)
}
$effect(() => {
if (query.isFetchingNextPage) {
announceToScreenReader('Loading more items...')
}
})
$effect(() => {
if (!query.hasNextPage && items.length > 0) {
announceToScreenReader('All items have been loaded')
}
})
</script>
<div class="infinite-scroll-container {className}">
<!-- Main content -->
{#if !isLoadingInitial}
{@render children(items)}
{/if}
<!-- Loading indicator for initial load -->
{#if isLoadingInitial}
{#if loadingSnippet}
{@render loadingSnippet()}
{:else}
<div class="loading-initial">
<span class="spinner" aria-hidden="true"></span>
<span>Loading...</span>
</div>
{/if}
{/if}
<!-- Empty state -->
{#if isEmpty}
{#if emptySnippet}
{@render emptySnippet()}
{:else}
<div class="empty-state">
<p>No items found</p>
</div>
{/if}
{/if}
<!-- Sentinel element for intersection observer -->
{#if showSentinel}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{/if}
<!-- Loading more indicator -->
{#if query.isFetchingNextPage}
{#if loadingMoreSnippet}
{@render loadingMoreSnippet()}
{:else}
<div class="loading-more" aria-busy="true">
<span class="spinner" aria-hidden="true"></span>
<span>Loading more...</span>
</div>
{/if}
{/if}
<!-- Error state with retry -->
{#if query.isError && !query.isFetchingNextPage}
{#if errorSnippet}
{@render errorSnippet(query.error)}
{:else}
<div class="error-state" role="alert">
<p>Failed to load items</p>
<button class="retry-button" onclick={() => query.refetch()} aria-label="Retry loading items">
Try Again
</button>
</div>
{/if}
{/if}
<!-- End of list indicator -->
{#if showEnd}
{#if endSnippet}
{@render endSnippet()}
{:else}
<div class="end-state">
<p>No more items to load</p>
</div>
{/if}
{/if}
<!-- Fallback load more button for accessibility -->
{#if query.hasNextPage && !query.isFetchingNextPage && !query.isLoading && items.length > 0}
<button
class="load-more-fallback"
onclick={() => query.fetchNextPage()}
aria-label="Load more items"
>
Load More
</button>
{/if}
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/layout' as *;
.infinite-scroll-container {
position: relative;
width: 100%;
}
.sentinel {
height: 1px;
margin-top: -200px;
pointer-events: none;
}
.loading-initial,
.loading-more,
.error-state,
.empty-state,
.end-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $unit-4x;
text-align: center;
gap: $unit;
}
.loading-initial,
.loading-more {
color: var(--text-secondary);
}
.error-state {
color: var(--text-error, #dc2626);
p {
margin: 0 0 $unit 0;
}
}
.empty-state,
.end-state {
color: var(--text-tertiary);
p {
margin: 0;
}
}
.spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary-color, #3366ff);
border-radius: 50%;
animation: spin 1s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 0.8;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.retry-button {
padding: $unit $unit-2x;
background: var(--button-bg, #3366ff);
color: var(--button-text, white);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: inherit;
font-family: inherit;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
&:active {
transform: translateY(1px);
}
}
.load-more-fallback {
display: block;
margin: $unit-2x auto;
padding: $unit $unit-2x;
background: var(--button-bg, #f3f4f6);
color: var(--button-text, #1f2937);
border: 1px solid var(--button-border, #e5e7eb);
border-radius: 4px;
cursor: pointer;
font-size: inherit;
font-family: inherit;
transition: all 0.2s;
// Only show for keyboard/screen reader users by default
&:not(:focus) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
&:hover {
background: var(--button-bg-hover, #e5e7eb);
}
}
// Screen reader only content
:global(.sr-only) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>