feat: add UI component library (toast, search, badges, modals, upload, table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:02:19 -05:00
co-authored by Claude Sonnet 4.6
parent ff6dc03a06
commit 7f7d4870d0
8 changed files with 239 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { headers, children }: {
headers: string[];
children: Snippet;
} = $props();
</script>
<div class="overflow-x-auto bg-white rounded-xl shadow">
<table class="w-full">
<thead>
<tr class="border-b bg-gray-50">
{#each headers as header}
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{header}</th>
{/each}
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{@render children()}
</tbody>
</table>
</div>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let { open = $bindable(false), title = 'Confirm', message = 'Are you sure?', onconfirm, oncancel }: {
open?: boolean;
title?: string;
message?: string;
onconfirm?: () => void;
oncancel?: () => void;
} = $props();
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="fixed inset-0 bg-black/50" onclick={() => { open = false; oncancel?.(); }} role="button" tabindex="-1" onkeydown={() => {}}></div>
<div class="relative bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-bold mb-2">{title}</h3>
<p class="text-gray-600 mb-6">{message}</p>
<div class="flex justify-end gap-3">
<button
onclick={() => { open = false; oncancel?.(); }}
class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
>Cancel</button>
<button
onclick={() => { open = false; onconfirm?.(); }}
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>Confirm</button>
</div>
</div>
</div>
{/if}
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
let { accept = 'image/*', multiple = false, onfiles }: {
accept?: string;
multiple?: boolean;
onfiles?: (files: FileList) => void;
} = $props();
let dragging = $state(false);
let input: HTMLInputElement;
function handleDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
if (e.dataTransfer?.files.length) {
onfiles?.(e.dataTransfer.files);
}
}
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files?.length) {
onfiles?.(target.files);
}
}
</script>
<div
class="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors
{dragging ? 'border-primary bg-primary/5' : 'border-gray-300 hover:border-primary'}"
ondragover={(e) => { e.preventDefault(); dragging = true; }}
ondragleave={() => dragging = false}
ondrop={handleDrop}
onclick={() => input.click()}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') input.click(); }}
>
<input bind:this={input} type="file" {accept} {multiple} class="hidden" onchange={handleChange} />
<p class="text-gray-500">Drop files here or click to browse</p>
</div>
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
let { page = 1, totalPages = 1, onpage }: {
page?: number;
totalPages?: number;
onpage?: (page: number) => void;
} = $props();
</script>
{#if totalPages > 1}
<nav class="flex items-center justify-center gap-2">
<button
onclick={() => onpage?.(page - 1)}
disabled={page <= 1}
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>&larr;</button>
<span class="px-3 py-1 text-sm text-gray-600">
Page {page} of {totalPages}
</span>
<button
onclick={() => onpage?.(page + 1)}
disabled={page >= totalPages}
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>&rarr;</button>
</nav>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
let { value = $bindable<number | null>(null), placeholder = 'Search for a pet...' }: {
value?: number | null;
placeholder?: string;
} = $props();
let query = $state('');
let results = $state<Array<{ id: number; name: string }>>([]);
let open = $state(false);
async function search() {
if (query.length < 2) { results = []; return; }
const res = await fetch(`/api/pets?q=${encodeURIComponent(query)}&limit=10`);
if (res.ok) {
const data = await res.json();
results = data.pets || [];
}
}
function select(pet: { id: number; name: string }) {
value = pet.id;
query = pet.name;
open = false;
}
</script>
<div class="relative">
<input
type="text"
bind:value={query}
{placeholder}
oninput={search}
onfocus={() => open = true}
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary"
/>
{#if open && results.length > 0}
<ul class="absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{#each results as pet}
<li>
<button
class="w-full px-4 py-2 text-left hover:bg-gray-50"
onclick={() => select(pet)}
>{pet.name}</button>
</li>
{/each}
</ul>
{/if}
</div>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
let { value = $bindable(''), placeholder = 'Search...', onsubmit }: {
value?: string;
placeholder?: string;
onsubmit?: (value: string) => void;
} = $props();
</script>
<form
class="relative"
onsubmit={(e) => { e.preventDefault(); onsubmit?.(value); }}
>
<input
type="text"
bind:value
{placeholder}
class="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</form>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
let { status }: { status: string } = $props();
const colorMap: Record<string, string> = {
available: 'bg-green-100 text-green-800',
adopted: 'bg-blue-100 text-blue-800',
foster: 'bg-purple-100 text-purple-800',
pending: 'bg-yellow-100 text-yellow-800',
medical_hold: 'bg-red-100 text-red-800',
behavioral_hold: 'bg-orange-100 text-orange-800',
trial: 'bg-teal-100 text-teal-800',
hospice: 'bg-pink-100 text-pink-800',
deceased: 'bg-gray-100 text-gray-800',
transferred: 'bg-gray-100 text-gray-800',
approved: 'bg-green-100 text-green-800',
denied: 'bg-red-100 text-red-800',
reviewing: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
suspended: 'bg-red-100 text-red-800'
};
const colors = $derived(colorMap[status] || 'bg-gray-100 text-gray-800');
const label = $derived(status.replace(/_/g, ' '));
</script>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium capitalize {colors}">
{label}
</span>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { getToasts, removeToast } from '$lib/toast.svelte';
const toasts = $derived(getToasts());
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-primary'
};
</script>
{#if toasts.length > 0}
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{#each toasts as toast (toast.id)}
<div class="{colors[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 max-w-sm">
<span class="flex-1">{toast.message}</span>
<button onclick={() => removeToast(toast.id)} class="text-white/80 hover:text-white">&times;</button>
</div>
{/each}
</div>
{/if}