@miozu/jera 0.0.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +443 -0
- package/README.md +211 -1
- package/llms.txt +64 -0
- package/package.json +44 -14
- package/src/actions/index.js +375 -0
- package/src/components/feedback/EmptyState.svelte +179 -0
- package/src/components/feedback/ProgressBar.svelte +116 -0
- package/src/components/feedback/Skeleton.svelte +107 -0
- package/src/components/feedback/Spinner.svelte +77 -0
- package/src/components/feedback/Toast.svelte +297 -0
- package/src/components/forms/Checkbox.svelte +147 -0
- package/src/components/forms/Dropzone.svelte +248 -0
- package/src/components/forms/FileUpload.svelte +266 -0
- package/src/components/forms/IconInput.svelte +184 -0
- package/src/components/forms/Input.svelte +121 -0
- package/src/components/forms/NumberInput.svelte +225 -0
- package/src/components/forms/PinInput.svelte +169 -0
- package/src/components/forms/Radio.svelte +143 -0
- package/src/components/forms/RadioGroup.svelte +62 -0
- package/src/components/forms/RangeSlider.svelte +212 -0
- package/src/components/forms/SearchInput.svelte +175 -0
- package/src/components/forms/Select.svelte +326 -0
- package/src/components/forms/Switch.svelte +159 -0
- package/src/components/forms/Textarea.svelte +122 -0
- package/src/components/navigation/Accordion.svelte +65 -0
- package/src/components/navigation/AccordionItem.svelte +146 -0
- package/src/components/navigation/Tabs.svelte +239 -0
- package/src/components/overlays/ConfirmDialog.svelte +272 -0
- package/src/components/overlays/Dropdown.svelte +153 -0
- package/src/components/overlays/DropdownDivider.svelte +23 -0
- package/src/components/overlays/DropdownItem.svelte +97 -0
- package/src/components/overlays/Modal.svelte +232 -0
- package/src/components/overlays/Popover.svelte +206 -0
- package/src/components/primitives/Avatar.svelte +132 -0
- package/src/components/primitives/Badge.svelte +118 -0
- package/src/components/primitives/Button.svelte +262 -0
- package/src/components/primitives/Card.svelte +104 -0
- package/src/components/primitives/Divider.svelte +105 -0
- package/src/components/primitives/LazyImage.svelte +104 -0
- package/src/components/primitives/Link.svelte +122 -0
- package/src/components/primitives/StatusBadge.svelte +122 -0
- package/src/index.js +128 -0
- package/src/tokens/colors.css +189 -0
- package/src/tokens/effects.css +128 -0
- package/src/tokens/index.css +81 -0
- package/src/tokens/spacing.css +49 -0
- package/src/tokens/typography.css +79 -0
- package/src/utils/cn.svelte.js +175 -0
- package/src/utils/index.js +17 -0
- package/src/utils/reactive.svelte.js +239 -0
- package/jera.js +0 -135
- package/www/components/jera/Input/Input.svelte +0 -63
- package/www/components/jera/Input/index.js +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ProgressBar
|
|
3
|
+
|
|
4
|
+
Visual progress indicator with optional label.
|
|
5
|
+
|
|
6
|
+
@example Basic usage
|
|
7
|
+
<ProgressBar value={65} />
|
|
8
|
+
|
|
9
|
+
@example With label
|
|
10
|
+
<ProgressBar value={65} showLabel />
|
|
11
|
+
|
|
12
|
+
@example Custom color
|
|
13
|
+
<ProgressBar value={80} variant="success" />
|
|
14
|
+
-->
|
|
15
|
+
<script>
|
|
16
|
+
let {
|
|
17
|
+
value = 0,
|
|
18
|
+
max = 100,
|
|
19
|
+
size = 'md',
|
|
20
|
+
variant = 'primary',
|
|
21
|
+
showLabel = false,
|
|
22
|
+
label = null,
|
|
23
|
+
indeterminate = false,
|
|
24
|
+
class: className = ''
|
|
25
|
+
} = $props();
|
|
26
|
+
|
|
27
|
+
const percentage = $derived(Math.min(100, Math.max(0, (value / max) * 100)));
|
|
28
|
+
|
|
29
|
+
const variantColors = {
|
|
30
|
+
primary: 'var(--color-primary)',
|
|
31
|
+
success: 'var(--color-success)',
|
|
32
|
+
warning: 'var(--color-warning)',
|
|
33
|
+
error: 'var(--color-error)',
|
|
34
|
+
info: 'var(--color-info)'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const barColor = $derived(variantColors[variant] || variantColors.primary);
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div class="progress-container {className}">
|
|
41
|
+
{#if showLabel || label}
|
|
42
|
+
<div class="progress-header">
|
|
43
|
+
{#if label}
|
|
44
|
+
<span class="progress-label">{label}</span>
|
|
45
|
+
{/if}
|
|
46
|
+
{#if showLabel && !indeterminate}
|
|
47
|
+
<span class="progress-value">{Math.round(percentage)}%</span>
|
|
48
|
+
{/if}
|
|
49
|
+
</div>
|
|
50
|
+
{/if}
|
|
51
|
+
|
|
52
|
+
<div
|
|
53
|
+
class="progress progress-{size}"
|
|
54
|
+
role="progressbar"
|
|
55
|
+
aria-valuenow={indeterminate ? undefined : value}
|
|
56
|
+
aria-valuemin="0"
|
|
57
|
+
aria-valuemax={max}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
class="progress-bar"
|
|
61
|
+
class:progress-indeterminate={indeterminate}
|
|
62
|
+
style="width: {indeterminate ? '100%' : percentage + '%'}; background: {barColor};"
|
|
63
|
+
></div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<style>
|
|
68
|
+
.progress-container {
|
|
69
|
+
width: 100%;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.progress-header {
|
|
73
|
+
display: flex;
|
|
74
|
+
justify-content: space-between;
|
|
75
|
+
align-items: center;
|
|
76
|
+
margin-bottom: 0.375rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.progress-label {
|
|
80
|
+
font-size: 0.875rem;
|
|
81
|
+
color: var(--color-text);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.progress-value {
|
|
85
|
+
font-size: 0.875rem;
|
|
86
|
+
font-weight: 500;
|
|
87
|
+
color: var(--color-text-strong);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.progress {
|
|
91
|
+
width: 100%;
|
|
92
|
+
background: var(--color-surface-alt);
|
|
93
|
+
border-radius: 9999px;
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.progress-sm { height: 0.25rem; }
|
|
98
|
+
.progress-md { height: 0.5rem; }
|
|
99
|
+
.progress-lg { height: 0.75rem; }
|
|
100
|
+
|
|
101
|
+
.progress-bar {
|
|
102
|
+
height: 100%;
|
|
103
|
+
border-radius: 9999px;
|
|
104
|
+
transition: width 0.3s ease-out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.progress-indeterminate {
|
|
108
|
+
width: 30% !important;
|
|
109
|
+
animation: indeterminate 1.5s ease-in-out infinite;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@keyframes indeterminate {
|
|
113
|
+
0% { transform: translateX(-100%); }
|
|
114
|
+
100% { transform: translateX(400%); }
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Skeleton
|
|
3
|
+
|
|
4
|
+
Loading placeholder with shimmer animation.
|
|
5
|
+
|
|
6
|
+
@example Text line
|
|
7
|
+
<Skeleton width="80%" />
|
|
8
|
+
|
|
9
|
+
@example Circle avatar
|
|
10
|
+
<Skeleton variant="circle" size="48px" />
|
|
11
|
+
|
|
12
|
+
@example Rectangle card
|
|
13
|
+
<Skeleton variant="rect" width="100%" height="200px" />
|
|
14
|
+
-->
|
|
15
|
+
<script>
|
|
16
|
+
let {
|
|
17
|
+
variant = 'text',
|
|
18
|
+
width = '100%',
|
|
19
|
+
height = null,
|
|
20
|
+
size = null,
|
|
21
|
+
lines = 1,
|
|
22
|
+
animate = true,
|
|
23
|
+
class: className = ''
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
// Compute dimensions based on variant
|
|
27
|
+
const computedHeight = $derived.by(() => {
|
|
28
|
+
if (height) return height;
|
|
29
|
+
if (size) return size;
|
|
30
|
+
switch (variant) {
|
|
31
|
+
case 'text': return '1rem';
|
|
32
|
+
case 'heading': return '1.5rem';
|
|
33
|
+
case 'circle': return size || '2.5rem';
|
|
34
|
+
case 'rect': return '4rem';
|
|
35
|
+
default: return '1rem';
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const computedWidth = $derived.by(() => {
|
|
40
|
+
if (width) return width;
|
|
41
|
+
if (size && variant === 'circle') return size;
|
|
42
|
+
return '100%';
|
|
43
|
+
});
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
{#if lines > 1}
|
|
47
|
+
<div class="skeleton-lines {className}">
|
|
48
|
+
{#each Array(lines) as _, i}
|
|
49
|
+
<div
|
|
50
|
+
class="skeleton skeleton-{variant}"
|
|
51
|
+
class:skeleton-animate={animate}
|
|
52
|
+
style="width: {i === lines - 1 ? '60%' : computedWidth}; height: {computedHeight};"
|
|
53
|
+
></div>
|
|
54
|
+
{/each}
|
|
55
|
+
</div>
|
|
56
|
+
{:else}
|
|
57
|
+
<div
|
|
58
|
+
class="skeleton skeleton-{variant} {className}"
|
|
59
|
+
class:skeleton-animate={animate}
|
|
60
|
+
style="width: {computedWidth}; height: {computedHeight};"
|
|
61
|
+
></div>
|
|
62
|
+
{/if}
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.skeleton {
|
|
66
|
+
background: var(--color-surface-alt);
|
|
67
|
+
border-radius: 0.25rem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.skeleton-text {
|
|
71
|
+
border-radius: 0.25rem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.skeleton-heading {
|
|
75
|
+
border-radius: 0.25rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.skeleton-circle {
|
|
79
|
+
border-radius: 50%;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.skeleton-rect {
|
|
83
|
+
border-radius: 0.5rem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.skeleton-lines {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
gap: 0.5rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.skeleton-animate {
|
|
93
|
+
background: linear-gradient(
|
|
94
|
+
90deg,
|
|
95
|
+
var(--color-surface-alt) 0%,
|
|
96
|
+
var(--color-border) 50%,
|
|
97
|
+
var(--color-surface-alt) 100%
|
|
98
|
+
);
|
|
99
|
+
background-size: 200% 100%;
|
|
100
|
+
animation: skeleton-shimmer 1.5s ease-in-out infinite;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes skeleton-shimmer {
|
|
104
|
+
0% { background-position: 200% 0; }
|
|
105
|
+
100% { background-position: -200% 0; }
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Spinner
|
|
3
|
+
|
|
4
|
+
Loading spinner indicator.
|
|
5
|
+
|
|
6
|
+
@example Basic usage
|
|
7
|
+
<Spinner />
|
|
8
|
+
|
|
9
|
+
@example With size
|
|
10
|
+
<Spinner size="lg" />
|
|
11
|
+
|
|
12
|
+
@example Custom color
|
|
13
|
+
<Spinner color="var(--color-success)" />
|
|
14
|
+
-->
|
|
15
|
+
<script>
|
|
16
|
+
let {
|
|
17
|
+
size = 'md',
|
|
18
|
+
color = 'currentColor',
|
|
19
|
+
label = 'Loading...',
|
|
20
|
+
class: className = ''
|
|
21
|
+
} = $props();
|
|
22
|
+
|
|
23
|
+
const sizes = {
|
|
24
|
+
xs: 12,
|
|
25
|
+
sm: 16,
|
|
26
|
+
md: 24,
|
|
27
|
+
lg: 32,
|
|
28
|
+
xl: 48
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const sizeValue = $derived(sizes[size] || sizes.md);
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<svg
|
|
35
|
+
class="spinner {className}"
|
|
36
|
+
width={sizeValue}
|
|
37
|
+
height={sizeValue}
|
|
38
|
+
viewBox="0 0 24 24"
|
|
39
|
+
fill="none"
|
|
40
|
+
aria-label={label}
|
|
41
|
+
role="status"
|
|
42
|
+
>
|
|
43
|
+
<circle
|
|
44
|
+
class="spinner-track"
|
|
45
|
+
cx="12"
|
|
46
|
+
cy="12"
|
|
47
|
+
r="10"
|
|
48
|
+
stroke="currentColor"
|
|
49
|
+
stroke-width="3"
|
|
50
|
+
opacity="0.2"
|
|
51
|
+
/>
|
|
52
|
+
<circle
|
|
53
|
+
class="spinner-indicator"
|
|
54
|
+
cx="12"
|
|
55
|
+
cy="12"
|
|
56
|
+
r="10"
|
|
57
|
+
stroke={color}
|
|
58
|
+
stroke-width="3"
|
|
59
|
+
stroke-linecap="round"
|
|
60
|
+
stroke-dasharray="60 200"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
|
|
64
|
+
<style>
|
|
65
|
+
.spinner {
|
|
66
|
+
animation: spin 1s linear infinite;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.spinner-track {
|
|
70
|
+
opacity: 0.2;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@keyframes spin {
|
|
74
|
+
from { transform: rotate(0deg); }
|
|
75
|
+
to { transform: rotate(360deg); }
|
|
76
|
+
}
|
|
77
|
+
</style>
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component Toast
|
|
3
|
+
|
|
4
|
+
A toast notification system with stacking, auto-dismiss, and animations.
|
|
5
|
+
Demonstrates advanced Svelte 5 patterns:
|
|
6
|
+
|
|
7
|
+
- Reactive class with ThemeState pattern
|
|
8
|
+
- $state.raw for non-proxied arrays (performance)
|
|
9
|
+
- $effect with cleanup for timers
|
|
10
|
+
- Context API for global toast access
|
|
11
|
+
- Portal action for DOM placement
|
|
12
|
+
- Compound variant styling
|
|
13
|
+
|
|
14
|
+
@example
|
|
15
|
+
// In your root layout
|
|
16
|
+
<ToastProvider>
|
|
17
|
+
<slot />
|
|
18
|
+
</ToastProvider>
|
|
19
|
+
|
|
20
|
+
// In any component
|
|
21
|
+
import { toast } from '@miozu/jera';
|
|
22
|
+
toast.success('Saved successfully!');
|
|
23
|
+
toast.error('Something went wrong');
|
|
24
|
+
toast.info('Did you know?');
|
|
25
|
+
toast.custom({ title: 'Custom', message: 'With title', duration: 5000 });
|
|
26
|
+
-->
|
|
27
|
+
<script module>
|
|
28
|
+
import { getContext, setContext } from 'svelte';
|
|
29
|
+
import { cv } from '../../utils/cn.svelte.js';
|
|
30
|
+
|
|
31
|
+
const TOAST_KEY = Symbol('jera-toast');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {'info' | 'success' | 'warning' | 'error'} ToastType
|
|
35
|
+
* @typedef {'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'} ToastPosition
|
|
36
|
+
*
|
|
37
|
+
* @typedef {{
|
|
38
|
+
* id: string,
|
|
39
|
+
* type: ToastType,
|
|
40
|
+
* title?: string,
|
|
41
|
+
* message: string,
|
|
42
|
+
* duration: number,
|
|
43
|
+
* createdAt: number,
|
|
44
|
+
* pausedAt?: number
|
|
45
|
+
* }} ToastItem
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Toast Controller Class
|
|
50
|
+
*
|
|
51
|
+
* Manages toast state and provides methods for showing toasts.
|
|
52
|
+
* Uses Svelte 5 reactive class pattern.
|
|
53
|
+
*/
|
|
54
|
+
export class ToastController {
|
|
55
|
+
/** @type {ToastItem[]} */
|
|
56
|
+
toasts = $state.raw([]);
|
|
57
|
+
|
|
58
|
+
/** @type {ToastPosition} */
|
|
59
|
+
position = $state('bottom-right');
|
|
60
|
+
|
|
61
|
+
/** @type {number} */
|
|
62
|
+
defaultDuration = 4000;
|
|
63
|
+
|
|
64
|
+
/** @type {number} */
|
|
65
|
+
maxToasts = 5;
|
|
66
|
+
|
|
67
|
+
#counter = 0;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Show a toast
|
|
71
|
+
* @param {Partial<ToastItem> & { message: string }} options
|
|
72
|
+
* @returns {string} Toast ID
|
|
73
|
+
*/
|
|
74
|
+
show(options) {
|
|
75
|
+
const id = `toast-${++this.#counter}`;
|
|
76
|
+
|
|
77
|
+
const toast = {
|
|
78
|
+
id,
|
|
79
|
+
type: options.type ?? 'info',
|
|
80
|
+
title: options.title,
|
|
81
|
+
message: options.message,
|
|
82
|
+
duration: options.duration ?? this.defaultDuration,
|
|
83
|
+
createdAt: Date.now()
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Add to beginning (newest first for top positions)
|
|
87
|
+
this.toasts = [toast, ...this.toasts].slice(0, this.maxToasts);
|
|
88
|
+
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Dismiss a toast by ID
|
|
94
|
+
* @param {string} id
|
|
95
|
+
*/
|
|
96
|
+
dismiss(id) {
|
|
97
|
+
this.toasts = this.toasts.filter(t => t.id !== id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Dismiss all toasts
|
|
102
|
+
*/
|
|
103
|
+
dismissAll() {
|
|
104
|
+
this.toasts = [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pause a toast's auto-dismiss timer
|
|
109
|
+
* @param {string} id
|
|
110
|
+
*/
|
|
111
|
+
pause(id) {
|
|
112
|
+
this.toasts = this.toasts.map(t =>
|
|
113
|
+
t.id === id ? { ...t, pausedAt: Date.now() } : t
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resume a toast's auto-dismiss timer
|
|
119
|
+
* @param {string} id
|
|
120
|
+
*/
|
|
121
|
+
resume(id) {
|
|
122
|
+
this.toasts = this.toasts.map(t => {
|
|
123
|
+
if (t.id !== id || !t.pausedAt) return t;
|
|
124
|
+
const pausedDuration = Date.now() - t.pausedAt;
|
|
125
|
+
return {
|
|
126
|
+
...t,
|
|
127
|
+
createdAt: t.createdAt + pausedDuration,
|
|
128
|
+
pausedAt: undefined
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Convenience methods
|
|
134
|
+
info = (message, options = {}) => this.show({ ...options, message, type: 'info' });
|
|
135
|
+
success = (message, options = {}) => this.show({ ...options, message, type: 'success' });
|
|
136
|
+
warning = (message, options = {}) => this.show({ ...options, message, type: 'warning' });
|
|
137
|
+
error = (message, options = {}) => this.show({ ...options, message, type: 'error' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create toast context (call in ToastProvider)
|
|
142
|
+
*/
|
|
143
|
+
export function createToastContext() {
|
|
144
|
+
const controller = new ToastController();
|
|
145
|
+
setContext(TOAST_KEY, controller);
|
|
146
|
+
return controller;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get toast controller from context
|
|
151
|
+
* @returns {ToastController}
|
|
152
|
+
*/
|
|
153
|
+
export function getToastContext() {
|
|
154
|
+
return getContext(TOAST_KEY);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Toast styles
|
|
158
|
+
export const toastStyles = cv({
|
|
159
|
+
base: [
|
|
160
|
+
'relative flex items-start gap-3 w-full max-w-sm',
|
|
161
|
+
'p-4 rounded-lg shadow-lg',
|
|
162
|
+
'border',
|
|
163
|
+
'animate-in fade-in slide-in-from-right-4 duration-200'
|
|
164
|
+
].join(' '),
|
|
165
|
+
|
|
166
|
+
variants: {
|
|
167
|
+
type: {
|
|
168
|
+
info: 'bg-surface border-border text-text',
|
|
169
|
+
success: 'bg-success/10 border-success/30 text-success',
|
|
170
|
+
warning: 'bg-warning/10 border-warning/30 text-warning',
|
|
171
|
+
error: 'bg-error/10 border-error/30 text-error'
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
defaults: { type: 'info' }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export const positionStyles = {
|
|
179
|
+
'top-left': 'top-4 left-4 flex-col',
|
|
180
|
+
'top-center': 'top-4 left-1/2 -translate-x-1/2 flex-col',
|
|
181
|
+
'top-right': 'top-4 right-4 flex-col',
|
|
182
|
+
'bottom-left': 'bottom-4 left-4 flex-col-reverse',
|
|
183
|
+
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse',
|
|
184
|
+
'bottom-right': 'bottom-4 right-4 flex-col-reverse'
|
|
185
|
+
};
|
|
186
|
+
</script>
|
|
187
|
+
|
|
188
|
+
<script>
|
|
189
|
+
import { cn } from '../../utils/cn.svelte.js';
|
|
190
|
+
import { portal } from '../../actions/index.js';
|
|
191
|
+
|
|
192
|
+
// Get toast controller from context
|
|
193
|
+
const toast = getToastContext();
|
|
194
|
+
|
|
195
|
+
// Icon components for each type
|
|
196
|
+
const icons = {
|
|
197
|
+
info: `<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>`,
|
|
198
|
+
success: `<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>`,
|
|
199
|
+
warning: `<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>`,
|
|
200
|
+
error: `<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>`
|
|
201
|
+
};
|
|
202
|
+
</script>
|
|
203
|
+
|
|
204
|
+
{#if toast.toasts.length > 0}
|
|
205
|
+
<div
|
|
206
|
+
use:portal
|
|
207
|
+
class={cn(
|
|
208
|
+
'fixed z-[var(--z-toast)] flex gap-2 pointer-events-none',
|
|
209
|
+
positionStyles[toast.position]
|
|
210
|
+
)}
|
|
211
|
+
role="region"
|
|
212
|
+
aria-label="Notifications"
|
|
213
|
+
>
|
|
214
|
+
{#each toast.toasts as item (item.id)}
|
|
215
|
+
{@const remaining = item.duration - (Date.now() - item.createdAt)}
|
|
216
|
+
|
|
217
|
+
<div
|
|
218
|
+
class={cn(toastStyles({ type: item.type }), 'pointer-events-auto')}
|
|
219
|
+
role="alert"
|
|
220
|
+
aria-live="polite"
|
|
221
|
+
onmouseenter={() => toast.pause(item.id)}
|
|
222
|
+
onmouseleave={() => toast.resume(item.id)}
|
|
223
|
+
>
|
|
224
|
+
<!-- Icon -->
|
|
225
|
+
<span class="shrink-0 w-5 h-5">
|
|
226
|
+
<svg
|
|
227
|
+
viewBox="0 0 24 24"
|
|
228
|
+
fill="none"
|
|
229
|
+
stroke="currentColor"
|
|
230
|
+
stroke-width="2"
|
|
231
|
+
stroke-linecap="round"
|
|
232
|
+
stroke-linejoin="round"
|
|
233
|
+
>
|
|
234
|
+
{@html icons[item.type]}
|
|
235
|
+
</svg>
|
|
236
|
+
</span>
|
|
237
|
+
|
|
238
|
+
<!-- Content -->
|
|
239
|
+
<div class="flex-1 min-w-0">
|
|
240
|
+
{#if item.title}
|
|
241
|
+
<p class="font-medium text-sm">{item.title}</p>
|
|
242
|
+
{/if}
|
|
243
|
+
<p class={cn('text-sm', item.title && 'mt-1 opacity-90')}>
|
|
244
|
+
{item.message}
|
|
245
|
+
</p>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- Close Button -->
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class={cn(
|
|
252
|
+
'shrink-0 p-1 rounded-md',
|
|
253
|
+
'opacity-60 hover:opacity-100',
|
|
254
|
+
'transition-opacity duration-150',
|
|
255
|
+
'focus:outline-none focus-visible:ring-2 focus-visible:ring-current/50'
|
|
256
|
+
)}
|
|
257
|
+
onclick={() => toast.dismiss(item.id)}
|
|
258
|
+
aria-label="Dismiss notification"
|
|
259
|
+
>
|
|
260
|
+
<svg
|
|
261
|
+
class="w-4 h-4"
|
|
262
|
+
viewBox="0 0 24 24"
|
|
263
|
+
fill="none"
|
|
264
|
+
stroke="currentColor"
|
|
265
|
+
stroke-width="2"
|
|
266
|
+
stroke-linecap="round"
|
|
267
|
+
stroke-linejoin="round"
|
|
268
|
+
>
|
|
269
|
+
<path d="M18 6 6 18" />
|
|
270
|
+
<path d="m6 6 12 12" />
|
|
271
|
+
</svg>
|
|
272
|
+
</button>
|
|
273
|
+
|
|
274
|
+
<!-- Auto-dismiss timer -->
|
|
275
|
+
{#if item.duration > 0 && !item.pausedAt}
|
|
276
|
+
{@const _ = setTimeout(() => toast.dismiss(item.id), remaining)}
|
|
277
|
+
{/if}
|
|
278
|
+
</div>
|
|
279
|
+
{/each}
|
|
280
|
+
</div>
|
|
281
|
+
{/if}
|
|
282
|
+
|
|
283
|
+
<style>
|
|
284
|
+
@keyframes fade-in {
|
|
285
|
+
from { opacity: 0; }
|
|
286
|
+
to { opacity: 1; }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
@keyframes slide-in-from-right-4 {
|
|
290
|
+
from { transform: translateX(1rem); }
|
|
291
|
+
to { transform: translateX(0); }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.animate-in {
|
|
295
|
+
animation: fade-in 200ms ease-out, slide-in-from-right-4 200ms ease-out;
|
|
296
|
+
}
|
|
297
|
+
</style>
|