feat: add form and layout components to shared-ui (Tier 6b)

New molecules:
- ModalFooter: Standardized modal footer with button layout
- DataCard: Generic card for displaying data items (memos, decks, etc.)
- PageHeader: Standardized page header with title, description, actions

New organisms:
- FormModal: Modal with built-in form handling, validation, loading states

All components use Svelte 5 snippets for flexible slot patterns.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 00:36:12 +01:00
parent 3c457f9c18
commit 5045d70bf7
7 changed files with 505 additions and 1 deletions

View file

@ -20,8 +20,11 @@ export { SkeletonBox, SkeletonText } from './molecules';
// Feedback
export { EmptyState } from './molecules';
// Layout
export { ModalFooter, DataCard, PageHeader } from './molecules';
// Organisms
export { Modal, ConfirmationModal, AppSlider } from './organisms';
export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms';
export type { AppItem } from './organisms';
// Navigation

View file

@ -0,0 +1,154 @@
<script lang="ts">
/**
* DataCard - Generic card for displaying data items
*
* Used for displaying items like memos, decks, blueprints, etc.
* Provides consistent layout with title, description, metadata, and actions.
*
* @example Basic usage
* ```svelte
* <DataCard
* title="My Deck"
* description="A collection of flashcards"
* onclick={() => openDeck(deck.id)}
* />
* ```
*
* @example With metadata and actions
* ```svelte
* <DataCard title={memo.title} description={memo.summary}>
* {#snippet metadata()}
* <span>5 min ago</span>
* <Badge>Audio</Badge>
* {/snippet}
* {#snippet actions()}
* <Button variant="ghost" size="sm" onclick={edit}>Edit</Button>
* <Button variant="ghost" size="sm" onclick={del}>Delete</Button>
* {/snippet}
* </DataCard>
* ```
*/
import type { Snippet } from 'svelte';
import { Text } from '../atoms';
type CardVariant = 'default' | 'elevated' | 'outlined' | 'ghost';
interface Props {
/** Card title */
title: string;
/** Card description/subtitle */
description?: string;
/** Card variant */
variant?: CardVariant;
/** Whether card is interactive (clickable) */
interactive?: boolean;
/** Click handler */
onclick?: () => void;
/** Icon/thumbnail snippet (left side) */
icon?: Snippet;
/** Metadata snippet (below description) */
metadata?: Snippet;
/** Actions snippet (right side or bottom) */
actions?: Snippet;
/** Badge/status snippet (top right) */
badge?: Snippet;
/** Additional CSS classes */
class?: string;
}
let {
title,
description,
variant = 'default',
interactive = false,
onclick,
icon,
metadata,
actions,
badge,
class: className = ''
}: Props = $props();
const variantClasses: Record<CardVariant, string> = {
default: 'bg-menu border border-theme',
elevated: 'bg-menu border border-theme shadow-md',
outlined: 'bg-transparent border-2 border-theme',
ghost: 'bg-transparent border-transparent hover:bg-menu-hover'
};
const isClickable = $derived(interactive || !!onclick);
</script>
<div
class="data-card rounded-xl p-4 transition-colors {variantClasses[variant]} {isClickable
? 'cursor-pointer hover:bg-menu-hover'
: ''} {className}"
onclick={onclick}
onkeydown={(e) => {
if (isClickable && onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick();
}
}}
role={isClickable ? 'button' : undefined}
tabindex={isClickable ? 0 : undefined}
>
<div class="flex items-start gap-3">
<!-- Icon/Thumbnail -->
{#if icon}
<div class="data-card__icon flex-shrink-0">
{@render icon()}
</div>
{/if}
<!-- Content -->
<div class="data-card__content flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<!-- Title -->
<Text variant="body" weight="semibold" class="truncate">
{title}
</Text>
<!-- Description -->
{#if description}
<Text variant="muted" class="mt-1 line-clamp-2">
{description}
</Text>
{/if}
</div>
<!-- Badge -->
{#if badge}
<div class="data-card__badge flex-shrink-0">
{@render badge()}
</div>
{/if}
</div>
<!-- Metadata -->
{#if metadata}
<div class="data-card__metadata mt-2 flex items-center gap-2 text-sm text-theme-secondary">
{@render metadata()}
</div>
{/if}
</div>
<!-- Actions -->
{#if actions}
<div class="data-card__actions flex-shrink-0 flex items-center gap-1" onclick={(e) => e.stopPropagation()}>
{@render actions()}
</div>
{/if}
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,85 @@
<script lang="ts">
/**
* ModalFooter - Standardized modal footer with button layout
*
* Provides consistent button arrangement for modal dialogs.
*
* @example Basic cancel/confirm
* ```svelte
* <ModalFooter
* cancelLabel="Cancel"
* confirmLabel="Save"
* onCancel={() => close()}
* onConfirm={() => save()}
* />
* ```
*
* @example With loading state
* ```svelte
* <ModalFooter
* confirmLabel="Deleting..."
* confirmVariant="danger"
* loading={isDeleting}
* onCancel={close}
* onConfirm={handleDelete}
* />
* ```
*/
import { Button } from '../atoms';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'outline';
interface Props {
/** Cancel button label (omit to hide) */
cancelLabel?: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button callback */
onCancel?: () => void;
/** Confirm button callback */
onConfirm?: () => void | Promise<void>;
/** Confirm button variant */
confirmVariant?: ButtonVariant;
/** Whether confirm action is loading */
loading?: boolean;
/** Disable all buttons */
disabled?: boolean;
/** Alignment of buttons */
align?: 'start' | 'center' | 'end' | 'between';
/** Additional CSS classes */
class?: string;
}
let {
cancelLabel = 'Cancel',
confirmLabel = 'Confirm',
onCancel,
onConfirm,
confirmVariant = 'primary',
loading = false,
disabled = false,
align = 'end',
class: className = ''
}: Props = $props();
const alignClasses: Record<string, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between'
};
</script>
<div class="modal-footer flex gap-3 {alignClasses[align]} {className}">
{#if cancelLabel && onCancel}
<Button variant="ghost" onclick={onCancel} disabled={disabled || loading}>
{cancelLabel}
</Button>
{/if}
{#if confirmLabel && onConfirm}
<Button variant={confirmVariant} onclick={onConfirm} {loading} disabled={disabled}>
{confirmLabel}
</Button>
{/if}
</div>

View file

@ -0,0 +1,139 @@
<script lang="ts">
/**
* PageHeader - Standardized page header layout
*
* Provides consistent page title, description, and action buttons layout.
*
* @example Basic usage
* ```svelte
* <PageHeader title="My Memos" />
* ```
*
* @example With description and actions
* ```svelte
* <PageHeader
* title="Flashcard Decks"
* description="Manage your study decks"
* >
* {#snippet actions()}
* <Button onclick={createDeck}>New Deck</Button>
* {/snippet}
* </PageHeader>
* ```
*
* @example With breadcrumb and icon
* ```svelte
* <PageHeader title="Edit Profile">
* {#snippet breadcrumb()}
* <a href="/settings">Settings</a> / Profile
* {/snippet}
* {#snippet icon()}
* <UserIcon />
* {/snippet}
* </PageHeader>
* ```
*/
import type { Snippet } from 'svelte';
import { Text } from '../atoms';
type HeaderSize = 'sm' | 'md' | 'lg';
interface Props {
/** Page title */
title: string;
/** Page description/subtitle */
description?: string;
/** Header size variant */
size?: HeaderSize;
/** Whether to show bottom border */
bordered?: boolean;
/** Icon snippet (before title) */
icon?: Snippet;
/** Breadcrumb snippet (above title) */
breadcrumb?: Snippet;
/** Actions snippet (right side) */
actions?: Snippet;
/** Tabs or navigation snippet (below header) */
tabs?: Snippet;
/** Additional CSS classes */
class?: string;
}
let {
title,
description,
size = 'md',
bordered = false,
icon,
breadcrumb,
actions,
tabs,
class: className = ''
}: Props = $props();
const sizeClasses: Record<HeaderSize, { container: string; title: string }> = {
sm: {
container: 'py-3',
title: 'text-lg'
},
md: {
container: 'py-4',
title: 'text-xl'
},
lg: {
container: 'py-6',
title: 'text-2xl'
}
};
</script>
<header
class="page-header {sizeClasses[size].container} {bordered
? 'border-b border-theme'
: ''} {className}"
>
<!-- Breadcrumb -->
{#if breadcrumb}
<div class="page-header__breadcrumb mb-2 text-sm text-theme-secondary">
{@render breadcrumb()}
</div>
{/if}
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<!-- Icon -->
{#if icon}
<div class="page-header__icon flex-shrink-0 text-theme-secondary">
{@render icon()}
</div>
{/if}
<!-- Title & Description -->
<div class="min-w-0">
<h1 class="font-semibold text-theme {sizeClasses[size].title} truncate">
{title}
</h1>
{#if description}
<Text variant="muted" class="mt-1">
{description}
</Text>
{/if}
</div>
</div>
<!-- Actions -->
{#if actions}
<div class="page-header__actions flex-shrink-0 flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
<!-- Tabs/Navigation -->
{#if tabs}
<div class="page-header__tabs mt-4">
{@render tabs()}
</div>
{/if}
</header>

View file

@ -19,3 +19,8 @@ export { SkeletonBox, SkeletonText } from './loaders';
// Feedback components
export { EmptyState } from './feedback';
// Layout components
export { default as ModalFooter } from './ModalFooter.svelte';
export { default as DataCard } from './DataCard.svelte';
export { default as PageHeader } from './PageHeader.svelte';

View file

@ -0,0 +1,117 @@
<script lang="ts">
/**
* FormModal - Modal with built-in form handling
*
* Extends Modal with form submission, validation error display,
* and standardized footer buttons.
*
* @example Basic form modal
* ```svelte
* <FormModal
* visible={showModal}
* onClose={() => showModal = false}
* onSubmit={handleSubmit}
* title="Create Item"
* submitLabel="Create"
* >
* <Input label="Name" bind:value={name} />
* <Textarea label="Description" bind:value={description} />
* </FormModal>
* ```
*/
import type { Snippet } from 'svelte';
import Modal from './Modal.svelte';
import { Button, Text } from '../atoms';
type SubmitVariant = 'primary' | 'danger' | 'success';
interface Props {
/** Whether the modal is visible */
visible: boolean;
/** Called when modal is closed */
onClose: () => void;
/** Called when form is submitted */
onSubmit: () => void | Promise<void>;
/** Modal title */
title: string;
/** Form content */
children: Snippet;
/** Icon snippet for header */
icon?: Snippet;
/** Submit button label */
submitLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Submit button variant */
submitVariant?: SubmitVariant;
/** Whether submission is in progress */
loading?: boolean;
/** Error message to display */
error?: string | null;
/** Max width of modal */
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
/** Whether submit button is disabled */
submitDisabled?: boolean;
}
let {
visible,
onClose,
onSubmit,
title,
children,
icon,
submitLabel = 'Submit',
cancelLabel = 'Cancel',
submitVariant = 'primary',
loading = false,
error = null,
maxWidth = 'md',
submitDisabled = false
}: Props = $props();
async function handleSubmit(e: Event) {
e.preventDefault();
await onSubmit();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && e.ctrlKey && !loading && !submitDisabled) {
handleSubmit(e);
}
}
</script>
<Modal {visible} {onClose} {title} {icon} {maxWidth}>
<form onsubmit={handleSubmit} onkeydown={handleKeydown} class="space-y-4">
<!-- Error message -->
{#if error}
<div class="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3">
<Text variant="small" class="text-red-600 dark:text-red-400">
{error}
</Text>
</div>
{/if}
<!-- Form content -->
{@render children()}
</form>
{#snippet footer()}
<div class="flex gap-3 justify-end">
<Button variant="ghost" onclick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={submitVariant}
type="submit"
onclick={handleSubmit}
{loading}
disabled={submitDisabled}
>
{submitLabel}
</Button>
</div>
{/snippet}
</Modal>

View file

@ -1,4 +1,5 @@
export { default as Modal } from './Modal.svelte';
export { default as ConfirmationModal } from './ConfirmationModal.svelte';
export { default as FormModal } from './FormModal.svelte';
export { default as AppSlider } from './AppSlider.svelte';
export type { AppItem } from './AppSlider.types';