@aspect-ops/exon-ui 0.1.0 → 0.2.1

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 (52) hide show
  1. package/README.md +341 -16
  2. package/dist/components/Accordion/Accordion.svelte +2 -2
  3. package/dist/components/Accordion/AccordionItem.svelte +2 -2
  4. package/dist/components/Chatbot/ChatMessage.svelte +143 -0
  5. package/dist/components/Chatbot/ChatMessage.svelte.d.ts +8 -0
  6. package/dist/components/Chatbot/Chatbot.svelte +640 -0
  7. package/dist/components/Chatbot/Chatbot.svelte.d.ts +22 -0
  8. package/dist/components/Chatbot/index.d.ts +3 -0
  9. package/dist/components/Chatbot/index.js +2 -0
  10. package/dist/components/Chatbot/types.d.ts +48 -0
  11. package/dist/components/Chatbot/types.js +2 -0
  12. package/dist/components/ContactForm/ContactForm.svelte +564 -0
  13. package/dist/components/ContactForm/ContactForm.svelte.d.ts +44 -0
  14. package/dist/components/ContactForm/index.d.ts +1 -0
  15. package/dist/components/ContactForm/index.js +1 -0
  16. package/dist/components/DoughnutChart/DoughnutChart.svelte +372 -0
  17. package/dist/components/DoughnutChart/DoughnutChart.svelte.d.ts +25 -0
  18. package/dist/components/DoughnutChart/index.d.ts +1 -0
  19. package/dist/components/DoughnutChart/index.js +1 -0
  20. package/dist/components/FAB/FAB.svelte +5 -1
  21. package/dist/components/FAB/FABGroup.svelte +10 -2
  22. package/dist/components/FileUpload/FileUpload.svelte +12 -12
  23. package/dist/components/Mermaid/Mermaid.svelte +206 -0
  24. package/dist/components/Mermaid/Mermaid.svelte.d.ts +28 -0
  25. package/dist/components/Mermaid/index.d.ts +1 -0
  26. package/dist/components/Mermaid/index.js +1 -0
  27. package/dist/components/Mermaid/mermaid.d.ts +21 -0
  28. package/dist/components/NumberInput/NumberInput.svelte +293 -0
  29. package/dist/components/NumberInput/NumberInput.svelte.d.ts +16 -0
  30. package/dist/components/NumberInput/index.d.ts +1 -0
  31. package/dist/components/NumberInput/index.js +1 -0
  32. package/dist/components/Pagination/Pagination.svelte +243 -0
  33. package/dist/components/Pagination/Pagination.svelte.d.ts +10 -0
  34. package/dist/components/Pagination/index.d.ts +1 -0
  35. package/dist/components/Pagination/index.js +1 -0
  36. package/dist/components/Popover/PopoverTrigger.svelte +1 -3
  37. package/dist/components/ToggleGroup/ToggleGroup.svelte +91 -0
  38. package/dist/components/ToggleGroup/ToggleGroup.svelte.d.ts +13 -0
  39. package/dist/components/ToggleGroup/ToggleGroupItem.svelte +158 -0
  40. package/dist/components/ToggleGroup/ToggleGroupItem.svelte.d.ts +9 -0
  41. package/dist/components/ToggleGroup/index.d.ts +3 -0
  42. package/dist/components/ToggleGroup/index.js +2 -0
  43. package/dist/components/ViewCounter/ViewCounter.svelte +157 -0
  44. package/dist/components/ViewCounter/ViewCounter.svelte.d.ts +17 -0
  45. package/dist/components/ViewCounter/index.d.ts +1 -0
  46. package/dist/components/ViewCounter/index.js +1 -0
  47. package/dist/index.d.ts +13 -1
  48. package/dist/index.js +12 -0
  49. package/dist/styles/tokens.css +2 -1
  50. package/dist/types/index.d.ts +1 -1
  51. package/dist/types/input.d.ts +35 -0
  52. package/package.json +2 -1
