194 lines
9 KiB
Markdown
194 lines
9 KiB
Markdown
# Search Sidebar Refactor Plan (Infinite Scroll + Legacy Parity)
|
||
|
||
This plan upgrades `src/lib/components/panels/SearchSidebar.svelte` to support infinite-scrolling search with cancellable requests, modular components, and accessible UX. It also aligns result content with our previous app’s components so we show the most useful fields.
|
||
|
||
Relevant packages to install:
|
||
https://runed.dev/docs/getting-started
|
||
https://runed.dev/docs/utilities/resource
|
||
|
||
## Goals
|
||
- Smooth, infinite-scrolling search with debounced input and request cancellation.
|
||
- Modular, testable components (header, filters, list, items).
|
||
- Strong accessibility and keyboard navigation.
|
||
- Reuse legacy field choices for results (image, name, uncap, tags).
|
||
|
||
## Legacy Result Fields (from hensei-web)
|
||
Reference: `../hensei-web/components/*Result`
|
||
- Common
|
||
- Thumbnail (grid variant), localized name.
|
||
- `UncapIndicator` with FLB/ULB/Transcendence (stage displayed as 5 in old UI).
|
||
- Element tag.
|
||
- Weapons
|
||
- Proficiency tag.
|
||
- Summons
|
||
- “Subaura” badge (if applicable/available from API).
|
||
- Characters
|
||
- Respect `special` flag for `UncapIndicator`.
|
||
- Implementation
|
||
- Use our image helper (`get*Image`) and placeholders; do not hardcode paths.
|
||
|
||
## Data Flow
|
||
- Unified adapter: `searchResource(type, params, { signal })` wraps current resource-specific search functions and normalizes the output to `{ results, total, nextCursor? }`.
|
||
- Debounce input: 250–300ms.
|
||
- Cancellation: AbortController cancels prior request when query/filters/cursor change.
|
||
- Single orchestrating effect: listens to `open`, `type`, `debouncedQuery`, and `filters`. Resets state and triggers initial fetch.
|
||
|
||
## Infinite Scrolling
|
||
- IntersectionObserver sentinel at list bottom triggers `loadMore()` when visible.
|
||
- Guards: `isLoading`, `hasMore`, `error` prevent runaway loads.
|
||
- Use Runed's `resource()` for fetching (Svelte 5‑native) instead of TanStack Query:
|
||
- Reactive key: `{ type, query: debouncedQuery, filters, page }`.
|
||
- Resource fn receives `AbortSignal` for cancellation.
|
||
- `keepPreviousValue: true` to avoid flicker on refresh.
|
||
- Append or replace items based on `page`.
|
||
|
||
### Runed setup
|
||
- Install: `pnpm add runed`
|
||
- Import where needed: `import { resource } from 'runed'`
|
||
- No extra config required; works seamlessly with Svelte 5 runes ($state/$derived/$effect).
|
||
|
||
## State Orchestration
|
||
- State: `items[]`, `isLoading`, `error`, `hasMore`, `page`, `debouncedQuery`, `filters`, `open`.
|
||
- Reset state on open/type/query/filters change; cancel in-flight; fetch page 1.
|
||
- Persist last-used filters per type in localStorage and restore on open.
|
||
- Optional lightweight cache: `Map<string, { items, page, hasMore, timestamp }>` keyed by `{ type, query, filters }` with small TTL.
|
||
|
||
## UX & Accessibility
|
||
- Loading skeletons for image/title/badges.
|
||
- Error state with Retry and helpful copy.
|
||
- Empty state: “No results — try widening filters.”
|
||
- Keyboard
|
||
- Focus trap when open; restore focus to trigger on close.
|
||
- Arrow Up/Down to move highlight; Enter to select; Escape to close.
|
||
- `aria-live=polite` announcements for loading and result counts.
|
||
- Performance
|
||
- Pre-fetch next page when 60–70% scrolled (if not using IO sentinel aggressively).
|
||
- Virtualization as a stretch goal if lists become large.
|
||
|
||
## Componentization
|
||
- `SearchSidebarHeader.svelte`: search input (debounced), close button, result count.
|
||
- `FilterGroup.svelte`: Element, Rarity, Proficiency; emits `{ element?: number[], rarity?: number[], proficiency1?: number[] }`.
|
||
- Result items (choose either specialized components or generic + slots)
|
||
- `WeaponResultItem.svelte`: image, name, UncapIndicator, [Element, Proficiency].
|
||
- `SummonResultItem.svelte`: image, name, UncapIndicator, [Element, Subaura?].
|
||
- `CharacterResultItem.svelte`: image, name, UncapIndicator(special), [Element].
|
||
- `ResultList.svelte`: renders items, manages sentinel and keyboard focus.
|
||
- `searchResource.ts`: adapter normalizing current API responses.
|
||
|
||
## Normalized API Contract
|
||
- Input
|
||
- `type: 'weapon' | 'character' | 'summon'`
|
||
- `query?: string`
|
||
- `filters: { element?: number[]; rarity?: number[]; proficiency1?: number[] }`
|
||
- `cursor?: { page?: number; perPage?: number }` (or token-ready for future)
|
||
- `signal: AbortSignal`
|
||
- Output
|
||
- `{ results: any[]; total: number; nextCursor?: { page?: number } }`
|
||
|
||
## Tasks (Phased)
|
||
|
||
### Phase 1 — Infra & UX Baseline
|
||
- [ ] Add `searchResource` adapter + types; normalize outputs.
|
||
- [ ] Debounce input and wire AbortController for cancellation.
|
||
- [ ] Consolidate effects; initialize/reset state predictably.
|
||
- [ ] Implement infinite scroll via IntersectionObserver; add guards.
|
||
- [ ] Add skeleton, error, and empty states (minimal styles).
|
||
|
||
### Phase 2 — Componentization & Fields
|
||
- [ ] Build `FilterGroup` (Element, Rarity, Proficiency) and emit filters.
|
||
- [ ] Implement `WeaponResultItem`, `SummonResultItem`, `CharacterResultItem` with fields per legacy.
|
||
- [ ] Extract `SearchSidebarHeader` (input, count, close) and `ResultList` (items + sentinel).
|
||
|
||
### Phase 3 — A11y & Keyboard
|
||
- [ ] Add focus trap and restore focus on close.
|
||
- [ ] Apply listbox/option roles; `aria-live` for loading/count.
|
||
- [ ] Arrow/Enter/Escape handlers; scroll highlighted item into view.
|
||
|
||
### Phase 4 — Persistence & Performance
|
||
- [ ] Persist filters per type in localStorage; hydrate on open.
|
||
- [ ] Optional: add small in-memory cache keyed by `{ type, query, filters }` (TTL 2–5 min).
|
||
- [ ] Optional: prefetch next page on near-end scroll.
|
||
- [ ] Optional: list virtualization if needed.
|
||
|
||
## Example: resource() Outline (Runed)
|
||
```
|
||
// Debounce query (250–300ms)
|
||
let query = $state('')
|
||
let debouncedQuery = $state('')
|
||
let debounceTimer: any
|
||
$effect(() => {
|
||
clearTimeout(debounceTimer)
|
||
debounceTimer = setTimeout(() => (debouncedQuery = query.trim()), 280)
|
||
})
|
||
|
||
// Paging + items
|
||
let page = $state(1)
|
||
let items: any[] = $state([])
|
||
let hasMore = $state(true)
|
||
|
||
const params = $derived(() => ({ type, query: debouncedQuery, filters, page }))
|
||
|
||
import { resource } from 'runed'
|
||
const searchRes = resource(
|
||
params,
|
||
async (p, ctx) => searchResource(p.type, { query: p.query, filters: p.filters, page: p.page, perPage: 20 }, { signal: ctx.signal }),
|
||
{ keepPreviousValue: true }
|
||
)
|
||
|
||
$effect(() => {
|
||
const val = searchRes.value
|
||
if (!val) return
|
||
if (page === 1) items = val.results
|
||
else items = [...items, ...val.results]
|
||
hasMore = val.nextCursor?.page ? true : false // or derive from total_pages
|
||
})
|
||
|
||
// IntersectionObserver sentinel triggers loadMore
|
||
function onIntersect() {
|
||
if (!searchRes.loading && hasMore) page += 1
|
||
}
|
||
```
|
||
|
||
### Phase 5 — Tests & Polish
|
||
- [ ] Unit tests for adapter (debounce + abort cases).
|
||
- [ ] Interaction tests (keyboard nav, infinite scroll, retry/reload).
|
||
- [ ] Visual QA: confirm result item content matches legacy intent.
|
||
|
||
## Acceptance Criteria
|
||
- Infinite scrolling with smooth loading; no duplicate/stale requests.
|
||
- Results show image, name, uncap, and tags mirroring legacy components.
|
||
- Accessible: screen-reader friendly, keyboard navigable, focus managed.
|
||
- Filters and results are modular and easily testable.
|
||
- Caching (or local persistence) makes repeat searches feel instant.
|
||
|
||
## Notes / Risks
|
||
- svelte-query adds a dependency; keep adapter thin to allow opting in later.
|
||
- Subaura badge requires API support; if not present, hide or infer conservatively.
|
||
- Virtualization is optional; only implement if list length causes perf issues.
|
||
|
||
## Extending To Teams Explore
|
||
|
||
The same resource-based, infinite-scroll pattern should power `src/routes/teams/explore/+page.svelte` to keep UX and tech consistent.
|
||
|
||
Guidelines:
|
||
- Shared primitives
|
||
- Reuse the resource() orchestration, IntersectionObserver sentinel, and list state (`items`, `page`, `hasMore`, `isLoading`, `error`).
|
||
- Reuse skeleton, empty, and error components/styles for visual consistency.
|
||
- Optional: extract a tiny `use:intersect` action and a generic `InfiniteList.svelte` wrapper.
|
||
- Explore adapter
|
||
- Create `exploreResource(params, { signal })` that normalizes `{ results, total, nextCursor }` from the teams listing endpoint.
|
||
- Inputs: `query?`, `filters?` (element, tags), `sort?` (newest/popular), `page`, `perPage`.
|
||
- SSR-friendly
|
||
- `+page.server.ts` fetches `page=1` for SEO and first paint; client continues with infinite scroll.
|
||
- Initialize client state from server data; enable `keepPreviousValue` to avoid flicker on hydration.
|
||
- URL sync
|
||
- Reflect `query`, `filters`, `sort`, and `page` in search params. On mount, hydrate state from the URL.
|
||
- Improves shareability and back/forward navigation.
|
||
- Cards and filters
|
||
- Implement `TeamCard.svelte` (thumbnail, name/title, owner, likes, updatedAt, element/tags) with a matching skeleton card.
|
||
- Build `TeamsFilterGroup.svelte` mirroring the sidebar’s `FilterGroup.svelte` experience.
|
||
- Performance
|
||
- Lazy-load images with `loading="lazy"` and `decoding="async"`; consider prefetching page+1 on near-end scroll.
|
||
- Virtualization only if card density leads to perf issues.
|
||
|
||
By following these conventions, the search sidebar and explore page share the same mental model, enabling rapid iteration and less bespoke code per page.
|