managarten/packages/shared-ui/src/organisms/BaseListView.svelte
Till JS 248100d490 fix(web): remove hardcoded white text, use theme tokens for light mode
PageShell header icon/title had opacity: 0.5 — removed for full
visibility. Moodlit, Zitare, Skilltree and BaseListView used
text-white/* classes that were invisible in light mode — migrated
to hsl(var(--color-foreground/muted-foreground)) tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:07:40 +02:00

101 lines
2.8 KiB
Svelte

<script lang="ts" generics="T">
/**
* BaseListView — shared scaffolding for module ListView components.
*
* Encodes the workbench convention every Mana module's ListView shares:
* wrapper padding → optional stats header → scrollable item region → empty state.
*
* Per-item rendering and data fetching stay with the consumer:
* - Pass `items` (already filtered & decrypted via queries.ts).
* - Provide an `item` snippet that renders one row.
* - Provide an optional `header` snippet for stat counts or filters.
*
* @example
* ```svelte
* <BaseListView items={sorted} getKey={(q) => q.id} emptyTitle="Keine Fragen">
* {#snippet header()}
* <span>{questions.length} Fragen</span>
* {/snippet}
* {#snippet item(question)}
* <button onclick={() => navigate('detail', { id: question.id })}>
* {question.title}
* </button>
* {/snippet}
* </BaseListView>
* ```
*/
import type { Snippet } from 'svelte';
import { EmptyState } from '../molecules';
interface Props<TItem> {
/** Items to render. Should already be filtered (deletedAt) and decrypted. */
items: TItem[];
/** Stable key extractor for the {#each} block. */
getKey: (item: TItem) => string | number;
/** Snippet that renders a single item row. */
item: Snippet<[TItem, number]>;
/** Optional header snippet (e.g. stat counts, filters). */
header?: Snippet;
/** Optional snippet rendered above the items but inside the scroll area. */
listHeader?: Snippet;
/** Optional snippet rendered at the very top, outside the scroll area (toolbar, voice bar, ...). */
toolbar?: Snippet;
/** Empty-state title. */
emptyTitle?: string;
/** Empty-state message. */
emptyMessage?: string;
/** Custom empty-state icon snippet. */
emptyIcon?: Snippet;
/** Override the entire empty area. */
empty?: Snippet;
/** Optional outer class override. */
class?: string;
/** Optional class for the inner scroll/list area. Use this to switch to grid, etc. */
listClass?: string;
}
let {
items,
getKey,
item,
header,
listHeader,
toolbar,
emptyTitle = 'Nichts hier',
emptyMessage,
emptyIcon,
empty,
class: className = '',
listClass = '',
}: Props<T> = $props();
</script>
<div class="flex h-full flex-col gap-3 p-3 sm:p-4 {className}">
{#if toolbar}
{@render toolbar()}
{/if}
{#if header}
<div class="flex gap-3 text-xs text-[hsl(var(--color-muted-foreground))]">
{@render header()}
</div>
{/if}
<div class="flex-1 overflow-auto {listClass}">
{#if listHeader}
{@render listHeader()}
{/if}
{#each items as entry, i (getKey(entry))}
{@render item(entry, i)}
{/each}
{#if items.length === 0}
{#if empty}
{@render empty()}
{:else}
<EmptyState variant="compact" title={emptyTitle} message={emptyMessage} icon={emptyIcon} />
{/if}
{/if}
</div>
</div>