@miozu/jera 0.0.2 → 0.4.2

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.
Files changed (82) hide show
  1. package/CLAUDE.md +734 -0
  2. package/README.md +219 -1
  3. package/llms.txt +97 -0
  4. package/package.json +54 -14
  5. package/src/actions/index.js +375 -0
  6. package/src/components/docs/CodeBlock.svelte +203 -0
  7. package/src/components/docs/DocSection.svelte +120 -0
  8. package/src/components/docs/PropsTable.svelte +136 -0
  9. package/src/components/docs/SplitPane.svelte +98 -0
  10. package/src/components/docs/index.js +14 -0
  11. package/src/components/feedback/Alert.svelte +234 -0
  12. package/src/components/feedback/EmptyState.svelte +179 -0
  13. package/src/components/feedback/ProgressBar.svelte +116 -0
  14. package/src/components/feedback/Skeleton.svelte +107 -0
  15. package/src/components/feedback/Spinner.svelte +77 -0
  16. package/src/components/feedback/Toast.svelte +261 -0
  17. package/src/components/forms/Checkbox.svelte +147 -0
  18. package/src/components/forms/Dropzone.svelte +248 -0
  19. package/src/components/forms/FileUpload.svelte +266 -0
  20. package/src/components/forms/IconInput.svelte +184 -0
  21. package/src/components/forms/Input.svelte +121 -0
  22. package/src/components/forms/NumberInput.svelte +225 -0
  23. package/src/components/forms/PinInput.svelte +169 -0
  24. package/src/components/forms/Radio.svelte +143 -0
  25. package/src/components/forms/RadioGroup.svelte +62 -0
  26. package/src/components/forms/RangeSlider.svelte +212 -0
  27. package/src/components/forms/SearchInput.svelte +175 -0
  28. package/src/components/forms/Select.svelte +324 -0
  29. package/src/components/forms/Switch.svelte +159 -0
  30. package/src/components/forms/Textarea.svelte +122 -0
  31. package/src/components/navigation/Accordion.svelte +65 -0
  32. package/src/components/navigation/AccordionItem.svelte +146 -0
  33. package/src/components/navigation/NavigationContainer.svelte +344 -0
  34. package/src/components/navigation/Sidebar.svelte +334 -0
  35. package/src/components/navigation/SidebarAccountGroup.svelte +495 -0
  36. package/src/components/navigation/SidebarAccountItem.svelte +492 -0
  37. package/src/components/navigation/SidebarGroup.svelte +230 -0
  38. package/src/components/navigation/SidebarGroupSwitcher.svelte +262 -0
  39. package/src/components/navigation/SidebarItem.svelte +210 -0
  40. package/src/components/navigation/SidebarNavigationItem.svelte +470 -0
  41. package/src/components/navigation/SidebarPopover.svelte +145 -0
  42. package/src/components/navigation/SidebarSearch.svelte +236 -0
  43. package/src/components/navigation/SidebarSection.svelte +158 -0
  44. package/src/components/navigation/SidebarToggle.svelte +86 -0
  45. package/src/components/navigation/Tabs.svelte +239 -0
  46. package/src/components/navigation/WorkspaceMenu.svelte +416 -0
  47. package/src/components/navigation/blocks/NavigationAccountGroup.svelte +396 -0
  48. package/src/components/navigation/blocks/NavigationCustomBlock.svelte +74 -0
  49. package/src/components/navigation/blocks/NavigationGroupSwitcher.svelte +277 -0
  50. package/src/components/navigation/blocks/NavigationSearch.svelte +300 -0
  51. package/src/components/navigation/blocks/NavigationSection.svelte +230 -0
  52. package/src/components/navigation/index.js +22 -0
  53. package/src/components/overlays/ConfirmDialog.svelte +272 -0
  54. package/src/components/overlays/Dropdown.svelte +153 -0
  55. package/src/components/overlays/DropdownDivider.svelte +23 -0
  56. package/src/components/overlays/DropdownItem.svelte +97 -0
  57. package/src/components/overlays/Modal.svelte +232 -0
  58. package/src/components/overlays/Popover.svelte +206 -0
  59. package/src/components/primitives/Avatar.svelte +132 -0
  60. package/src/components/primitives/Badge.svelte +118 -0
  61. package/src/components/primitives/Button.svelte +214 -0
  62. package/src/components/primitives/Card.svelte +104 -0
  63. package/src/components/primitives/Divider.svelte +105 -0
  64. package/src/components/primitives/LazyImage.svelte +104 -0
  65. package/src/components/primitives/Link.svelte +122 -0
  66. package/src/components/primitives/Stat.svelte +197 -0
  67. package/src/components/primitives/StatusBadge.svelte +122 -0
  68. package/src/index.js +183 -0
  69. package/src/tokens/colors.css +157 -0
  70. package/src/tokens/effects.css +128 -0
  71. package/src/tokens/index.css +81 -0
  72. package/src/tokens/spacing.css +49 -0
  73. package/src/tokens/typography.css +79 -0
  74. package/src/utils/cn.svelte.js +175 -0
  75. package/src/utils/highlighter.js +124 -0
  76. package/src/utils/index.js +22 -0
  77. package/src/utils/navigation.svelte.js +423 -0
  78. package/src/utils/reactive.svelte.js +328 -0
  79. package/src/utils/sidebar.svelte.js +211 -0
  80. package/jera.js +0 -135
  81. package/www/components/jera/Input/Input.svelte +0 -63
  82. package/www/components/jera/Input/index.js +0 -1
