Data Components
Components that wrap content with loading, pagination, or capture flows.
SparkAsync
Handles loading state, error display, and retry in one component. Pass an async loader; the component runs it and renders the right state.
<template>
<SparkAsync :load="loadUsers">
<template #default="{ data, refresh }">
<ul>
<li v-for="user in data" :key="user.id">{{ user.name }}</li>
</ul>
<button class="kf-btn-ghost" @click="refresh">Reload</button>
</template>
</SparkAsync>
</template>
<script setup>
import { SparkAsync, useApi } from '@katforge/spark';
const api = useApi ();
const loadUsers = () => api.users.list ();
</script>
The default loading state is a centered brand spinner; the default error state shows the error message with a Retry button. Override either with the loading or error slot.
<SparkAsync :load="loadUsers">
<template #loading>
<div class="py-12 text-center text-kf-muted">Fetching users…</div>
</template>
<template #error="{ error, retry }">
<div class="kf-card-glass p-4">
<p class="text-kf-error mb-3">{{ error.message }}</p>
<SparkButton variant="ghost" @click="retry">Try again</SparkButton>
</div>
</template>
<template #default="{ data }">
<UserTable :users="data" />
</template>
</SparkAsync>
Re-fetching on dependency change
Pass any reactive source (or array of sources) to watch. The loader re-runs whenever the source changes. Combine with keepData (default true) to keep showing the previous result while the new one fetches, useful for filters and pagination.
<template>
<SparkInput v-model="query" placeholder="Search…" />
<SparkAsync :load="() => kf.search (query)" :watch="query">
<template #default="{ data, loading }">
<div :class="{ 'opacity-50': loading }">
<SearchResult v-for="r in data" :key="r.id" :result="r" />
</div>
</template>
</SparkAsync>
</template>
Manual mode and empty state
Set :immediate="false" to skip the auto-load on mount. Provide an empty slot to render when the loader resolves with an empty array.
<SparkAsync :load="loadReport" :immediate="false">
<template #default="{ data, refresh }">
<SparkButton v-if="!data" @click="refresh">Generate report</SparkButton>
<ReportView v-else :report="data" />
</template>
</SparkAsync>
<SparkAsync :load="() => kf.notifications.list ()">
<template #empty="{ refresh }">
<p class="text-kf-muted">No notifications yet.</p>
<SparkButton variant="ghost" @click="refresh">Check again</SparkButton>
</template>
<template #default="{ data }">
<NotificationRow v-for="n in data" :key="n.id" :notification="n" />
</template>
</SparkAsync>
A fast API response can flash the spinner so briefly that the UI feels jittery. SparkAsync holds the loading state for at least minDuration ms (default 750) before settling. Set :min-duration="0" to opt out, or raise it for endpoints where a longer pause feels intentional.
Animated transitions
Every state change (loading → content, content → empty, error → loading on retry) animates with a subtle 200 ms fade + 4 px slide. SparkAsync wraps its rendered state in a single <div class="spark-async-frame"> so the transition has a stable element to animate. If you need a fragment-style render with no wrapper, drop down to useAsync ().
Props
| Prop | Type | Default | Description |
|---|---|---|---|
load | () => Promise<T> | — | Required. The async loader. Re-invoked on refresh () and on watch source change |
immediate | boolean | true | Run the loader on mount |
watch | WatchSource | WatchSource[] | — | Re-run the loader when any of these change |
keepData | boolean | true | Keep showing previous data during a refresh until the new data arrives |
initial | T | null | Initial value for data before the first load resolves |
minDuration | number | 750 | Minimum milliseconds the loading state is held, even if the loader resolves sooner. 0 disables. |
Slots
| Slot | Props | Description |
|---|---|---|
default | { data, refresh, loading, error } | Rendered once data is available |
loading | {} | Shown during the first load. Defaults to a centered brand spinner |
error | { error, retry } | Shown when the loader rejects and no data is available |
empty | { refresh } | Optional. Shown when data is an empty array |
SparkAsyncPaged
SparkAsync's paginated sibling. Same loading, error, empty, and content states, plus an accumulated items array, a hasMore cursor, and IntersectionObserver-powered automatic page loading.
Use SparkAsyncPaged when the backend returns pages and the UI should append more as the user scrolls. For a single one-shot fetch, reach for SparkAsync.
Scroll the list above. The sentinel at the end of the rows triggers loadMore () as soon as it enters the viewport, so the next page appends without a click. The "Loading more…" row appears between pages; the "End of leaderboard" row settles in once hasMore flips to false.
Loader contract
Your load function takes a 1-based page number and returns a PagedResult:
interface PagedResult <T> {
items: T [];
hasMore: boolean;
total?: number;
}
The component owns the accumulator: items is the flat list of everything loaded so far, not just the most recent page. Refreshes reset it; loadMore () appends.
Basic usage
<template>
<SparkAsyncPaged
:load="loadLeaderboardPage"
:watch="query"
:min-duration="1000"
>
<template #default="{ items, loadingMore, hasMore, sentinelRef }">
<div class="kf-card !p-0 overflow-y-auto max-h-96">
<LeaderboardRow
v-for="entry in items"
:key="entry.rank"
:entry="entry"
/>
<div :ref="sentinelRef" />
<div v-if="loadingMore" class="flex justify-center py-3">
<SparkSpinner :size="16" />
</div>
<div v-else-if="!hasMore" class="flex justify-center py-2">
<p class="text-xs text-kf-muted">End of leaderboard</p>
</div>
</div>
</template>
<template #empty>
<p class="text-kf-muted py-6 text-center">No players yet.</p>
</template>
</SparkAsyncPaged>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { SparkAsyncPaged, SparkSpinner, useApi } from '@katforge/spark';
import type { PagedResult } from '@katforge/spark';
const api = useApi ();
const query = ref ('');
async function loadLeaderboardPage (page: number): Promise <PagedResult <Entry>> {
const { entries, has_more, total } = await api.leaderboard.list ({ page, query: query.value });
return { items: entries, hasMore: has_more, total };
}
</script>
sentinelRef is a function template-ref. Attach it to any element at the end of your list (typically an invisible <div />), and the component fires loadMore () whenever that element scrolls into view. No scroll handlers, no pixel math.
Resetting on filter / tab change
Pass a reactive source (or array of sources) to watch. The loader reruns for page 1, the accumulator resets, pagination starts over. Debounce search inputs yourself if you don't want a request per keystroke.
<SparkAsyncPaged
:load="(page) => kf.users.search ({ q: query, tab: activeTab, page })"
:watch="[ query, activeTab ]"
>…</SparkAsyncPaged>
Manual pagination
You don't have to use the observer. Omit the sentinel element and call loadMore () yourself, e.g. from a "Show more" button.
<SparkAsyncPaged :load="loadReports">
<template #default="{ items, loadMore, loadingMore, hasMore }">
<ReportCard v-for="r in items" :key="r.id" :report="r" />
<SparkButton
v-if="hasMore"
variant="ghost"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? 'Loading…' : 'Show more' }}
</SparkButton>
</template>
</SparkAsyncPaged>
Like SparkAsync, the initial load holds the loading state for minDuration ms (default 750). Subsequent loadMore () appends don't — they should feel instant.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
load | (page: number) => Promise<PagedResult<T>> | — | Required. Called with a 1-based page number. |
immediate | boolean | true | Run the loader on mount |
watch | WatchSource | WatchSource[] | — | Reset pagination and refetch page 1 when any source changes |
initial | T[] | [] | Seed items before the first fetch resolves |
minDuration | number | 750 | Minimum ms the initial loading state is held. Page 1 only. |
sentinelMargin | string | '0px 0px 120px 0px' | rootMargin for the IntersectionObserver |
Slots
| Slot | Props | Description |
|---|---|---|
default | { items, total, page, hasMore, loading, loadingMore, error, refresh, loadMore, sentinelRef } | Rendered once any data is available |
loading | {} | First-load spinner. Defaults to a centered brand spinner |
error | { error, retry } | Shown when the initial load rejects and no items are present |
empty | { refresh } | Optional. Shown when the loader resolves with zero items |
For full programmatic control, use usePagedAsync () directly.
SparkFeedback
Drop-in feedback collector for games and sites. Renders a translucent floating bubble in a screen corner that opaques on hover, opening a kind-tagged feedback modal (Bug / Idea / Praise / Other). Submits to the shared /v1/leads/feedback endpoint via the @katforge/api — bearer token is auto-forwarded for logged-in callers, so the server stamps user_id and player_id automatically. Guests can attach an optional reply-to email.
Pick a placement — the bubble pins to the corner of your viewport (try resting then hovering it). Pick "Inline" to render the trigger here in the demo.
<template>
<RouterView />
<SparkFlashContainer />
<!-- One line at app root — the bubble lives over every page -->
<SparkFeedback :source="Realms.stumper" />
</template>
<script setup>
import { Realms, SparkFeedback, SparkFlashContainer } from '@katforge/spark';
</script>
The source prop is typed against the Realms const so the right realm is always sent. Pass the value through Realms.<slug> for autocomplete and refactor safety — typos become compile errors instead of silently bad data on the server.
For navbars, set placement="inline" to render just the trigger so consumers can place it inside their existing layout:
<header class="navbar">
<SparkLogo />
<nav>…</nav>
<SparkFeedback :source="Realms.katforge" placement="inline" label="Feedback" />
<SparkUserMenu />
</header>
Pass context to attach app state for triage. The component stamps route with window.location.pathname automatically; pass route explicitly to override (e.g. when using a virtual router).
<SparkFeedback
:source="Realms.stumper"
:context="{
mode: game.mode,
difficulty: game.difficulty,
session_score: game.score,
}"
:route="route.fullPath"
/>
For the storage shape, retention, and triage behavior, see the API endpoint reference.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
source | RealmSlug | — | Required. Realm submitting the feedback. |
placement | 'bottom-right' | 'bottom-left' | 'inline' | 'bottom-right' | inline renders only the trigger button, suitable for navbars. |
label | string | 'Feedback' | Trigger label. Shown inline; used as aria-label and revealed-on-hover label on the floating tab. |
context | Record<string, unknown> | {} | Free-form metadata stored alongside the feedback. |
route | string | window.location.pathname | Route override. |
palette | SparkPalette | palettes.ember | Palette scoped to the modal. |
Events
| Event | Payload | Description |
|---|---|---|
submit | FeedbackKind | Fires after a successful submission with the chosen kind ('bug', 'idea', 'praise', 'other'). |
Behavior
- Auth-aware. Logged-in users skip the email field —
user_idandplayer_idcome from the bearer token. Guests see an optional email field for follow-up. - Tab-style at rest. The floating trigger bulges past the viewport edge so only the inner half is visible. On hover or focus it slides fully into the viewport, opaques up, and reveals the label.
- Per-kind color. Red for Bug, amber for Idea, pink for Praise, indigo for Other.
- Rate limited. 5 submissions per minute per IP, mirroring the contact form throttle.
Imperative API
Mount one <SparkFeedback> at app root and any other Spark component (or your own code) can drive the modal via the useFeedback composable. SparkUserMenu's built-in Feedback menu item already does this.
import { useFeedback } from '@katforge/spark';
const feedback = useFeedback ();
feedback.value?.open (); // open the modal
feedback.value?.open ('bug'); // preselect a kind
feedback.value?.close ();
feedback.value?.toggle ();
useFeedback () returns Ref<FeedbackHandle | null>. The ref is null until a <SparkFeedback> mounts somewhere in the app, so callers should hide their own affordance or fall back to a link when the value is null.
Realms enum
Spark ships a Realms const + matching RealmSlug type. Use it for source instead of hand-typing strings — autocomplete prevents typos, and the same list is enforced server-side.
import { Realms } from '@katforge/spark';
Realms.katforge // 'katforge'
Realms.stumper // 'stumper'
Realms.lextris // 'lextris'
Realms.goblins // 'goblins'
Realms.wc3stats // 'wc3stats'
Realms.darkerdb // 'darkerdb'
REALM_SLUGS is the runtime list (ReadonlyArray<RealmSlug>), useful for iteration or validation.
SparkScrollbar
Slim scroll container with the KATforge-styled scrollbar. Replaces the default browser scrollbar (which renders white on dark backgrounds across most platforms) with a low-contrast track and a brand-tinted thumb on hover. Used internally by SparkCombobox, SparkSelect, and SparkUserMenu; reach for it directly whenever you need a scrolling region inside dark Spark surfaces.
<template>
<SparkScrollbar :max-height="320">
<ul>
<li v-for="entry in entries" :key="entry.id">{{ entry.label }}</li>
</ul>
</SparkScrollbar>
</template>
The styling lives as a public utility class in katforge.css, so existing scrolling elements can adopt the look without an extra wrapper:
<ul class="my-list spark-scrollbar" style="overflow-y: auto; max-height: 20rem;">…</ul>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
maxHeight | number | string | — | Cap on the scroll container's height. Numbers are pixels; strings pass through (e.g. '50vh', '24rem'). |
maxWidth | number | string | — | Cap on width. |
axis | 'y' | 'x' | 'both' | 'y' | Which axis scrolls. The other is overflow: hidden. |
tag | string | 'div' | HTML tag for the wrapper. |
Cross-browser
- Firefox: uses
scrollbar-width: thin+scrollbar-color. Always rendered. - Chromium / WebKit:
::-webkit-scrollbarrules, 8 px wide, transparent track, thumb starts at 14% white and brightens to brand primary at 55% on hover. - Both: the bar respects the container's
border-radius, so rounded panels don't get a square scrollbar bleeding past the corner.