@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.
- package/README.md +341 -16
- package/dist/components/Accordion/Accordion.svelte +2 -2
- package/dist/components/Accordion/AccordionItem.svelte +2 -2
- package/dist/components/Chatbot/ChatMessage.svelte +143 -0
- package/dist/components/Chatbot/ChatMessage.svelte.d.ts +8 -0
- package/dist/components/Chatbot/Chatbot.svelte +640 -0
- package/dist/components/Chatbot/Chatbot.svelte.d.ts +22 -0
- package/dist/components/Chatbot/index.d.ts +3 -0
- package/dist/components/Chatbot/index.js +2 -0
- package/dist/components/Chatbot/types.d.ts +48 -0
- package/dist/components/Chatbot/types.js +2 -0
- package/dist/components/ContactForm/ContactForm.svelte +564 -0
- package/dist/components/ContactForm/ContactForm.svelte.d.ts +44 -0
- package/dist/components/ContactForm/index.d.ts +1 -0
- package/dist/components/ContactForm/index.js +1 -0
- package/dist/components/DoughnutChart/DoughnutChart.svelte +372 -0
- package/dist/components/DoughnutChart/DoughnutChart.svelte.d.ts +25 -0
- package/dist/components/DoughnutChart/index.d.ts +1 -0
- package/dist/components/DoughnutChart/index.js +1 -0
- package/dist/components/FAB/FAB.svelte +5 -1
- package/dist/components/FAB/FABGroup.svelte +10 -2
- package/dist/components/FileUpload/FileUpload.svelte +12 -12
- package/dist/components/Mermaid/Mermaid.svelte +206 -0
- package/dist/components/Mermaid/Mermaid.svelte.d.ts +28 -0
- package/dist/components/Mermaid/index.d.ts +1 -0
- package/dist/components/Mermaid/index.js +1 -0
- package/dist/components/Mermaid/mermaid.d.ts +21 -0
- package/dist/components/NumberInput/NumberInput.svelte +293 -0
- package/dist/components/NumberInput/NumberInput.svelte.d.ts +16 -0
- package/dist/components/NumberInput/index.d.ts +1 -0
- package/dist/components/NumberInput/index.js +1 -0
- package/dist/components/Pagination/Pagination.svelte +243 -0
- package/dist/components/Pagination/Pagination.svelte.d.ts +10 -0
- package/dist/components/Pagination/index.d.ts +1 -0
- package/dist/components/Pagination/index.js +1 -0
- package/dist/components/Popover/PopoverTrigger.svelte +1 -3
- package/dist/components/ToggleGroup/ToggleGroup.svelte +91 -0
- package/dist/components/ToggleGroup/ToggleGroup.svelte.d.ts +13 -0
- package/dist/components/ToggleGroup/ToggleGroupItem.svelte +158 -0
- package/dist/components/ToggleGroup/ToggleGroupItem.svelte.d.ts +9 -0
- package/dist/components/ToggleGroup/index.d.ts +3 -0
- package/dist/components/ToggleGroup/index.js +2 -0
- package/dist/components/ViewCounter/ViewCounter.svelte +157 -0
- package/dist/components/ViewCounter/ViewCounter.svelte.d.ts +17 -0
- package/dist/components/ViewCounter/index.d.ts +1 -0
- package/dist/components/ViewCounter/index.js +1 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.js +12 -0
- package/dist/styles/tokens.css +2 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/input.d.ts +35 -0
- 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';
|
|
@@ -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,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>
|