mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
3c457f9c18
commit
5045d70bf7
7 changed files with 505 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
154
packages/shared-ui/src/molecules/DataCard.svelte
Normal file
154
packages/shared-ui/src/molecules/DataCard.svelte
Normal 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>
|
||||
85
packages/shared-ui/src/molecules/ModalFooter.svelte
Normal file
85
packages/shared-ui/src/molecules/ModalFooter.svelte
Normal 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>
|
||||
139
packages/shared-ui/src/molecules/PageHeader.svelte
Normal file
139
packages/shared-ui/src/molecules/PageHeader.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
117
packages/shared-ui/src/organisms/FormModal.svelte
Normal file
117
packages/shared-ui/src/organisms/FormModal.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue