web: unify empty + loading states behind EmptyState/InlineLoader
Replaces ad-hoc "Loading…" text and bare empty messages with two
shared feedback primitives that carry subtle lucide icons, consistent
muted-foreground/destructive tones, and a11y signaling (role=status,
aria-busy, role=alert on destructive empties). Loading copy gains
context ("Loading photos/folders/heaps/metadata…") and the right-
sidebar idle state moves from a "ⓘ" glyph to a MousePointerClick
icon. SkeletonGrid stays as the initial-grid loader.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
84
web/src/lib/components/feedback/EmptyState.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
Shared empty / no-data placeholder. Doubles as an error display when
|
||||
`tone="destructive"` (swaps colors and announces with role=alert).
|
||||
Use `size="compact"` inside sidebars where vertical space is tight.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
icon?: Component<any> | any;
|
||||
title: string;
|
||||
description?: string;
|
||||
descriptionSnippet?: Snippet;
|
||||
align?: 'left' | 'center';
|
||||
tone?: 'muted' | 'destructive';
|
||||
size?: 'compact' | 'default';
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
descriptionSnippet,
|
||||
align,
|
||||
tone = 'muted',
|
||||
size = 'default',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const resolvedAlign = $derived(align ?? (size === 'compact' ? 'left' : 'center'));
|
||||
const isDestructive = $derived(tone === 'destructive');
|
||||
</script>
|
||||
|
||||
{#if size === 'compact'}
|
||||
<div
|
||||
class="flex gap-1.5 px-3 py-2 text-[11px] {resolvedAlign === 'center'
|
||||
? 'items-center justify-center text-center'
|
||||
: 'items-start'} {isDestructive ? 'text-destructive' : 'text-muted-foreground'}"
|
||||
role={isDestructive ? 'alert' : undefined}
|
||||
aria-live={isDestructive ? 'assertive' : undefined}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon class="h-3 w-3 shrink-0 {resolvedAlign === 'left' ? 'mt-0.5' : ''}" aria-hidden="true" />
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<span>{title}</span>
|
||||
{#if descriptionSnippet}
|
||||
<div class="mt-0.5 opacity-80">{@render descriptionSnippet()}</div>
|
||||
{:else if description}
|
||||
<div class="mt-0.5 opacity-80">{description}</div>
|
||||
{/if}
|
||||
{#if children}
|
||||
<div class="mt-1.5">{@render children()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col gap-2 p-8 {resolvedAlign === 'center'
|
||||
? 'items-center text-center'
|
||||
: 'items-start text-left'}"
|
||||
role={isDestructive ? 'alert' : undefined}
|
||||
aria-live={isDestructive ? 'assertive' : undefined}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon
|
||||
class="h-5 w-5 {isDestructive ? 'text-destructive' : 'text-muted-foreground/70'}"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
<p class="text-sm font-medium {isDestructive ? 'text-destructive' : ''}">{title}</p>
|
||||
{#if descriptionSnippet}
|
||||
<div class="max-w-prose space-y-2 text-xs text-muted-foreground">
|
||||
{@render descriptionSnippet()}
|
||||
</div>
|
||||
{:else if description}
|
||||
<p class="max-w-prose text-xs text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
{#if children}
|
||||
<div class="mt-2">{@render children()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
39
web/src/lib/components/feedback/InlineLoader.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
Tiny "Loading…" indicator: spinner + label. Use this for in-flight queries
|
||||
in sidebars, popovers, and right rails. For the initial photo-grid load,
|
||||
use SkeletonGrid instead (layout-preserving).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
label?: string;
|
||||
size?: 'sm' | 'default';
|
||||
align?: 'left' | 'center';
|
||||
srOnly?: boolean;
|
||||
polite?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
label = 'Loading…',
|
||||
size = 'default',
|
||||
align = 'left',
|
||||
srOnly = false,
|
||||
polite = true
|
||||
}: Props = $props();
|
||||
|
||||
const textSize = $derived(size === 'sm' ? 'text-[11px]' : 'text-xs');
|
||||
const iconSize = $derived(size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5');
|
||||
const padding = $derived(size === 'sm' ? 'px-3 py-2' : 'px-3 py-2');
|
||||
const justify = $derived(align === 'center' ? 'justify-center' : 'justify-start');
|
||||
</script>
|
||||
|
||||
<p
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live={polite ? 'polite' : 'off'}
|
||||
class="flex items-center gap-1.5 {padding} {textSize} {justify} text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="{iconSize} animate-spin" aria-hidden="true" />
|
||||
<span class={srOnly ? 'sr-only' : ''}>{label}</span>
|
||||
</p>
|
||||
2
web/src/lib/components/feedback/index.ts
Normal file
2
web/src/lib/components/feedback/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
export { default as InlineLoader } from './InlineLoader.svelte';
|
||||
Reference in New Issue
Block a user