@@ -0,0 +1,118 @@
1
+ <!--
2
+ @component Badge
3
+
4
+ A versatile badge/tag component for status, labels, and counts.
5
+ Consolidates Badge, StatusBadge, and Status patterns into one component.
6
+
7
+ @example
8
+ <Badge>Default</Badge>
9
+ <Badge variant="success">Active</Badge>
10
+ <Badge variant="error" size="sm">Error</Badge>
11
+
12
+ @example
13
+ // With icon
14
+ <Badge variant="primary">
15
+ {#snippet iconLeft()}<CheckIcon size={12} />{/snippet}
16
+ Verified
17
+ </Badge>
18
+
19
+ @example
20
+ // Clickable badge
21
+ <Badge onclick={() => filter('active')} clickable>Active</Badge>
22
+ -->
23
+ <script module>
24
+ import { cv } from '../../utils/cn.svelte.js';
25
+
26
+ export const badgeStyles = cv({
27
+ base: [
28
+ 'inline-flex items-center justify-center gap-1',
29
+ 'font-medium rounded-full',
30
+ 'transition-colors'
31
+ ].join(' '),
32
+
33
+ variants: {
34
+ variant: {
35
+ default: 'bg-[var(--color-base02)] text-[var(--color-base05)]',
36
+ primary: 'bg-[color-mix(in_srgb,var(--color-base0D)_15%,transparent)] text-[var(--color-base0D)]',
37
+ secondary: 'bg-[color-mix(in_srgb,var(--color-base0C)_15%,transparent)] text-[var(--color-base0C)]',
38
+ success: 'bg-[color-mix(in_srgb,var(--color-base0B)_15%,transparent)] text-[var(--color-base0B)]',
39
+ warning: 'bg-[color-mix(in_srgb,var(--color-base0A)_15%,transparent)] text-[var(--color-base0A)]',
40
+ error: 'bg-[color-mix(in_srgb,var(--color-base08)_15%,transparent)] text-[var(--color-base08)]',
41
+ info: 'bg-[color-mix(in_srgb,var(--color-base0D)_15%,transparent)] text-[var(--color-base0D)]'
42
+ },
43
+
44
+ size: {
45
+ sm: 'px-2 py-0.5 text-xs',
46
+ md: 'px-2.5 py-1 text-xs',
47
+ lg: 'px-3 py-1.5 text-sm'
48
+ },
49
+
50
+ clickable: {
51
+ true: 'cursor-pointer hover:opacity-80',
52
+ false: ''
53
+ }
54
+ },
55
+
56
+ defaults: {
57
+ variant: 'default',
58
+ size: 'md',
59
+ clickable: 'false'
60
+ }
61
+ });
62
+ </script>
63
+
64
+ <script>
65
+ import { cn } from '../../utils/cn.svelte.js';
66
+
67
+ let {
68
+ children,
69
+ iconLeft,
70
+ iconRight,
71
+ variant = 'default',
72
+ size = 'md',
73
+ clickable = false,
74
+ class: className = '',
75
+ onclick,
76
+ ...rest
77
+ } = $props();
78
+
79
+ const badgeClass = $derived(
80
+ badgeStyles({
81
+ variant,
82
+ size,
83
+ clickable: (clickable || onclick) ? 'true' : 'false',
84
+ class: className
85
+ })
86
+ );
87
+ </script>
88
+
89
+ {#if onclick || clickable}
90
+ <button
91
+ type="button"
92
+ class={badgeClass}
93
+ {onclick}
94
+ {...rest}
95
+ >
96
+ {#if iconLeft}
97
+ <span class="shrink-0">{@render iconLeft()}</span>
98
+ {/if}
99
+ {#if children}
100
+ {@render children()}
101
+ {/if}
102
+ {#if iconRight}
103
+ <span class="shrink-0">{@render iconRight()}</span>
104
+ {/if}
105
+ </button>
106
+ {:else}
107
+ <span class={badgeClass} {...rest}>
108
+ {#if iconLeft}
109
+ <span class="shrink-0">{@render iconLeft()}</span>
110
+ {/if}
111
+ {#if children}
112
+ {@render children()}
113
+ {/if}
114
+ {#if iconRight}
115
+ <span class="shrink-0">{@render iconRight()}</span>
116
+ {/if}
117
+ </span>
118
+ {/if}
@@ -0,0 +1,214 @@
1
+ <!--
2
+ @component Button
3
+
4
+ A polymorphic button component with variants, sizes, and loading states.
5
+
6
+ @example
7
+ <Button>Click me</Button>
8
+ <Button variant="secondary" size="lg">Large Secondary</Button>
9
+ <Button href="/about">Link Button</Button>
10
+ <Button loading>Loading...</Button>
11
+ -->
12
+ <script>
13
+ import { cn } from '../../utils/cn.svelte.js';
14
+
15
+ let {
16
+ children,
17
+ iconLeft,
18
+ iconRight,
19
+ variant = 'primary',
20
+ size = 'md',
21
+ disabled = false,
22
+ loading = false,
23
+ fullWidth = false,
24
+ href,
25
+ type = 'button',
26
+ class: className = '',
27
+ onclick,
28
+ ...restProps
29
+ } = $props();
30
+
31
+ const isLink = $derived(!!href);
32
+ const isIconOnly = $derived(!children && (!!iconLeft || !!iconRight));
33
+ const isDisabled = $derived(disabled || loading);
34
+
35
+ const buttonClass = $derived(
36
+ cn(
37
+ 'btn',
38
+ `btn-${variant}`,
39
+ `btn-${size}`,
40
+ fullWidth && 'btn-full',
41
+ isIconOnly && 'btn-icon-only',
42
+ className
43
+ )
44
+ );
45
+
46
+ const spinnerSize = $derived({
47
+ xs: 12, sm: 14, md: 16, lg: 18, xl: 20
48
+ }[size]);
49
+ </script>
50
+
51
+ {#if isLink}
52
+ <a
53
+ {href}
54
+ class={buttonClass}
55
+ aria-disabled={isDisabled || undefined}
56
+ role="button"
57
+ {...restProps}
58
+ >
59
+ {#if loading}
60
+ <svg class="btn-spinner" width={spinnerSize} height={spinnerSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
61
+ <circle cx="12" cy="12" r="10" opacity="0.25" />
62
+ <path d="M12 2a10 10 0 0 1 10 10" opacity="0.75" />
63
+ </svg>
64
+ {:else if iconLeft}
65
+ <span class="btn-icon">{@render iconLeft()}</span>
66
+ {/if}
67
+ {#if children}
68
+ <span class={loading ? 'btn-content-loading' : ''}>{@render children()}</span>
69
+ {/if}
70
+ {#if iconRight && !loading}
71
+ <span class="btn-icon">{@render iconRight()}</span>
72
+ {/if}
73
+ </a>
74
+ {:else}
75
+ <button
76
+ {type}
77
+ class={buttonClass}
78
+ disabled={isDisabled}
79
+ aria-busy={loading || undefined}
80
+ {onclick}
81
+ {...restProps}
82
+ >
83
+ {#if loading}
84
+ <svg class="btn-spinner" width={spinnerSize} height={spinnerSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
85
+ <circle cx="12" cy="12" r="10" opacity="0.25" />
86
+ <path d="M12 2a10 10 0 0 1 10 10" opacity="0.75" />
87
+ </svg>
88
+ {:else if iconLeft}
89
+ <span class="btn-icon">{@render iconLeft()}</span>
90
+ {/if}
91
+ {#if children}
92
+ <span class={loading ? 'btn-content-loading' : ''}>{@render children()}</span>
93
+ {/if}
94
+ {#if iconRight && !loading}
95
+ <span class="btn-icon">{@render iconRight()}</span>
96
+ {/if}
97
+ </button>
98
+ {/if}
99
+
100
+ <style>
101
+ /* Base button styles */
102
+ .btn {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ gap: 0.5rem;
107
+ font-weight: 500;
108
+ border-radius: var(--radius-lg, 0.5rem);
109
+ border: 1px solid transparent;
110
+ cursor: pointer;
111
+ user-select: none;
112
+ transition: all 150ms ease-out;
113
+ }
114
+
115
+ .btn:focus-visible {
116
+ outline: none;
117
+ box-shadow: 0 0 0 2px var(--color-base00), 0 0 0 4px var(--color-base0D);
118
+ }
119
+
120
+ .btn:disabled {
121
+ opacity: 0.5;
122
+ cursor: not-allowed;
123
+ pointer-events: none;
124
+ }
125
+
126
+ /* Variants */
127
+ .btn-primary {
128
+ background-color: var(--color-base0D);
129
+ color: var(--color-base07);
130
+ }
131
+ .btn-primary:hover {
132
+ filter: brightness(1.1);
133
+ }
134
+ .btn-primary:active {
135
+ filter: brightness(0.95);
136
+ }
137
+
138
+ .btn-secondary {
139
+ background-color: var(--color-base02);
140
+ color: var(--color-base07);
141
+ border-color: var(--color-base03);
142
+ }
143
+ .btn-secondary:hover {
144
+ background-color: var(--color-base03);
145
+ }
146
+
147
+ .btn-ghost {
148
+ background-color: transparent;
149
+ color: var(--color-base05);
150
+ }
151
+ .btn-ghost:hover {
152
+ background-color: var(--color-base02);
153
+ }
154
+
155
+ .btn-outline {
156
+ background-color: transparent;
157
+ color: var(--color-base0D);
158
+ border-color: color-mix(in srgb, var(--color-base0D) 40%, transparent);
159
+ }
160
+ .btn-outline:hover {
161
+ background-color: color-mix(in srgb, var(--color-base0D) 5%, transparent);
162
+ border-color: var(--color-base0D);
163
+ }
164
+
165
+ .btn-danger {
166
+ background-color: var(--color-base08);
167
+ color: var(--color-base07);
168
+ }
169
+ .btn-danger:hover {
170
+ filter: brightness(1.1);
171
+ }
172
+
173
+ .btn-success {
174
+ background-color: var(--color-base0B);
175
+ color: var(--color-base07);
176
+ }
177
+ .btn-success:hover {
178
+ filter: brightness(1.1);
179
+ }
180
+
181
+ /* Sizes */
182
+ .btn-xs { height: 1.75rem; padding: 0 0.625rem; font-size: 0.75rem; }
183
+ .btn-sm { height: 2rem; padding: 0 0.75rem; font-size: 0.875rem; }
184
+ .btn-md { height: 2.5rem; padding: 0 1rem; font-size: 0.875rem; }
185
+ .btn-lg { height: 3rem; padding: 0 1.5rem; font-size: 1rem; }
186
+ .btn-xl { height: 3.5rem; padding: 0 2rem; font-size: 1.125rem; }
187
+
188
+ /* Modifiers */
189
+ .btn-full { width: 100%; }
190
+ .btn-icon-only { aspect-ratio: 1; padding: 0; }
191
+ .btn-icon-only.btn-xs { width: 1.75rem; }
192
+ .btn-icon-only.btn-sm { width: 2rem; }
193
+ .btn-icon-only.btn-md { width: 2.5rem; }
194
+ .btn-icon-only.btn-lg { width: 3rem; }
195
+ .btn-icon-only.btn-xl { width: 3.5rem; }
196
+
197
+ /* Icon and spinner */
198
+ .btn-icon {
199
+ display: inline-flex;
200
+ flex-shrink: 0;
201
+ }
202
+
203
+ .btn-spinner {
204
+ animation: spin 1s linear infinite;
205
+ }
206
+
207
+ .btn-content-loading {
208
+ opacity: 0;
209
+ }
210
+
211
+ @keyframes spin {
212
+ to { transform: rotate(360deg); }
213
+ }
214
+ </style>
@@ -0,0 +1,104 @@
1
+ <!--
2
+ @component Card
3
+
4
+ A flexible card container with optional title and variants.
5
+
6
+ @example Basic
7
+ <Card>
8
+ <p>Card content here</p>
9
+ </Card>
10
+
11
+ @example With title
12
+ <Card title="Settings">
13
+ <p>Your settings content</p>
14
+ </Card>
15
+
16
+ @example Danger variant
17
+ <Card title="Danger Zone" variant="danger">
18
+ <Button variant="danger">Delete Account</Button>
19
+ </Card>
20
+ -->
21
+ <script>
22
+ let {
23
+ title = '',
24
+ variant = 'default',
25
+ class: className = '',
26
+ children
27
+ } = $props();
28
+ </script>
29
+
30
+ <div class="card card-{variant} {className}">
31
+ {#if title}
32
+ <h3 class="card-title">{@html title}</h3>
33
+ {/if}
34
+ <div class="card-content">
35
+ {@render children?.()}
36
+ </div>
37
+ </div>
38
+
39
+ <style>
40
+ .card {
41
+ background: transparent;
42
+ border: 1px solid var(--color-base02);
43
+ border-radius: var(--radius-xl);
44
+ padding: var(--space-6);
45
+ transition: border-color 0.2s ease;
46
+ }
47
+
48
+ .card:hover {
49
+ border-color: var(--color-base03);
50
+ }
51
+
52
+ .card-danger {
53
+ border-color: color-mix(in srgb, var(--color-base08) 30%, transparent);
54
+ background: color-mix(in srgb, var(--color-base08) 3%, transparent);
55
+ }
56
+
57
+ .card-danger:hover {
58
+ border-color: color-mix(in srgb, var(--color-base08) 40%, transparent);
59
+ }
60
+
61
+ .card-warning {
62
+ border-color: color-mix(in srgb, var(--color-base0A) 30%, transparent);
63
+ background: color-mix(in srgb, var(--color-base0A) 3%, transparent);
64
+ }
65
+
66
+ .card-warning:hover {
67
+ border-color: color-mix(in srgb, var(--color-base0A) 40%, transparent);
68
+ }
69
+
70
+ .card-success {
71
+ border-color: color-mix(in srgb, var(--color-base0B) 30%, transparent);
72
+ background: color-mix(in srgb, var(--color-base0B) 3%, transparent);
73
+ }
74
+
75
+ .card-success:hover {
76
+ border-color: color-mix(in srgb, var(--color-base0B) 40%, transparent);
77
+ }
78
+
79
+ .card-title {
80
+ margin: 0 0 var(--space-5) 0;
81
+ font-size: var(--text-base);
82
+ font-weight: 500;
83
+ color: var(--color-base07);
84
+ display: flex;
85
+ align-items: center;
86
+ gap: var(--space-2);
87
+ }
88
+
89
+ .card-danger .card-title {
90
+ color: var(--color-base08);
91
+ }
92
+
93
+ .card-warning .card-title {
94
+ color: var(--color-base0A);
95
+ }
96
+
97
+ .card-success .card-title {
98
+ color: var(--color-base0B);
99
+ }
100
+
101
+ .card-content {
102
+ /* Allow child components to handle their own spacing */
103
+ }
104
+ </style>
@@ -0,0 +1,105 @@
1
+ <!--
2
+ @component Divider
3
+
4
+ A visual separator for content sections.
5
+
6
+ @example Horizontal divider
7
+ <Divider />
8
+
9
+ @example Vertical divider
10
+ <Divider orientation="vertical" />
11
+
12
+ @example With label
13
+ <Divider>
14
+ or continue with
15
+ </Divider>
16
+ -->
17
+ <script>
18
+ let {
19
+ orientation = 'horizontal',
20
+ thickness = '1px',
21
+ spacing = '1rem',
22
+ children,
23
+ class: className = ''
24
+ } = $props();
25
+
26
+ const isHorizontal = $derived(orientation === 'horizontal');
27
+ </script>
28
+
29
+ {#if children}
30
+ <div
31
+ class="divider divider-{orientation} divider-labeled {className}"
32
+ style="--divider-spacing: {spacing};"
33
+ role="separator"
34
+ aria-orientation={orientation}
35
+ >
36
+ <span class="divider-line" style="--divider-thickness: {thickness};"></span>
37
+ <span class="divider-label">
38
+ {@render children()}
39
+ </span>
40
+ <span class="divider-line" style="--divider-thickness: {thickness};"></span>
41
+ </div>
42
+ {:else}
43
+ <div
44
+ class="divider divider-{orientation} {className}"
45
+ style="--divider-thickness: {thickness}; --divider-spacing: {spacing};"
46
+ role="separator"
47
+ aria-orientation={orientation}
48
+ ></div>
49
+ {/if}
50
+
51
+ <style>
52
+ .divider {
53
+ background: color-mix(in srgb, var(--color-base04) 30%, transparent);
54
+ flex-shrink: 0;
55
+ }
56
+
57
+ .divider-horizontal {
58
+ width: 100%;
59
+ height: var(--divider-thickness, 1px);
60
+ margin: var(--divider-spacing, 1rem) 0;
61
+ }
62
+
63
+ .divider-vertical {
64
+ width: var(--divider-thickness, 1px);
65
+ height: 100%;
66
+ margin: 0 var(--divider-spacing, 1rem);
67
+ }
68
+
69
+ .divider-labeled {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 1rem;
73
+ background: transparent;
74
+ }
75
+
76
+ .divider-labeled.divider-horizontal {
77
+ height: auto;
78
+ }
79
+
80
+ .divider-labeled.divider-vertical {
81
+ flex-direction: column;
82
+ width: auto;
83
+ }
84
+
85
+ .divider-line {
86
+ flex: 1;
87
+ background: color-mix(in srgb, var(--color-base04) 30%, transparent);
88
+ }
89
+
90
+ .divider-horizontal .divider-line {
91
+ height: var(--divider-thickness, 1px);
92
+ }
93
+
94
+ .divider-vertical .divider-line {
95
+ width: var(--divider-thickness, 1px);
96
+ }
97
+
98
+ .divider-label {
99
+ font-size: 0.75rem;
100
+ color: var(--color-base04);
101
+ text-transform: uppercase;
102
+ letter-spacing: 0.05em;
103
+ white-space: nowrap;
104
+ }
105
+ </style>
@@ -0,0 +1,104 @@
1
+ <!--
2
+ @component LazyImage
3
+
4
+ Intersection Observer-based lazy loading image with placeholder support.
5
+
6
+ @example Basic
7
+ <LazyImage src="/photo.jpg" alt="Description" />
8
+
9
+ @example With placeholder
10
+ <LazyImage
11
+ src="/large-photo.jpg"
12
+ alt="Photo"
13
+ placeholder="/tiny-blur.jpg"
14
+ />
15
+
16
+ @example Custom threshold
17
+ <LazyImage
18
+ src="/image.jpg"
19
+ alt="Image"
20
+ threshold={0.5}
21
+ rootMargin="100px"
22
+ />
23
+ -->
24
+ <script>
25
+ import { onMount } from 'svelte';
26
+
27
+ let {
28
+ src,
29
+ alt = '',
30
+ width,
31
+ height,
32
+ loading = 'lazy',
33
+ threshold = 0.01,
34
+ rootMargin = '50px',
35
+ placeholder = null,
36
+ class: className = '',
37
+ onload,
38
+ ...rest
39
+ } = $props();
40
+
41
+ let imgElement = $state(null);
42
+ let isLoaded = $state(false);
43
+ let isInView = $state(false);
44
+ let actualSrc = $state(placeholder || '');
45
+
46
+ onMount(() => {
47
+ if ('IntersectionObserver' in window && imgElement) {
48
+ const observer = new IntersectionObserver(
49
+ (entries) => {
50
+ entries.forEach((entry) => {
51
+ if (entry.isIntersecting && !isLoaded) {
52
+ isInView = true;
53
+ actualSrc = src;
54
+ observer.unobserve(imgElement);
55
+ }
56
+ });
57
+ },
58
+ { rootMargin, threshold }
59
+ );
60
+
61
+ observer.observe(imgElement);
62
+
63
+ return () => {
64
+ observer.disconnect();
65
+ };
66
+ } else {
67
+ actualSrc = src;
68
+ isInView = true;
69
+ }
70
+ });
71
+
72
+ function handleLoad() {
73
+ isLoaded = true;
74
+ onload?.();
75
+ }
76
+ </script>
77
+
78
+ <img
79
+ bind:this={imgElement}
80
+ src={actualSrc}
81
+ {alt}
82
+ {width}
83
+ {height}
84
+ {loading}
85
+ class="lazy-image {isLoaded ? 'lazy-loaded' : 'lazy-loading'} {className}"
86
+ onload={handleLoad}
87
+ decoding="async"
88
+ {...rest}
89
+ />
90
+
91
+ <style>
92
+ .lazy-image {
93
+ transition: opacity 0.3s ease;
94
+ }
95
+
96
+ .lazy-loading {
97
+ opacity: 0;
98
+ background: var(--color-base01);
99
+ }
100
+
101
+ .lazy-loaded {
102
+ opacity: 1;
103
+ }
104
+ </style>