- 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
314 lines
6.6 KiB
Svelte
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>
|