Docs/Data Components

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.

Vue
<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.

Vue
<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.

Vue
<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.

Vue
<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

PropTypeDefaultDescription
load() => Promise<T>Required. The async loader. Re-invoked on refresh () and on watch source change
immediatebooleantrueRun the loader on mount
watchWatchSource | WatchSource[]Re-run the loader when any of these change
keepDatabooleantrueKeep showing previous data during a refresh until the new data arrives
initialTnullInitial value for data before the first load resolves
minDurationnumber750Minimum milliseconds the loading state is held, even if the loader resolves sooner. 0 disables.

Slots

SlotPropsDescription
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:

TypeScript
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

Vue
<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.

Vue
<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.

Vue
<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

PropTypeDefaultDescription
load(page: number) => Promise<PagedResult<T>>Required. Called with a 1-based page number.
immediatebooleantrueRun the loader on mount
watchWatchSource | WatchSource[]Reset pagination and refetch page 1 when any source changes
initialT[][]Seed items before the first fetch resolves
minDurationnumber750Minimum ms the initial loading state is held. Page 1 only.
sentinelMarginstring'0px 0px 120px 0px'rootMargin for the IntersectionObserver

Slots

SlotPropsDescription
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.

Vue
<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:

Vue
<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).

Vue
<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

PropTypeDefaultDescription
sourceRealmSlugRequired. Realm submitting the feedback.
placement'bottom-right' | 'bottom-left' | 'inline''bottom-right'inline renders only the trigger button, suitable for navbars.
labelstring'Feedback'Trigger label. Shown inline; used as aria-label and revealed-on-hover label on the floating tab.
contextRecord<string, unknown>{}Free-form metadata stored alongside the feedback.
routestringwindow.location.pathnameRoute override.
paletteSparkPalettepalettes.emberPalette scoped to the modal.

Events

EventPayloadDescription
submitFeedbackKindFires after a successful submission with the chosen kind ('bug', 'idea', 'praise', 'other').

Behavior

  • Auth-aware. Logged-in users skip the email field — user_id and player_id come 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.

TypeScript
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.

TypeScript
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.

Default browser scrollbar

  • #1Inferno
  • #2Ember
  • #3Bellows
  • #4Crucible
  • #5Hearth
  • #6Pyre
  • #7Cinder
  • #8Spark
  • #9Tinder
  • #10Anvil
  • #11Forge
  • #12Smelter
  • #13Bloom
  • #14Slag
  • #15Quench
  • #16Temper
  • #17Rivet
  • #18Hammer
  • #19Mortar
  • #20Kiln
  • #21Foundry
  • #22Ironworks
  • #23Bellower
  • #24Striker
  • #25Quencher
  • #26Gilder
  • #27Smith
  • #28Wrought
  • #29Brazier
  • #30Ingot

SparkScrollbar

  • #1Inferno
  • #2Ember
  • #3Bellows
  • #4Crucible
  • #5Hearth
  • #6Pyre
  • #7Cinder
  • #8Spark
  • #9Tinder
  • #10Anvil
  • #11Forge
  • #12Smelter
  • #13Bloom
  • #14Slag
  • #15Quench
  • #16Temper
  • #17Rivet
  • #18Hammer
  • #19Mortar
  • #20Kiln
  • #21Foundry
  • #22Ironworks
  • #23Bellower
  • #24Striker
  • #25Quencher
  • #26Gilder
  • #27Smith
  • #28Wrought
  • #29Brazier
  • #30Ingot
Vue
<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:

Vue
<ul class="my-list spark-scrollbar" style="overflow-y: auto; max-height: 20rem;">…</ul>

Props

PropTypeDefaultDescription
maxHeightnumber | stringCap on the scroll container's height. Numbers are pixels; strings pass through (e.g. '50vh', '24rem').
maxWidthnumber | stringCap on width.
axis'y' | 'x' | 'both''y'Which axis scrolls. The other is overflow: hidden.
tagstring'div'HTML tag for the wrapper.

Cross-browser

  • Firefox: uses scrollbar-width: thin + scrollbar-color. Always rendered.
  • Chromium / WebKit: ::-webkit-scrollbar rules, 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.