@@ -0,0 +1,243 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ currentPage: number;
4
+ totalPages: number;
5
+ siblingCount?: number;
6
+ onPageChange?: (page: number) => void;
7
+ class?: string;
8
+ }
9
+
10
+ let {
11
+ currentPage,
12
+ totalPages,
13
+ siblingCount = 1,
14
+ onPageChange,
15
+ class: className = ''
16
+ }: Props = $props();
17
+
18
+ const canGoPrevious = $derived(currentPage > 1);
19
+ const canGoNext = $derived(currentPage < totalPages);
20
+
21
+ // Generate page numbers with ellipsis logic
22
+ const pageNumbers = $derived(() => {
23
+ const pages: (number | 'ellipsis-start' | 'ellipsis-end')[] = [];
24
+
25
+ // Always show first page
26
+ pages.push(1);
27
+
28
+ // Calculate the range around current page
29
+ const leftSibling = Math.max(currentPage - siblingCount, 2);
30
+ const rightSibling = Math.min(currentPage + siblingCount, totalPages - 1);
31
+
32
+ // Add ellipsis after first page if needed
33
+ if (leftSibling > 2) {
34
+ pages.push('ellipsis-start');
35
+ }
36
+
37
+ // Add sibling pages
38
+ for (let i = leftSibling; i <= rightSibling; i++) {
39
+ pages.push(i);
40
+ }
41
+
42
+ // Add ellipsis before last page if needed
43
+ if (rightSibling < totalPages - 1) {
44
+ pages.push('ellipsis-end');
45
+ }
46
+
47
+ // Always show last page (if more than 1 page)
48
+ if (totalPages > 1) {
49
+ pages.push(totalPages);
50
+ }
51
+
52
+ return pages;
53
+ });
54
+
55
+ function handlePageClick(page: number) {
56
+ if (page !== currentPage && page >= 1 && page <= totalPages) {
57
+ onPageChange?.(page);
58
+ }
59
+ }
60
+
61
+ function handlePrevious() {
62
+ if (canGoPrevious) {
63
+ onPageChange?.(currentPage - 1);
64
+ }
65
+ }
66
+
67
+ function handleNext() {
68
+ if (canGoNext) {
69
+ onPageChange?.(currentPage + 1);
70
+ }
71
+ }
72
+
73
+ function handleKeyDown(event: KeyboardEvent) {
74
+ switch (event.key) {
75
+ case 'ArrowLeft':
76
+ event.preventDefault();
77
+ handlePrevious();
78
+ break;
79
+ case 'ArrowRight':
80
+ event.preventDefault();
81
+ handleNext();
82
+ break;
83
+ case 'Home':
84
+ event.preventDefault();
85
+ handlePageClick(1);
86
+ break;
87
+ case 'End':
88
+ event.preventDefault();
89
+ handlePageClick(totalPages);
90
+ break;
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
96
+ <nav class="pagination {className}" aria-label="Pagination">
97
+ <div class="pagination-wrapper" role="group" onkeydown={handleKeyDown}>
98
+ <button
99
+ class="pagination-button pagination-button--previous"
100
+ disabled={!canGoPrevious}
101
+ onclick={handlePrevious}
102
+ aria-label="Previous page"
103
+ >
104
+ Previous
105
+ </button>
106
+
107
+ <div class="pagination-pages">
108
+ {#each pageNumbers() as item}
109
+ {#if typeof item === 'number'}
110
+ <button
111
+ class="pagination-button pagination-button--page"
112
+ class:pagination-button--current={item === currentPage}
113
+ onclick={() => handlePageClick(item)}
114
+ aria-label={item === currentPage ? `Current page, page ${item}` : `Go to page ${item}`}
115
+ aria-current={item === currentPage ? 'page' : undefined}
116
+ >
117
+ {item}
118
+ </button>
119
+ {:else}
120
+ <span class="pagination-ellipsis" aria-hidden="true">...</span>
121
+ {/if}
122
+ {/each}
123
+ </div>
124
+
125
+ <button
126
+ class="pagination-button pagination-button--next"
127
+ disabled={!canGoNext}
128
+ onclick={handleNext}
129
+ aria-label="Next page"
130
+ >
131
+ Next
132
+ </button>
133
+ </div>
134
+ </nav>
135
+
136
+ <style>
137
+ .pagination {
138
+ font-family: inherit;
139
+ }
140
+
141
+ .pagination-wrapper {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 0.5rem;
145
+ }
146
+
147
+ .pagination-pages {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 0.25rem;
151
+ }
152
+
153
+ .pagination-button {
154
+ min-width: 2.75rem;
155
+ min-height: 2.75rem; /* 44px minimum touch target */
156
+ padding: 0.5rem 0.75rem;
157
+ border: 1px solid var(--pagination-border-color, #d1d5db);
158
+ border-radius: var(--pagination-border-radius, 0.375rem);
159
+ background-color: var(--pagination-bg-color, #ffffff);
160
+ color: var(--pagination-text-color, #374151);
161
+ font-family: inherit;
162
+ font-size: 0.875rem;
163
+ font-weight: 500;
164
+ cursor: pointer;
165
+ transition: all 0.2s ease;
166
+ }
167
+
168
+ .pagination-button:hover:not(:disabled) {
169
+ background-color: var(--pagination-hover-bg-color, #f3f4f6);
170
+ border-color: var(--pagination-hover-border-color, #9ca3af);
171
+ }
172
+
173
+ .pagination-button:focus-visible {
174
+ outline: 2px solid var(--pagination-focus-color, #3b82f6);
175
+ outline-offset: 2px;
176
+ }
177
+
178
+ .pagination-button:disabled {
179
+ opacity: 0.5;
180
+ cursor: not-allowed;
181
+ }
182
+
183
+ .pagination-button--page {
184
+ min-width: 2.75rem;
185
+ }
186
+
187
+ .pagination-button--current {
188
+ background-color: var(--pagination-current-bg-color, #3b82f6);
189
+ color: var(--pagination-current-text-color, #ffffff);
190
+ border-color: var(--pagination-current-border-color, #3b82f6);
191
+ }
192
+
193
+ .pagination-button--current:hover {
194
+ background-color: var(--pagination-current-hover-bg-color, #2563eb);
195
+ border-color: var(--pagination-current-hover-border-color, #2563eb);
196
+ }
197
+
198
+ .pagination-button--previous,
199
+ .pagination-button--next {
200
+ padding-left: 1rem;
201
+ padding-right: 1rem;
202
+ }
203
+
204
+ .pagination-ellipsis {
205
+ display: inline-flex;
206
+ align-items: center;
207
+ justify-content: center;
208
+ min-width: 2.75rem;
209
+ min-height: 2.75rem;
210
+ padding: 0.5rem;
211
+ color: var(--pagination-ellipsis-color, #6b7280);
212
+ font-size: 0.875rem;
213
+ }
214
+
215
+ /* Mobile responsive: hide some siblings on small screens */
216
+ @media (max-width: 640px) {
217
+ .pagination-wrapper {
218
+ gap: 0.25rem;
219
+ }
220
+
221
+ .pagination-pages {
222
+ gap: 0.125rem;
223
+ }
224
+
225
+ .pagination-button--previous,
226
+ .pagination-button--next {
227
+ padding-left: 0.75rem;
228
+ padding-right: 0.75rem;
229
+ font-size: 0.75rem;
230
+ }
231
+
232
+ .pagination-button--page {
233
+ min-width: 2.75rem;
234
+ padding: 0.5rem;
235
+ font-size: 0.75rem;
236
+ }
237
+
238
+ .pagination-ellipsis {
239
+ min-width: 2rem;
240
+ padding: 0.25rem;
241
+ }
242
+ }
243
+ </style>
@@ -0,0 +1,10 @@
1
+ interface Props {
2
+ currentPage: number;
3
+ totalPages: number;
4
+ siblingCount?: number;
5
+ onPageChange?: (page: number) => void;
6
+ class?: string;
7
+ }
8
+ declare const Pagination: import("svelte").Component<Props, {}, "">;
9
+ type Pagination = ReturnType<typeof Pagination>;
10
+ export default Pagination;
@@ -0,0 +1 @@
1
+ export { default as Pagination } from './Pagination.svelte';
@@ -0,0 +1 @@
1
+ export { default as Pagination } from './Pagination.svelte';
@@ -9,6 +9,4 @@
9
9
  let { class: className = '', children }: Props = $props();
10
10
  </script>
11
11
 
12
- <BitsPopover.Trigger class={className}>
13
- {@render children?.()}
14
- </BitsPopover.Trigger>
12
+ <BitsPopover.Trigger child={children}></BitsPopover.Trigger>
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import { setContext } from 'svelte';
3
+ import type { ToggleGroupType, ToggleGroupOrientation } from '../../types/index.js';
4
+
5
+ interface Props {
6
+ type?: ToggleGroupType;
7
+ value?: string | string[];
8
+ onValueChange?: (value: string | string[]) => void;
9
+ disabled?: boolean;
10
+ orientation?: ToggleGroupOrientation;
11
+ class?: string;
12
+ children?: import('svelte').Snippet;
13
+ }
14
+
15
+ let {
16
+ type = 'single',
17
+ value = $bindable(type === 'single' ? '' : []),
18
+ onValueChange,
19
+ disabled = false,
20
+ orientation = 'horizontal',
21
+ class: className = '',
22
+ children
23
+ }: Props = $props();
24
+
25
+ const handleToggle = (itemValue: string) => {
26
+ if (disabled) return;
27
+
28
+ let newValue: string | string[];
29
+
30
+ if (type === 'single') {
31
+ // Single mode: toggle or select new value
32
+ newValue = (value as string) === itemValue ? '' : itemValue;
33
+ } else {
34
+ // Multiple mode: toggle item in array
35
+ const currentArray = (value as string[]) || [];
36
+ if (currentArray.includes(itemValue)) {
37
+ newValue = currentArray.filter((v) => v !== itemValue);
38
+ } else {
39
+ newValue = [...currentArray, itemValue];
40
+ }
41
+ }
42
+
43
+ value = newValue;
44
+ onValueChange?.(newValue);
45
+ };
46
+
47
+ const isSelected = (itemValue: string): boolean => {
48
+ if (type === 'single') {
49
+ return (value as string) === itemValue;
50
+ } else {
51
+ return ((value as string[]) || []).includes(itemValue);
52
+ }
53
+ };
54
+
55
+ // Set context for child items
56
+ setContext('toggleGroup', {
57
+ get type() {
58
+ return type;
59
+ },
60
+ get disabled() {
61
+ return disabled;
62
+ },
63
+ onToggle: handleToggle,
64
+ isSelected
65
+ });
66
+ </script>
67
+
68
+ <div
69
+ class="toggle-group toggle-group--{orientation} {className}"
70
+ role={type === 'single' ? 'radiogroup' : 'group'}
71
+ aria-disabled={disabled}
72
+ >
73
+ {#if children}
74
+ {@render children()}
75
+ {/if}
76
+ </div>
77
+
78
+ <style>
79
+ .toggle-group {
80
+ display: inline-flex;
81
+ font-family: inherit;
82
+ }
83
+
84
+ .toggle-group--horizontal {
85
+ flex-direction: row;
86
+ }
87
+
88
+ .toggle-group--vertical {
89
+ flex-direction: column;
90
+ }
91
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { ToggleGroupType, ToggleGroupOrientation } from '../../types/index.js';
2
+ interface Props {
3
+ type?: ToggleGroupType;
4
+ value?: string | string[];
5
+ onValueChange?: (value: string | string[]) => void;
6
+ disabled?: boolean;
7
+ orientation?: ToggleGroupOrientation;
8
+ class?: string;
9
+ children?: import('svelte').Snippet;
10
+ }
11
+ declare const ToggleGroup: import("svelte").Component<Props, {}, "value">;
12
+ type ToggleGroup = ReturnType<typeof ToggleGroup>;
13
+ export default ToggleGroup;
@@ -0,0 +1,158 @@
1
+ <script lang="ts">
2
+ import { getContext } from 'svelte';
3
+
4
+ interface Props {
5
+ value: string;
6
+ disabled?: boolean;
7
+ class?: string;
8
+ children?: import('svelte').Snippet;
9
+ }
10
+
11
+ let { value, disabled = false, class: className = '', children }: Props = $props();
12
+
13
+ const context = getContext<{
14
+ type: 'single' | 'multiple';
15
+ disabled: boolean;
16
+ onToggle: (value: string) => void;
17
+ isSelected: (value: string) => boolean;
18
+ }>('toggleGroup');
19
+
20
+ const isGroupDisabled = context?.disabled ?? false;
21
+ const isDisabled = $derived(disabled || isGroupDisabled);
22
+ const isPressed = $derived(context?.isSelected(value) ?? false);
23
+
24
+ const handleClick = () => {
25
+ if (isDisabled) return;
26
+ context?.onToggle(value);
27
+ };
28
+
29
+ const handleKeydown = (e: KeyboardEvent) => {
30
+ const target = e.currentTarget as HTMLElement;
31
+ const group = target.parentElement;
32
+ if (!group) return;
33
+
34
+ const items = Array.from(group.querySelectorAll('[role="radio"], [role="button"]'));
35
+ const currentIndex = items.indexOf(target);
36
+
37
+ let nextIndex = currentIndex;
38
+
39
+ switch (e.key) {
40
+ case 'ArrowRight':
41
+ case 'ArrowDown':
42
+ e.preventDefault();
43
+ nextIndex = (currentIndex + 1) % items.length;
44
+ break;
45
+ case 'ArrowLeft':
46
+ case 'ArrowUp':
47
+ e.preventDefault();
48
+ nextIndex = currentIndex - 1;
49
+ if (nextIndex < 0) nextIndex = items.length - 1;
50
+ break;
51
+ case ' ':
52
+ case 'Enter':
53
+ e.preventDefault();
54
+ handleClick();
55
+ return;
56
+ default:
57
+ return;
58
+ }
59
+
60
+ (items[nextIndex] as HTMLElement)?.focus();
61
+ };
62
+ </script>
63
+
64
+ <button
65
+ class="toggle-group-item {isPressed ? 'toggle-group-item--pressed' : ''} {className}"
66
+ role={context?.type === 'single' ? 'radio' : 'button'}
67
+ aria-pressed={isPressed}
68
+ aria-checked={context?.type === 'single' ? isPressed : undefined}
69
+ aria-disabled={isDisabled}
70
+ disabled={isDisabled}
71
+ tabindex={isDisabled ? -1 : 0}
72
+ onclick={handleClick}
73
+ onkeydown={handleKeydown}
74
+ >
75
+ {#if children}
76
+ {@render children()}
77
+ {/if}
78
+ </button>
79
+
80
+ <style>
81
+ .toggle-group-item {
82
+ position: relative;
83
+ display: inline-flex;
84
+ align-items: center;
85
+ justify-content: center;
86
+ min-height: var(--touch-target-min, 44px);
87
+ min-width: var(--touch-target-min, 44px);
88
+ padding: var(--space-sm, 0.5rem) var(--space-md, 1rem);
89
+ background: var(--color-bg, #ffffff);
90
+ color: var(--color-text, #1f2937);
91
+ border: 1px solid var(--color-border, #e5e7eb);
92
+ font-family: inherit;
93
+ font-size: var(--text-sm, 0.875rem);
94
+ font-weight: 500;
95
+ cursor: pointer;
96
+ transition: all var(--transition-fast, 150ms ease);
97
+ -webkit-tap-highlight-color: transparent;
98
+ margin-left: -1px;
99
+ }
100
+
101
+ .toggle-group-item:first-child {
102
+ margin-left: 0;
103
+ border-top-left-radius: var(--radius-md, 0.375rem);
104
+ border-bottom-left-radius: var(--radius-md, 0.375rem);
105
+ }
106
+
107
+ .toggle-group-item:last-child {
108
+ border-top-right-radius: var(--radius-md, 0.375rem);
109
+ border-bottom-right-radius: var(--radius-md, 0.375rem);
110
+ }
111
+
112
+ :global(.toggle-group--vertical) .toggle-group-item {
113
+ margin-left: 0;
114
+ margin-top: -1px;
115
+ }
116
+
117
+ :global(.toggle-group--vertical) .toggle-group-item:first-child {
118
+ margin-top: 0;
119
+ border-top-left-radius: var(--radius-md, 0.375rem);
120
+ border-top-right-radius: var(--radius-md, 0.375rem);
121
+ border-bottom-left-radius: 0;
122
+ }
123
+
124
+ :global(.toggle-group--vertical) .toggle-group-item:last-child {
125
+ border-top-right-radius: 0;
126
+ border-bottom-left-radius: var(--radius-md, 0.375rem);
127
+ border-bottom-right-radius: var(--radius-md, 0.375rem);
128
+ }
129
+
130
+ .toggle-group-item:hover:not(:disabled) {
131
+ background: var(--color-bg-muted, #f3f4f6);
132
+ z-index: 1;
133
+ }
134
+
135
+ .toggle-group-item:focus-visible {
136
+ outline: 2px solid var(--color-primary, #3b82f6);
137
+ outline-offset: 2px;
138
+ z-index: 2;
139
+ }
140
+
141
+ .toggle-group-item--pressed {
142
+ background: var(--color-primary, #3b82f6);
143
+ color: var(--color-text-inverse, #ffffff);
144
+ border-color: var(--color-primary, #3b82f6);
145
+ z-index: 1;
146
+ }
147
+
148
+ .toggle-group-item--pressed:hover:not(:disabled) {
149
+ background: var(--color-primary-hover, #2563eb);
150
+ border-color: var(--color-primary-hover, #2563eb);
151
+ }
152
+
153
+ .toggle-group-item:disabled {
154
+ opacity: 0.5;
155
+ cursor: not-allowed;
156
+ pointer-events: none;
157
+ }
158
+ </style>
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ value: string;
3
+ disabled?: boolean;
4
+ class?: string;
5
+ children?: import('svelte').Snippet;
6
+ }
7
+ declare const ToggleGroupItem: import("svelte").Component<Props, {}, "">;
8
+ type ToggleGroupItem = ReturnType<typeof ToggleGroupItem>;
9
+ export default ToggleGroupItem;
@@ -0,0 +1,3 @@
1
+ export { default as ToggleGroup } from './ToggleGroup.svelte';
2
+ export { default as ToggleGroupItem } from './ToggleGroupItem.svelte';
3
+ export type { ToggleGroupType, ToggleGroupOrientation, ToggleGroupProps, ToggleGroupItemProps } from '../../types/index.js';
@@ -0,0 +1,2 @@
1
+ export { default as ToggleGroup } from './ToggleGroup.svelte';
2
+ export { default as ToggleGroupItem } from './ToggleGroupItem.svelte';
@@ -0,0 +1,157 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+
4
+ interface Props {
5
+ /** Unique identifier for the content being viewed */
6
+ slug: string;
7
+ /** Whether to show the "views" label */
8
+ showLabel?: boolean;
9
+ /** Whether to track the view (POST to API) or just display */
10
+ trackView?: boolean;
11
+ /** Custom class */
12
+ class?: string;
13
+ /** Callback to get current view count */
14
+ onGetCount?: (slug: string) => Promise<number>;
15
+ /** Callback to track/increment view */
16
+ onTrackView?: (slug: string) => Promise<number>;
17
+ }
18
+
19
+ let {
20
+ slug,
21
+ showLabel = true,
22
+ trackView = true,
23
+ class: className = '',
24
+ onGetCount,
25
+ onTrackView
26
+ }: Props = $props();
27
+
28
+ let viewCount = $state<number | null>(null);
29
+ let isLoading = $state(true);
30
+ let hasError = $state(false);
31
+
32
+ // Format large numbers (1K, 1M, etc.)
33
+ function formatCount(count: number): string {
34
+ if (count >= 1000000) {
35
+ return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
36
+ }
37
+ if (count >= 1000) {
38
+ return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
39
+ }
40
+ return count.toString();
41
+ }
42
+
43
+ // Check if already viewed in this session
44
+ function hasViewedInSession(): boolean {
45
+ if (typeof window === 'undefined') return false;
46
+ const viewed = sessionStorage.getItem(`viewed_${slug}`);
47
+ return viewed === 'true';
48
+ }
49
+
50
+ // Mark as viewed in session
51
+ function markViewedInSession(): void {
52
+ if (typeof window !== 'undefined') {
53
+ sessionStorage.setItem(`viewed_${slug}`, 'true');
54
+ }
55
+ }
56
+
57
+ onMount(async () => {
58
+ try {
59
+ // If tracking is enabled and not already viewed
60
+ if (trackView && onTrackView && !hasViewedInSession()) {
61
+ viewCount = await onTrackView(slug);
62
+ markViewedInSession();
63
+ } else if (onGetCount) {
64
+ // Just get the count without tracking
65
+ viewCount = await onGetCount(slug);
66
+ } else {
67
+ // No callbacks provided, show 0
68
+ viewCount = 0;
69
+ }
70
+ } catch {
71
+ hasError = true;
72
+ viewCount = 0;
73
+ } finally {
74
+ isLoading = false;
75
+ }
76
+ });
77
+
78
+ const formattedCount = $derived(viewCount !== null ? formatCount(viewCount) : '—');
79
+ </script>
80
+
81
+ <span class="view-counter {className}" class:view-counter--loading={isLoading}>
82
+ <svg
83
+ xmlns="http://www.w3.org/2000/svg"
84
+ width="16"
85
+ height="16"
86
+ viewBox="0 0 24 24"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ stroke-width="2"
90
+ stroke-linecap="round"
91
+ stroke-linejoin="round"
92
+ class="view-counter__icon"
93
+ aria-hidden="true"
94
+ >
95
+ <path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
96
+ <circle cx="12" cy="12" r="3" />
97
+ </svg>
98
+ <span class="view-counter__count">
99
+ {#if isLoading}
100
+ <span class="view-counter__skeleton"></span>
101
+ {:else}
102
+ {formattedCount}
103
+ {/if}
104
+ </span>
105
+ {#if showLabel}
106
+ <span class="view-counter__label">views</span>
107
+ {/if}
108
+ </span>
109
+
110
+ <style>
111
+ .view-counter {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ gap: var(--space-xs, 0.25rem);
115
+ font-size: var(--text-sm, 0.875rem);
116
+ color: var(--color-text-muted, #6b7280);
117
+ font-family: inherit;
118
+ }
119
+
120
+ .view-counter__icon {
121
+ flex-shrink: 0;
122
+ opacity: 0.7;
123
+ }
124
+
125
+ .view-counter__count {
126
+ font-weight: var(--font-medium, 500);
127
+ min-width: 1.5rem;
128
+ }
129
+
130
+ .view-counter__label {
131
+ opacity: 0.8;
132
+ }
133
+
134
+ .view-counter__skeleton {
135
+ display: inline-block;
136
+ width: 2rem;
137
+ height: 1em;
138
+ background: linear-gradient(
139
+ 90deg,
140
+ var(--color-bg-muted, #f3f4f6) 25%,
141
+ var(--color-border, #e5e7eb) 50%,
142
+ var(--color-bg-muted, #f3f4f6) 75%
143
+ );
144
+ background-size: 200% 100%;
145
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
146
+ border-radius: var(--radius-sm, 0.25rem);
147
+ }
148
+
149
+ @keyframes skeleton-pulse {
150
+ 0% {
151
+ background-position: 200% 0;
152
+ }
153
+ 100% {
154
+ background-position: -200% 0;
155
+ }
156
+ }
157
+ </style>