mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat: add layout components to shared-ui (Tier 6)
Button enhancements: - Add 'outline' and 'success' variants - Add 'xl' size option New skeleton/loading components: - SkeletonBox: Base skeleton with shimmer animation (theme-aware) - SkeletonText: Multi-line text skeleton New feedback components: - EmptyState: Standardized empty state display with icon, title, message, and optional action buttons New confirmation components: - ConfirmationModal: Pre-styled confirmation dialog for delete/warning actions with 'danger', 'warning', and 'info' variants These components reduce duplication across apps and provide consistent UX patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c87641f91b
commit
3c457f9c18
10 changed files with 413 additions and 7 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' | 'success';
|
||||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
|
|
@ -30,13 +30,16 @@
|
||||||
primary: 'bg-primary text-white hover:bg-primary/90 border-transparent',
|
primary: 'bg-primary text-white hover:bg-primary/90 border-transparent',
|
||||||
secondary: 'bg-menu text-theme hover:bg-menu-hover border-theme',
|
secondary: 'bg-menu text-theme hover:bg-menu-hover border-theme',
|
||||||
ghost: 'bg-transparent text-theme hover:bg-menu-hover border-transparent',
|
ghost: 'bg-transparent text-theme hover:bg-menu-hover border-transparent',
|
||||||
danger: 'bg-red-600 text-white hover:bg-red-700 border-transparent'
|
danger: 'bg-red-600 text-white hover:bg-red-700 border-transparent',
|
||||||
|
outline: 'bg-transparent text-primary border-primary hover:bg-primary/10',
|
||||||
|
success: 'bg-green-600 text-white hover:bg-green-700 border-transparent'
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeClasses: Record<ButtonSize, string> = {
|
const sizeClasses: Record<ButtonSize, string> = {
|
||||||
sm: 'px-3 py-1.5 text-sm',
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
md: 'px-4 py-2 text-base',
|
md: 'px-4 py-2 text-base',
|
||||||
lg: 'px-6 py-3 text-lg'
|
lg: 'px-6 py-3 text-lg',
|
||||||
|
xl: 'px-8 py-4 text-xl'
|
||||||
};
|
};
|
||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,14 @@ export { TagBadge } from './molecules';
|
||||||
// Media
|
// Media
|
||||||
export { AudioPlayer } from './molecules';
|
export { AudioPlayer } from './molecules';
|
||||||
|
|
||||||
|
// Loading/Skeletons
|
||||||
|
export { SkeletonBox, SkeletonText } from './molecules';
|
||||||
|
|
||||||
|
// Feedback
|
||||||
|
export { EmptyState } from './molecules';
|
||||||
|
|
||||||
// Organisms
|
// Organisms
|
||||||
export { Modal, AppSlider } from './organisms';
|
export { Modal, ConfirmationModal, AppSlider } from './organisms';
|
||||||
export type { AppItem } from './organisms';
|
export type { AppItem } from './organisms';
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
|
|
|
||||||
131
packages/shared-ui/src/molecules/feedback/EmptyState.svelte
Normal file
131
packages/shared-ui/src/molecules/feedback/EmptyState.svelte
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* EmptyState - Standardized empty state display
|
||||||
|
*
|
||||||
|
* Used when a list, search, or section has no content to display.
|
||||||
|
* Provides consistent visual feedback with optional action button.
|
||||||
|
*
|
||||||
|
* @example Basic usage
|
||||||
|
* ```svelte
|
||||||
|
* <EmptyState
|
||||||
|
* title="No memos yet"
|
||||||
|
* message="Start recording to create your first memo"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example With action and icon
|
||||||
|
* ```svelte
|
||||||
|
* <EmptyState
|
||||||
|
* title="No results found"
|
||||||
|
* message="Try adjusting your search or filters"
|
||||||
|
* actionLabel="Clear filters"
|
||||||
|
* onAction={() => clearFilters()}
|
||||||
|
* >
|
||||||
|
* {#snippet icon()}
|
||||||
|
* <SearchIcon class="w-12 h-12" />
|
||||||
|
* {/snippet}
|
||||||
|
* </EmptyState>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { Text, Button } from '../../atoms';
|
||||||
|
|
||||||
|
type EmptyStateVariant = 'default' | 'compact' | 'centered';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Title text */
|
||||||
|
title: string;
|
||||||
|
/** Description message */
|
||||||
|
message?: string;
|
||||||
|
/** Action button label */
|
||||||
|
actionLabel?: string;
|
||||||
|
/** Action button callback */
|
||||||
|
onAction?: () => void;
|
||||||
|
/** Secondary action label */
|
||||||
|
secondaryActionLabel?: string;
|
||||||
|
/** Secondary action callback */
|
||||||
|
onSecondaryAction?: () => void;
|
||||||
|
/** Layout variant */
|
||||||
|
variant?: EmptyStateVariant;
|
||||||
|
/** Custom icon snippet */
|
||||||
|
icon?: Snippet;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
secondaryActionLabel,
|
||||||
|
onSecondaryAction,
|
||||||
|
variant = 'default',
|
||||||
|
icon,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantClasses: Record<EmptyStateVariant, string> = {
|
||||||
|
default: 'py-12 px-6',
|
||||||
|
compact: 'py-6 px-4',
|
||||||
|
centered: 'py-16 px-8'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="empty-state flex flex-col items-center justify-center text-center {variantClasses[variant]} {className}"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
{#if icon}
|
||||||
|
<div class="empty-state__icon mb-4 text-theme-secondary opacity-50">
|
||||||
|
{@render icon()}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Default icon -->
|
||||||
|
<div class="empty-state__icon mb-4 text-theme-secondary opacity-50">
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<Text variant="body" weight="semibold" class="mb-2">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
{#if message}
|
||||||
|
<Text variant="muted" class="max-w-sm mb-4">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
{#if actionLabel || secondaryActionLabel}
|
||||||
|
<div class="empty-state__actions flex gap-3 mt-2">
|
||||||
|
{#if secondaryActionLabel && onSecondaryAction}
|
||||||
|
<Button variant="ghost" onclick={onSecondaryAction}>
|
||||||
|
{secondaryActionLabel}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if actionLabel && onAction}
|
||||||
|
<Button variant="primary" onclick={onAction}>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
4
packages/shared-ui/src/molecules/feedback/index.ts
Normal file
4
packages/shared-ui/src/molecules/feedback/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Feedback components for user states
|
||||||
|
*/
|
||||||
|
export { default as EmptyState } from './EmptyState.svelte';
|
||||||
|
|
@ -3,7 +3,7 @@ export { default as Input } from './Input.svelte';
|
||||||
export { default as Select } from './Select.svelte';
|
export { default as Select } from './Select.svelte';
|
||||||
export { default as Textarea } from './Textarea.svelte';
|
export { default as Textarea } from './Textarea.svelte';
|
||||||
export { default as Checkbox } from './Checkbox.svelte';
|
export { default as Checkbox } from './Checkbox.svelte';
|
||||||
export type { SelectOption } from './Select.svelte';
|
export type { SelectOption } from './Select.types';
|
||||||
|
|
||||||
// Stats components
|
// Stats components
|
||||||
export { GlassCard, StatRow } from './stats';
|
export { GlassCard, StatRow } from './stats';
|
||||||
|
|
@ -13,3 +13,9 @@ export { TagBadge } from './tags';
|
||||||
|
|
||||||
// Media components
|
// Media components
|
||||||
export { AudioPlayer } from './media';
|
export { AudioPlayer } from './media';
|
||||||
|
|
||||||
|
// Loading components
|
||||||
|
export { SkeletonBox, SkeletonText } from './loaders';
|
||||||
|
|
||||||
|
// Feedback components
|
||||||
|
export { EmptyState } from './feedback';
|
||||||
|
|
|
||||||
79
packages/shared-ui/src/molecules/loaders/SkeletonBox.svelte
Normal file
79
packages/shared-ui/src/molecules/loaders/SkeletonBox.svelte
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* SkeletonBox - Base component for skeleton loading states
|
||||||
|
*
|
||||||
|
* Reusable box with shimmer animation for loading states.
|
||||||
|
* Theme-aware (light/dark mode) and fully customizable.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <SkeletonBox width="200px" height="24px" />
|
||||||
|
* <SkeletonBox width="100%" height="100px" borderRadius="12px" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Width of the skeleton (CSS value) */
|
||||||
|
width?: string;
|
||||||
|
/** Height of the skeleton (CSS value) */
|
||||||
|
height?: string;
|
||||||
|
/** Border radius (CSS value) */
|
||||||
|
borderRadius?: string;
|
||||||
|
/** Make it circular (overrides borderRadius) */
|
||||||
|
circle?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
width = '100%',
|
||||||
|
height = '20px',
|
||||||
|
borderRadius = '4px',
|
||||||
|
circle = false,
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const computedRadius = $derived(circle ? '50%' : borderRadius);
|
||||||
|
const computedHeight = $derived(circle ? width : height);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="skeleton-box {className}"
|
||||||
|
style="width: {width}; height: {computedHeight}; border-radius: {computedRadius};"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.skeleton-box {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--skeleton-base, #e5e7eb) 0%,
|
||||||
|
var(--skeleton-highlight, #f3f4f6) 50%,
|
||||||
|
var(--skeleton-base, #e5e7eb) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light Mode - Default */
|
||||||
|
:global(:root) {
|
||||||
|
--skeleton-base: #e5e7eb;
|
||||||
|
--skeleton-highlight: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode */
|
||||||
|
:global(.dark) {
|
||||||
|
--skeleton-base: #2a2a2a;
|
||||||
|
--skeleton-highlight: #3a3a3a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
packages/shared-ui/src/molecules/loaders/SkeletonText.svelte
Normal file
45
packages/shared-ui/src/molecules/loaders/SkeletonText.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* SkeletonText - Multi-line text skeleton
|
||||||
|
*
|
||||||
|
* Creates multiple skeleton lines for text content loading states.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <SkeletonText lines={3} />
|
||||||
|
* <SkeletonText lines={2} lastLineWidth="60%" />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SkeletonBox from './SkeletonBox.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Number of lines to show */
|
||||||
|
lines?: number;
|
||||||
|
/** Height of each line */
|
||||||
|
lineHeight?: string;
|
||||||
|
/** Gap between lines */
|
||||||
|
gap?: string;
|
||||||
|
/** Width of the last line (to simulate natural text) */
|
||||||
|
lastLineWidth?: string;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
lines = 3,
|
||||||
|
lineHeight = '16px',
|
||||||
|
gap = '8px',
|
||||||
|
lastLineWidth = '70%',
|
||||||
|
class: className = ''
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="skeleton-text {className}" style="display: flex; flex-direction: column; gap: {gap};">
|
||||||
|
{#each Array(lines) as _, i}
|
||||||
|
<SkeletonBox
|
||||||
|
width={i === lines - 1 ? lastLineWidth : '100%'}
|
||||||
|
height={lineHeight}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
5
packages/shared-ui/src/molecules/loaders/index.ts
Normal file
5
packages/shared-ui/src/molecules/loaders/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
/**
|
||||||
|
* Loading state components
|
||||||
|
*/
|
||||||
|
export { default as SkeletonBox } from './SkeletonBox.svelte';
|
||||||
|
export { default as SkeletonText } from './SkeletonText.svelte';
|
||||||
126
packages/shared-ui/src/organisms/ConfirmationModal.svelte
Normal file
126
packages/shared-ui/src/organisms/ConfirmationModal.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* ConfirmationModal - Pre-styled confirmation dialog
|
||||||
|
*
|
||||||
|
* Used for delete confirmations, destructive actions, or any action
|
||||||
|
* that requires user confirmation before proceeding.
|
||||||
|
*
|
||||||
|
* @example Delete confirmation
|
||||||
|
* ```svelte
|
||||||
|
* <ConfirmationModal
|
||||||
|
* visible={showDeleteModal}
|
||||||
|
* onClose={() => showDeleteModal = false}
|
||||||
|
* onConfirm={handleDelete}
|
||||||
|
* variant="danger"
|
||||||
|
* title="Delete memo?"
|
||||||
|
* message="This action cannot be undone."
|
||||||
|
* confirmLabel="Delete"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Warning confirmation
|
||||||
|
* ```svelte
|
||||||
|
* <ConfirmationModal
|
||||||
|
* visible={showWarningModal}
|
||||||
|
* onClose={() => showWarningModal = false}
|
||||||
|
* onConfirm={handleProceed}
|
||||||
|
* variant="warning"
|
||||||
|
* title="Are you sure?"
|
||||||
|
* message="You have unsaved changes that will be lost."
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Icon } from '@manacore/shared-icons';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import { Text, Button } from '../atoms';
|
||||||
|
|
||||||
|
type ConfirmationVariant = 'danger' | 'warning' | 'info';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Whether the modal is visible */
|
||||||
|
visible: boolean;
|
||||||
|
/** Called when modal is closed (cancel or backdrop click) */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Called when user confirms the action */
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
/** Visual variant */
|
||||||
|
variant?: ConfirmationVariant;
|
||||||
|
/** Modal title */
|
||||||
|
title: string;
|
||||||
|
/** Confirmation message */
|
||||||
|
message?: string;
|
||||||
|
/** Confirm button label */
|
||||||
|
confirmLabel?: string;
|
||||||
|
/** Cancel button label */
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** Whether confirm action is in progress */
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
variant = 'danger',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
loading = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantConfig: Record<
|
||||||
|
ConfirmationVariant,
|
||||||
|
{ iconName: string; iconColor: string; buttonVariant: 'danger' | 'primary' }
|
||||||
|
> = {
|
||||||
|
danger: {
|
||||||
|
iconName: 'alert-triangle',
|
||||||
|
iconColor: 'text-red-500',
|
||||||
|
buttonVariant: 'danger'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
iconName: 'alert-circle',
|
||||||
|
iconColor: 'text-yellow-500',
|
||||||
|
buttonVariant: 'primary'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
iconName: 'info',
|
||||||
|
iconColor: 'text-blue-500',
|
||||||
|
buttonVariant: 'primary'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = $derived(variantConfig[variant]);
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
await onConfirm();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal {visible} {onClose} {title} maxWidth="sm">
|
||||||
|
{#snippet icon()}
|
||||||
|
<div class="p-2 rounded-full bg-menu-hover">
|
||||||
|
<Icon name={config.iconName} size={20} class={config.iconColor} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="text-center py-2">
|
||||||
|
{#if message}
|
||||||
|
<Text variant="muted" class="leading-relaxed">
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet footer()}
|
||||||
|
<div class="flex gap-3 justify-end">
|
||||||
|
<Button variant="ghost" onclick={onClose} disabled={loading}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button variant={config.buttonVariant} onclick={handleConfirm} {loading}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export { default as Modal } from './Modal.svelte';
|
export { default as Modal } from './Modal.svelte';
|
||||||
|
export { default as ConfirmationModal } from './ConfirmationModal.svelte';
|
||||||
export { default as AppSlider } from './AppSlider.svelte';
|
export { default as AppSlider } from './AppSlider.svelte';
|
||||||
export type { AppItem } from './AppSlider.svelte';
|
export type { AppItem } from './AppSlider.types';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue