@marianmeres/stuic 3.1.0 → 3.2.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.
@@ -0,0 +1,344 @@
1
+ <script lang="ts" module>
2
+ import type { ItemCollection as ItemCollectionBase } from "@marianmeres/item-collection";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import type { Snippet } from "svelte";
5
+ import type { THC } from "../Thc/index.js";
6
+
7
+ export interface CarouselItem {
8
+ id: string | number;
9
+ content: THC;
10
+ disabled?: boolean;
11
+ data?: Record<string, any>;
12
+ }
13
+
14
+ interface ItemCollectionItem extends CarouselItem {}
15
+ export interface ItemColl extends ItemCollectionBase<ItemCollectionItem> {}
16
+
17
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
18
+ /** Array of carousel items */
19
+ items: CarouselItem[];
20
+
21
+ /** Number of items visible per view (default: 1) */
22
+ itemsPerView?: number;
23
+
24
+ /** Percentage of next item to show as peek hint (0-50) */
25
+ peekPercent?: number;
26
+
27
+ /** Gap between items in pixels or CSS value */
28
+ gap?: number | string;
29
+
30
+ /** Enable/disable active item tracking */
31
+ trackActive?: boolean;
32
+
33
+ /** Sync active item based on scroll position (requires trackActive) */
34
+ syncActiveOnScroll?: boolean;
35
+
36
+ /** Currently active item index (bindable) */
37
+ activeIndex?: number;
38
+
39
+ /** Currently active item value/id (bindable) */
40
+ value?: string | number;
41
+
42
+ /** Enable scroll snap behavior (default: true) */
43
+ snap?: boolean;
44
+
45
+ /** Snap alignment: start, center, end (default: start) */
46
+ snapAlign?: "start" | "center" | "end";
47
+
48
+ /** Enable keyboard navigation (default: true) */
49
+ keyboard?: boolean;
50
+
51
+ /** Allow cycling from last to first and vice versa */
52
+ loop?: boolean;
53
+
54
+ /** Scroll behavior for programmatic navigation (default: smooth) */
55
+ scrollBehavior?: ScrollBehavior;
56
+
57
+ /** Show scrollbar on hover (default: true). Set to false when using navigation buttons */
58
+ scrollbar?: boolean;
59
+
60
+ /** Custom class for container */
61
+ class?: string;
62
+
63
+ /** Custom class for the scrollable track */
64
+ classTrack?: string;
65
+
66
+ /** Custom class for each item wrapper */
67
+ classItem?: string;
68
+
69
+ /** Custom class for active item */
70
+ classItemActive?: string;
71
+
72
+ /** Skip all default styling */
73
+ unstyled?: boolean;
74
+
75
+ /** Bindable element reference */
76
+ el?: HTMLDivElement;
77
+
78
+ /** Callback when active item changes */
79
+ onActiveChange?: (item: CarouselItem, index: number) => void;
80
+
81
+ /** Custom render snippet for items (alternative to THC) */
82
+ renderItem?: Snippet<[{ item: CarouselItem; index: number; active: boolean }]>;
83
+ }
84
+ </script>
85
+
86
+ <script lang="ts">
87
+ import { ItemCollection } from "@marianmeres/item-collection";
88
+ import { twMerge } from "../../utils/tw-merge.js";
89
+ import Thc from "../Thc/Thc.svelte";
90
+
91
+ let {
92
+ items,
93
+ itemsPerView = 1,
94
+ peekPercent = 0,
95
+ gap,
96
+ trackActive = false,
97
+ syncActiveOnScroll = false,
98
+ activeIndex = $bindable(0),
99
+ value = $bindable(),
100
+ snap = true,
101
+ snapAlign = "start",
102
+ keyboard = true,
103
+ loop = false,
104
+ scrollBehavior = "smooth",
105
+ scrollbar = true,
106
+ class: classProp,
107
+ classTrack,
108
+ classItem,
109
+ classItemActive,
110
+ unstyled = false,
111
+ el = $bindable(),
112
+ onActiveChange,
113
+ renderItem,
114
+ ...rest
115
+ }: Props = $props();
116
+
117
+ // Internal refs
118
+ let trackEl: HTMLDivElement | undefined = $state();
119
+ let itemEls: Record<string | number, HTMLDivElement> = $state({});
120
+
121
+ // ItemCollection for managing items and active state
122
+ const coll: ItemColl = $derived.by(() => {
123
+ const out = new ItemCollection(
124
+ items.map((item) => ({ ...item })),
125
+ {
126
+ idPropName: "id",
127
+ allowNextPrevCycle: loop,
128
+ }
129
+ );
130
+
131
+ // Set initial active based on value or activeIndex
132
+ if (value !== undefined) {
133
+ const idx = out.items.findIndex((item) => item.id === value);
134
+ if (idx > -1) out.setActiveIndex(idx);
135
+ } else if (activeIndex !== undefined && activeIndex >= 0) {
136
+ out.setActiveIndex(activeIndex);
137
+ }
138
+
139
+ return out;
140
+ });
141
+
142
+ // Sync collection changes back to bindable props
143
+ $effect(() => {
144
+ return coll.subscribe((c) => {
145
+ if (trackActive) {
146
+ value = c.active?.id;
147
+ activeIndex = c.activeIndex ?? 0;
148
+ if (c.active && c.activeIndex !== undefined) {
149
+ onActiveChange?.(c.active, c.activeIndex);
150
+ }
151
+ }
152
+ });
153
+ });
154
+
155
+ // Flag to prevent scroll loops (when programmatic scroll triggers observer)
156
+ let isScrollingProgrammatically = false;
157
+
158
+ // Track active item based on scroll position using IntersectionObserver
159
+ $effect(() => {
160
+ if (!trackActive || !syncActiveOnScroll || !trackEl) return;
161
+
162
+ const observer = new IntersectionObserver(
163
+ (entries) => {
164
+ if (isScrollingProgrammatically) return;
165
+
166
+ // Find the entry with highest intersection ratio
167
+ let mostVisible: { id: string | number; ratio: number } | null = null;
168
+
169
+ for (const entry of entries) {
170
+ if (entry.isIntersecting) {
171
+ const id = entry.target.getAttribute("data-id");
172
+ if (id && (!mostVisible || entry.intersectionRatio > mostVisible.ratio)) {
173
+ // Parse id back to number if it was originally a number
174
+ const parsedId = coll.items.find((i) => String(i.id) === id)?.id;
175
+ if (parsedId !== undefined) {
176
+ mostVisible = { id: parsedId, ratio: entry.intersectionRatio };
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ if (mostVisible && mostVisible.id !== coll.active?.id) {
183
+ const item = coll.items.find((i) => i.id === mostVisible!.id);
184
+ if (item) {
185
+ coll.setActive(item);
186
+ }
187
+ }
188
+ },
189
+ {
190
+ root: trackEl,
191
+ threshold: [0.5, 0.75, 1.0],
192
+ }
193
+ );
194
+
195
+ // Observe all item elements
196
+ for (const id in itemEls) {
197
+ if (itemEls[id]) {
198
+ observer.observe(itemEls[id]);
199
+ }
200
+ }
201
+
202
+ return () => observer.disconnect();
203
+ });
204
+
205
+ // Scroll active item into view when active changes programmatically
206
+ function scrollActiveIntoView() {
207
+ // Set flag to prevent IntersectionObserver from re-triggering
208
+ isScrollingProgrammatically = true;
209
+
210
+ // Use setTimeout to allow the ItemCollection state to update first
211
+ setTimeout(() => {
212
+ const activeItem = coll.active;
213
+ if (activeItem && itemEls[activeItem.id]) {
214
+ itemEls[activeItem.id]?.scrollIntoView({
215
+ behavior: scrollBehavior,
216
+ block: "nearest",
217
+ inline: snapAlign === "center" ? "center" : snapAlign === "end" ? "end" : "start",
218
+ });
219
+ }
220
+
221
+ // Reset flag after scroll animation completes
222
+ setTimeout(() => {
223
+ isScrollingProgrammatically = false;
224
+ }, scrollBehavior === "instant" ? 0 : 300);
225
+ }, 0);
226
+ }
227
+
228
+ // Keyboard navigation handler
229
+ function handleKeydown(e: KeyboardEvent) {
230
+ if (!keyboard) return;
231
+
232
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
233
+ e.preventDefault();
234
+ coll.setActiveNext();
235
+ scrollActiveIntoView();
236
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
237
+ e.preventDefault();
238
+ coll.setActivePrevious();
239
+ scrollActiveIntoView();
240
+ } else if (e.key === "Home") {
241
+ e.preventDefault();
242
+ coll.setActiveFirst();
243
+ scrollActiveIntoView();
244
+ } else if (e.key === "End") {
245
+ e.preventDefault();
246
+ coll.setActiveLast();
247
+ scrollActiveIntoView();
248
+ }
249
+ }
250
+
251
+ // Public API methods
252
+ export function goTo(index: number) {
253
+ coll.setActiveIndex(index);
254
+ scrollActiveIntoView();
255
+ }
256
+
257
+ export function goToId(id: string | number) {
258
+ const item = coll.items.find((i) => i.id === id);
259
+ if (item) {
260
+ coll.setActive(item);
261
+ scrollActiveIntoView();
262
+ }
263
+ }
264
+
265
+ export function next() {
266
+ coll.setActiveNext();
267
+ scrollActiveIntoView();
268
+ }
269
+
270
+ export function previous() {
271
+ coll.setActivePrevious();
272
+ scrollActiveIntoView();
273
+ }
274
+
275
+ export function getCollection() {
276
+ return coll;
277
+ }
278
+
279
+ // Compute inline styles for gap and peek
280
+ let trackStyle = $derived.by(() => {
281
+ const styles: string[] = [];
282
+ if (gap !== undefined) {
283
+ styles.push(`--stuic-carousel-gap: ${typeof gap === "number" ? `${gap}px` : gap}`);
284
+ }
285
+ if (peekPercent > 0) {
286
+ styles.push(`--stuic-carousel-peek-percent: ${peekPercent}%`);
287
+ }
288
+ return styles.join("; ");
289
+ });
290
+
291
+ // Helper to check if item is active
292
+ function isItemActive(index: number): boolean {
293
+ return trackActive && coll.activeIndex === index;
294
+ }
295
+ </script>
296
+
297
+ {#if coll.size}
298
+ <div
299
+ bind:this={el}
300
+ class={twMerge(!unstyled && "stuic-carousel", classProp)}
301
+ data-items-per-view={!unstyled ? itemsPerView : undefined}
302
+ {...rest}
303
+ >
304
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
305
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
306
+ <div
307
+ bind:this={trackEl}
308
+ class={twMerge(!unstyled && "stuic-carousel-track", classTrack)}
309
+ style={trackStyle || undefined}
310
+ tabindex={keyboard ? 0 : undefined}
311
+ role="region"
312
+ aria-label="Carousel"
313
+ aria-roledescription="carousel"
314
+ data-snap={!unstyled && snap ? "true" : undefined}
315
+ data-snap-align={!unstyled && snap ? snapAlign : undefined}
316
+ data-scrollbar={!unstyled && scrollbar ? "true" : undefined}
317
+ onkeydown={handleKeydown}
318
+ >
319
+ {#each coll.items as item, i (item.id)}
320
+ {@const active = isItemActive(i)}
321
+ <div
322
+ bind:this={itemEls[item.id]}
323
+ data-id={item.id}
324
+ class={twMerge(
325
+ !unstyled && "stuic-carousel-item",
326
+ classItem,
327
+ active && classItemActive
328
+ )}
329
+ role="group"
330
+ aria-roledescription="slide"
331
+ aria-label={`Slide ${i + 1} of ${coll.size}`}
332
+ data-active={active ? "true" : undefined}
333
+ data-disabled={item.disabled ? "true" : undefined}
334
+ >
335
+ {#if renderItem}
336
+ {@render renderItem({ item, index: i, active })}
337
+ {:else}
338
+ <Thc thc={item.content} />
339
+ {/if}
340
+ </div>
341
+ {/each}
342
+ </div>
343
+ </div>
344
+ {/if}
@@ -0,0 +1,73 @@
1
+ import type { ItemCollection as ItemCollectionBase } from "@marianmeres/item-collection";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { THC } from "../Thc/index.js";
5
+ export interface CarouselItem {
6
+ id: string | number;
7
+ content: THC;
8
+ disabled?: boolean;
9
+ data?: Record<string, any>;
10
+ }
11
+ interface ItemCollectionItem extends CarouselItem {
12
+ }
13
+ export interface ItemColl extends ItemCollectionBase<ItemCollectionItem> {
14
+ }
15
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
16
+ /** Array of carousel items */
17
+ items: CarouselItem[];
18
+ /** Number of items visible per view (default: 1) */
19
+ itemsPerView?: number;
20
+ /** Percentage of next item to show as peek hint (0-50) */
21
+ peekPercent?: number;
22
+ /** Gap between items in pixels or CSS value */
23
+ gap?: number | string;
24
+ /** Enable/disable active item tracking */
25
+ trackActive?: boolean;
26
+ /** Sync active item based on scroll position (requires trackActive) */
27
+ syncActiveOnScroll?: boolean;
28
+ /** Currently active item index (bindable) */
29
+ activeIndex?: number;
30
+ /** Currently active item value/id (bindable) */
31
+ value?: string | number;
32
+ /** Enable scroll snap behavior (default: true) */
33
+ snap?: boolean;
34
+ /** Snap alignment: start, center, end (default: start) */
35
+ snapAlign?: "start" | "center" | "end";
36
+ /** Enable keyboard navigation (default: true) */
37
+ keyboard?: boolean;
38
+ /** Allow cycling from last to first and vice versa */
39
+ loop?: boolean;
40
+ /** Scroll behavior for programmatic navigation (default: smooth) */
41
+ scrollBehavior?: ScrollBehavior;
42
+ /** Show scrollbar on hover (default: true). Set to false when using navigation buttons */
43
+ scrollbar?: boolean;
44
+ /** Custom class for container */
45
+ class?: string;
46
+ /** Custom class for the scrollable track */
47
+ classTrack?: string;
48
+ /** Custom class for each item wrapper */
49
+ classItem?: string;
50
+ /** Custom class for active item */
51
+ classItemActive?: string;
52
+ /** Skip all default styling */
53
+ unstyled?: boolean;
54
+ /** Bindable element reference */
55
+ el?: HTMLDivElement;
56
+ /** Callback when active item changes */
57
+ onActiveChange?: (item: CarouselItem, index: number) => void;
58
+ /** Custom render snippet for items (alternative to THC) */
59
+ renderItem?: Snippet<[{
60
+ item: CarouselItem;
61
+ index: number;
62
+ active: boolean;
63
+ }]>;
64
+ }
65
+ declare const Carousel: import("svelte").Component<Props, {
66
+ goTo: (index: number) => void;
67
+ goToId: (id: string | number) => void;
68
+ next: () => void;
69
+ previous: () => void;
70
+ getCollection: () => ItemColl;
71
+ }, "value" | "el" | "activeIndex">;
72
+ type Carousel = ReturnType<typeof Carousel>;
73
+ export default Carousel;
@@ -0,0 +1,134 @@
1
+ # Carousel
2
+
3
+ A horizontally scrollable carousel component with optional active item tracking,
4
+ keyboard navigation, snap scrolling, and flexible content rendering via THC.
5
+
6
+ ## Props
7
+
8
+ | Prop | Type | Default | Description |
9
+ | ---------------- | -------------------------------------------------------------- | ---------- | ----------------------------------------------- |
10
+ | `items` | `CarouselItem[]` | required | Array of carousel items |
11
+ | `itemsPerView` | `number` | `1` | Number of items visible per view |
12
+ | `peekPercent` | `number` | `0` | Percentage of next item to show (0-50) |
13
+ | `gap` | `number \| string` | - | Gap between items |
14
+ | `trackActive` | `boolean` | `false` | Enable active item tracking |
15
+ | `activeIndex` | `number` | `0` | Active item index (bindable) |
16
+ | `value` | `string \| number` | - | Active item id (bindable) |
17
+ | `snap` | `boolean` | `true` | Enable scroll snap |
18
+ | `snapAlign` | `"start" \| "center" \| "end"` | `"start"` | Snap alignment |
19
+ | `keyboard` | `boolean` | `true` | Enable keyboard navigation |
20
+ | `loop` | `boolean` | `false` | Loop navigation |
21
+ | `scrollBehavior` | `ScrollBehavior` | `"smooth"` | Scroll behavior |
22
+ | `class` | `string` | - | Custom class for container |
23
+ | `classTrack` | `string` | - | Custom class for scroll track |
24
+ | `classItem` | `string` | - | Custom class for items |
25
+ | `classItemActive`| `string` | - | Custom class for active item |
26
+ | `unstyled` | `boolean` | `false` | Skip default styling |
27
+ | `el` | `HTMLDivElement` | - | Element reference (bindable) |
28
+ | `onActiveChange` | `(item: CarouselItem, index: number) => void` | - | Callback when active changes |
29
+ | `renderItem` | `Snippet<[{ item: CarouselItem; index: number; active: boolean }]>` | - | Custom item render snippet |
30
+
31
+ ## CarouselItem Interface
32
+
33
+ ```typescript
34
+ interface CarouselItem {
35
+ id: string | number;
36
+ content: THC; // Text, HTML, Component, or Snippet
37
+ disabled?: boolean;
38
+ data?: Record<string, any>; // Custom data for renderItem
39
+ }
40
+ ```
41
+
42
+ ## Methods
43
+
44
+ | Method | Description |
45
+ | ------------ | ------------------------------ |
46
+ | `goTo(index)`| Navigate to item at index |
47
+ | `goToId(id)` | Navigate to item with id |
48
+ | `next()` | Navigate to next item |
49
+ | `previous()` | Navigate to previous item |
50
+
51
+ ## Usage
52
+
53
+ ### Basic
54
+
55
+ ```svelte
56
+ <Carousel
57
+ items={[
58
+ { id: 1, content: "Slide 1" },
59
+ { id: 2, content: "Slide 2" },
60
+ { id: 3, content: "Slide 3" },
61
+ ]}
62
+ />
63
+ ```
64
+
65
+ ### Multiple Items Per View with Peek
66
+
67
+ ```svelte
68
+ <Carousel items={items} itemsPerView={3} peekPercent={10} gap={16} />
69
+ ```
70
+
71
+ ### With Active Tracking
72
+
73
+ ```svelte
74
+ <script>
75
+ let activeIndex = $state(0);
76
+ </script>
77
+
78
+ <Carousel
79
+ items={items}
80
+ trackActive
81
+ bind:activeIndex
82
+ onActiveChange={(item, i) => console.log('Active:', item)}
83
+ />
84
+ ```
85
+
86
+ ### Custom Rendering
87
+
88
+ ```svelte
89
+ <Carousel items={items}>
90
+ {#snippet renderItem({ item, index, active })}
91
+ <div class="card" class:active>
92
+ <h3>{item.content}</h3>
93
+ <p>{item.data?.description}</p>
94
+ </div>
95
+ {/snippet}
96
+ </Carousel>
97
+ ```
98
+
99
+ ### Programmatic Navigation
100
+
101
+ ```svelte
102
+ <script>
103
+ let carousel: Carousel;
104
+ </script>
105
+
106
+ <Carousel bind:this={carousel} items={items} trackActive />
107
+
108
+ <button onclick={() => carousel.previous()}>Previous</button>
109
+ <button onclick={() => carousel.next()}>Next</button>
110
+ <button onclick={() => carousel.goTo(0)}>First</button>
111
+ ```
112
+
113
+ ## CSS Variables
114
+
115
+ | Variable | Default | Description |
116
+ | --------------------------------------- | -------------------------- | ----------------------- |
117
+ | `--stuic-carousel-gap` | `1rem` | Gap between items |
118
+ | `--stuic-carousel-peek-percent` | `0%` | Peek percentage |
119
+ | `--stuic-carousel-item-radius` | `--radius-md` | Item border radius |
120
+ | `--stuic-carousel-item-bg` | `transparent` | Item background |
121
+ | `--stuic-carousel-item-bg-active` | `transparent` | Active item background |
122
+ | `--stuic-carousel-item-border` | `transparent` | Item border color |
123
+ | `--stuic-carousel-item-border-active` | `--stuic-color-primary` | Active border |
124
+ | `--stuic-carousel-item-border-width` | `0` | Item border width |
125
+ | `--stuic-carousel-item-border-width-active` | `2px` | Active border width |
126
+ | `--stuic-carousel-ring-width` | `3px` | Focus ring width |
127
+ | `--stuic-carousel-ring-color` | `--stuic-color-ring` | Focus ring color |
128
+
129
+ ## Keyboard Navigation
130
+
131
+ - **ArrowLeft/ArrowRight**: Previous/Next item
132
+ - **ArrowUp/ArrowDown**: Previous/Next item (alternative)
133
+ - **Home**: First item
134
+ - **End**: Last item
@@ -0,0 +1,206 @@
1
+ /* ============================================================================
2
+ CAROUSEL COMPONENT TOKENS
3
+ Override globally: :root { --stuic-carousel-gap: 2rem; }
4
+ Override locally: <Carousel style="--stuic-carousel-gap: 2rem;">
5
+ ============================================================================ */
6
+
7
+ :root {
8
+ /* Layout tokens */
9
+ --stuic-carousel-gap: 1rem;
10
+ --stuic-carousel-padding: 0;
11
+ --stuic-carousel-peek-percent: 0%;
12
+
13
+ /* Item tokens */
14
+ --stuic-carousel-item-radius: var(--radius-md);
15
+ --stuic-carousel-item-bg: transparent;
16
+ --stuic-carousel-item-bg-active: transparent;
17
+ --stuic-carousel-item-border: transparent;
18
+ --stuic-carousel-item-border-active: var(--stuic-color-primary);
19
+ --stuic-carousel-item-border-width: 1px;
20
+ --stuic-carousel-item-border-width-active: 1px;
21
+
22
+ /* Scroll behavior */
23
+ --stuic-carousel-scroll-padding: 0;
24
+ --stuic-carousel-snap-align: start;
25
+
26
+ /* Focus ring */
27
+ --stuic-carousel-ring-width: 2px;
28
+ --stuic-carousel-ring-color: var(--stuic-color-ring);
29
+
30
+ /* Transition */
31
+ --stuic-carousel-transition: 150ms;
32
+ }
33
+
34
+ @layer components {
35
+ /* ============================================================================
36
+ BASE STYLES
37
+ ============================================================================ */
38
+
39
+ .stuic-carousel {
40
+ position: relative;
41
+ width: 100%;
42
+ overflow: visible;
43
+ }
44
+
45
+ .stuic-carousel-track {
46
+ display: flex;
47
+ gap: var(--stuic-carousel-gap);
48
+ overflow-x: auto;
49
+ overflow-y: hidden;
50
+ scroll-padding: var(--stuic-carousel-scroll-padding);
51
+
52
+ /* Hide scrollbar by default */
53
+ scrollbar-width: none;
54
+ -ms-overflow-style: none;
55
+
56
+ /* Mobile-friendly */
57
+ -webkit-overflow-scrolling: touch;
58
+ touch-action: pan-x pan-y;
59
+ }
60
+
61
+ /* Hide scrollbar for non-scrollbar mode */
62
+ .stuic-carousel-track::-webkit-scrollbar {
63
+ display: none;
64
+ }
65
+
66
+ /* Always show scrollbar space to prevent height jump, but thumb invisible until hover */
67
+ .stuic-carousel-track[data-scrollbar="true"] {
68
+ scrollbar-width: thin;
69
+ scrollbar-color: transparent transparent;
70
+ }
71
+
72
+ .stuic-carousel-track[data-scrollbar="true"]::-webkit-scrollbar {
73
+ display: block;
74
+ height: 8px;
75
+ }
76
+
77
+ .stuic-carousel-track[data-scrollbar="true"]::-webkit-scrollbar-track {
78
+ background: transparent;
79
+ }
80
+
81
+ .stuic-carousel-track[data-scrollbar="true"]::-webkit-scrollbar-thumb {
82
+ background: transparent;
83
+ border-radius: 4px;
84
+ transition: background 150ms;
85
+ }
86
+
87
+ /* Show scrollbar thumb on hover */
88
+ .stuic-carousel-track[data-scrollbar="true"]:hover {
89
+ scrollbar-color: var(--stuic-color-border, #ccc) transparent;
90
+ }
91
+
92
+ .stuic-carousel-track[data-scrollbar="true"]:hover::-webkit-scrollbar-thumb {
93
+ background: var(--stuic-color-border, #ccc);
94
+ }
95
+
96
+ .stuic-carousel-track[data-scrollbar="true"]:hover::-webkit-scrollbar-thumb:hover {
97
+ background: var(--stuic-color-border-hover, #999);
98
+ }
99
+
100
+ /* Snap scrolling */
101
+ .stuic-carousel-track[data-snap="true"] {
102
+ scroll-snap-type: x mandatory;
103
+ }
104
+
105
+ /* Focus styles for keyboard navigation */
106
+ .stuic-carousel-track:focus {
107
+ outline: none;
108
+ }
109
+
110
+ .stuic-carousel-track:focus-visible {
111
+ outline: var(--stuic-carousel-ring-width) solid var(--stuic-carousel-ring-color);
112
+ outline-offset: 2px;
113
+ }
114
+
115
+ /* ============================================================================
116
+ ITEM STYLES
117
+ ============================================================================ */
118
+
119
+ .stuic-carousel-item {
120
+ flex: 0 0 auto;
121
+ /* Width calculated via CSS custom property */
122
+ width: var(--_item-width, 100%);
123
+ min-width: 0;
124
+
125
+ border-radius: var(--stuic-carousel-item-radius);
126
+ background: var(--stuic-carousel-item-bg);
127
+ border: var(--stuic-carousel-item-border-width) solid
128
+ var(--stuic-carousel-item-border);
129
+
130
+ transition:
131
+ border-color var(--stuic-carousel-transition),
132
+ background var(--stuic-carousel-transition),
133
+ border-width var(--stuic-carousel-transition);
134
+ }
135
+
136
+ /* Snap alignment */
137
+ .stuic-carousel-track[data-snap="true"] .stuic-carousel-item {
138
+ scroll-snap-align: var(--stuic-carousel-snap-align, start);
139
+ }
140
+
141
+ .stuic-carousel-track[data-snap-align="center"] .stuic-carousel-item {
142
+ scroll-snap-align: center;
143
+ }
144
+
145
+ .stuic-carousel-track[data-snap-align="end"] .stuic-carousel-item {
146
+ scroll-snap-align: end;
147
+ }
148
+
149
+ /* Active item */
150
+ .stuic-carousel-item[data-active="true"] {
151
+ background: var(--stuic-carousel-item-bg-active);
152
+ border-color: var(--stuic-carousel-item-border-active);
153
+ border-width: var(--stuic-carousel-item-border-width-active);
154
+ }
155
+
156
+ /* Disabled item */
157
+ .stuic-carousel-item[data-disabled="true"] {
158
+ opacity: 0.5;
159
+ pointer-events: none;
160
+ }
161
+
162
+ /* ============================================================================
163
+ ITEMS PER VIEW CALCULATIONS
164
+ Width = (100% - (N-1)*gap - peek) / N
165
+ ============================================================================ */
166
+
167
+ /* 1 item per view (default) */
168
+ .stuic-carousel[data-items-per-view="1"] .stuic-carousel-item {
169
+ --_item-width: calc(100% - var(--stuic-carousel-peek-percent, 0%));
170
+ }
171
+
172
+ /* 2 items per view */
173
+ .stuic-carousel[data-items-per-view="2"] .stuic-carousel-item {
174
+ --_item-width: calc(
175
+ (100% - var(--stuic-carousel-gap) - var(--stuic-carousel-peek-percent, 0%)) / 2
176
+ );
177
+ }
178
+
179
+ /* 3 items per view */
180
+ .stuic-carousel[data-items-per-view="3"] .stuic-carousel-item {
181
+ --_item-width: calc(
182
+ (100% - 2 * var(--stuic-carousel-gap) - var(--stuic-carousel-peek-percent, 0%)) / 3
183
+ );
184
+ }
185
+
186
+ /* 4 items per view */
187
+ .stuic-carousel[data-items-per-view="4"] .stuic-carousel-item {
188
+ --_item-width: calc(
189
+ (100% - 3 * var(--stuic-carousel-gap) - var(--stuic-carousel-peek-percent, 0%)) / 4
190
+ );
191
+ }
192
+
193
+ /* 5 items per view */
194
+ .stuic-carousel[data-items-per-view="5"] .stuic-carousel-item {
195
+ --_item-width: calc(
196
+ (100% - 4 * var(--stuic-carousel-gap) - var(--stuic-carousel-peek-percent, 0%)) / 5
197
+ );
198
+ }
199
+
200
+ /* 6 items per view */
201
+ .stuic-carousel[data-items-per-view="6"] .stuic-carousel-item {
202
+ --_item-width: calc(
203
+ (100% - 5 * var(--stuic-carousel-gap) - var(--stuic-carousel-peek-percent, 0%)) / 6
204
+ );
205
+ }
206
+ }
@@ -0,0 +1 @@
1
+ export { default as Carousel, type Props as CarouselProps, type CarouselItem, type ItemColl as CarouselItemCollection, } from "./Carousel.svelte";
@@ -0,0 +1 @@
1
+ export { default as Carousel, } from "./Carousel.svelte";
@@ -243,32 +243,13 @@
243
243
  setValidationResult,
244
244
  });
245
245
 
246
- const INPUT_CLS = [
247
- "w-full",
248
- // "rounded bg-neutral-50 dark:bg-neutral-800",
249
- // "focus:outline-none focus:ring-0",
250
- // "border border-neutral-300 dark:border-neutral-600",
251
- // "focus:border-neutral-400 focus:dark:border-neutral-500",
252
- // "focus-visible:outline-none focus-visible:ring-0",
253
- ].join(" ");
254
-
255
- const INPUT_EXPANDED_CLS = [
256
- // "w-full",
257
- // "rounded bg-neutral-50 dark:bg-neutral-800",
258
- // "focus:outline-none focus:ring-0",
259
- "border border-neutral-300 dark:border-neutral-600",
260
- // "focus:border-neutral-400 focus:dark:border-neutral-500",
261
- // "focus-visible:outline-none focus-visible:ring-0",
262
- ].join(" ");
246
+ const INPUT_CLS = "w-full";
263
247
 
264
248
  const BTN_CLS = [
249
+ "toggle-btn",
265
250
  "px-2 rounded-r block",
266
- "opacity-60 hover:opacity-100",
267
251
  "min-w-[44px] min-h-[44px]",
268
252
  "flex items-center justify-center",
269
- "hover:bg-neutral-200 dark:hover:bg-neutral-600",
270
- // "focus-visible:outline-neutral-400",
271
- // "disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent",
272
253
  ].join(" ");
273
254
  </script>
274
255
 
@@ -294,19 +275,14 @@
294
275
  {validation}
295
276
  {style}
296
277
  >
297
- <div class="w-full flex">
278
+ <div class="stuic-input-localized w-full flex">
298
279
  <div class="flex-1">
299
280
  {#if !expanded}
300
281
  {#if multiline}
301
282
  <textarea
302
283
  value={entries.find((e) => e.language === _defaultLanguage)?.value ?? ""}
303
284
  oninput={(e) => updateEntry(_defaultLanguage, e.currentTarget.value)}
304
- class={twMerge(
305
- INPUT_CLS,
306
- expanded && INPUT_EXPANDED_CLS,
307
- "min-h-16",
308
- classLanguageInput
309
- )}
285
+ class={twMerge(INPUT_CLS, "min-h-16", classLanguageInput)}
310
286
  {disabled}
311
287
  {tabindex}
312
288
  {placeholder}
@@ -320,30 +296,31 @@
320
296
  type="text"
321
297
  value={entries.find((e) => e.language === _defaultLanguage)?.value ?? ""}
322
298
  oninput={(e) => updateEntry(_defaultLanguage, e.currentTarget.value)}
323
- class={twMerge(INPUT_CLS, expanded && INPUT_EXPANDED_CLS, classLanguageInput)}
299
+ class={twMerge(INPUT_CLS, classLanguageInput)}
324
300
  {disabled}
325
301
  {tabindex}
326
302
  {placeholder}
327
303
  />
328
304
  {/if}
329
305
  {:else}
330
- <div class="">
306
+ <div class="expanded-wrap">
331
307
  <!-- Expanded: all language rows -->
332
308
  {#each sortedLanguages as lang, idx (lang)}
333
309
  <div
334
310
  class={twMerge(
335
311
  "flex-1 flex gap-2 items-center pl-2",
336
- idx > 0 && "border-t border-neutral-200 dark:border-neutral-600",
312
+ idx > 0 && "entry-divider",
337
313
  classEntry
338
314
  )}
339
315
  >
340
316
  <div
341
317
  class={twMerge(
318
+ "lang-label",
342
319
  "shrink-0 min-w-8",
343
320
  "flex",
344
- "text-sm font-medium opacity-60 uppercase",
321
+ "text-sm font-medium uppercase",
345
322
  lang === _defaultLanguage &&
346
- "after:content-['*'] after:opacity-40 after:pl-0.5",
323
+ "lang-label-default after:content-['*'] after:pl-0.5",
347
324
  classLanguageLabel
348
325
  )}
349
326
  >
@@ -59,6 +59,13 @@
59
59
  --stuic-checkbox-checked-bg: var(--stuic-input-accent);
60
60
  --stuic-checkbox-checked-border: var(--stuic-input-accent);
61
61
 
62
+ /* FieldInputLocalized specific */
63
+ --stuic-input-localized-divider: var(--stuic-color-border);
64
+ --stuic-input-localized-toggle-text: var(--stuic-color-muted-foreground);
65
+ --stuic-input-localized-toggle-text-hover: var(--stuic-color-foreground);
66
+ --stuic-input-localized-toggle-hover-bg: var(--stuic-color-muted);
67
+ --stuic-input-localized-label-text: var(--stuic-color-muted-foreground);
68
+
62
69
  /* FieldOptions specific */
63
70
  --stuic-field-options-divider: var(--stuic-color-border);
64
71
  --stuic-field-options-control-text: var(--stuic-color-muted-foreground);
@@ -217,7 +224,11 @@
217
224
  .stuic-input .input-wrap.invalid:focus-within {
218
225
  border-color: var(--stuic-input-accent-error);
219
226
  box-shadow: 0 0 0 var(--stuic-input-ring-width)
220
- color-mix(in srgb, var(--stuic-input-accent-error) 20%, var(--stuic-color-background));
227
+ color-mix(
228
+ in srgb,
229
+ var(--stuic-input-accent-error) 20%,
230
+ var(--stuic-color-background)
231
+ );
221
232
  }
222
233
 
223
234
  /* Disabled state */
@@ -541,4 +552,37 @@
541
552
  .stuic-field-options-icon--selected {
542
553
  opacity: 1;
543
554
  }
555
+
556
+ /* ============================================================================
557
+ FIELD INPUT LOCALIZED
558
+ ============================================================================ */
559
+
560
+ .stuic-input-localized .entry-divider {
561
+ border-top: 1px solid var(--stuic-input-localized-divider);
562
+ }
563
+
564
+ .stuic-input-localized .toggle-btn {
565
+ color: var(--stuic-input-localized-toggle-text);
566
+ transition:
567
+ background var(--stuic-input-transition),
568
+ color var(--stuic-input-transition);
569
+ }
570
+
571
+ .stuic-input-localized .toggle-btn:hover:not(:disabled) {
572
+ color: var(--stuic-input-localized-toggle-text-hover);
573
+ background: var(--stuic-input-localized-toggle-hover-bg);
574
+ }
575
+
576
+ .stuic-input-localized .lang-label {
577
+ color: var(--stuic-input-localized-label-text);
578
+ }
579
+
580
+ .stuic-input-localized .lang-label-default::after {
581
+ opacity: 0.5;
582
+ }
583
+
584
+ .stuic-input-localized .expanded-wrap {
585
+ border-right: 1px solid var(--stuic-input-localized-divider);
586
+ /* border-radius: var(--stuic-input-radius); */
587
+ }
544
588
  }
package/dist/index.css CHANGED
@@ -30,6 +30,7 @@ In practice:
30
30
  @import "./components/Button/index.css";
31
31
  @import "./components/ButtonGroupRadio/index.css";
32
32
  @import "./components/Collapsible/index.css";
33
+ @import "./components/Carousel/index.css";
33
34
  @import "./components/CommandMenu/index.css";
34
35
  @import "./components/DismissibleMessage/index.css";
35
36
  @import "./components/DropdownMenu/index.css";
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./components/Avatar/index.js";
29
29
  export * from "./components/Backdrop/index.js";
30
30
  export * from "./components/Button/index.js";
31
31
  export * from "./components/ButtonGroupRadio/index.js";
32
+ export * from "./components/Carousel/index.js";
32
33
  export * from "./components/Collapsible/index.js";
33
34
  export * from "./components/ColorScheme/index.js";
34
35
  export * from "./components/CommandMenu/index.js";
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ export * from "./components/Avatar/index.js";
30
30
  export * from "./components/Backdrop/index.js";
31
31
  export * from "./components/Button/index.js";
32
32
  export * from "./components/ButtonGroupRadio/index.js";
33
+ export * from "./components/Carousel/index.js";
33
34
  export * from "./components/Collapsible/index.js";
34
35
  export * from "./components/ColorScheme/index.js";
35
36
  export * from "./components/CommandMenu/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",
@@ -26,22 +26,22 @@
26
26
  "@marianmeres/icons-fns": "^5.0.0",
27
27
  "@marianmeres/random-human-readable": "^1.6.1",
28
28
  "@sveltejs/adapter-auto": "^4.0.0",
29
- "@sveltejs/kit": "^2.50.1",
29
+ "@sveltejs/kit": "^2.50.2",
30
30
  "@sveltejs/package": "^2.5.7",
31
31
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
32
32
  "@tailwindcss/cli": "^4.1.18",
33
33
  "@tailwindcss/forms": "^0.5.11",
34
34
  "@tailwindcss/typography": "^0.5.19",
35
35
  "@tailwindcss/vite": "^4.1.18",
36
- "@types/node": "^25.1.0",
36
+ "@types/node": "^25.2.0",
37
37
  "dotenv": "^16.6.1",
38
38
  "eslint": "^9.39.2",
39
39
  "globals": "^16.5.0",
40
40
  "prettier": "^3.8.1",
41
41
  "prettier-plugin-svelte": "^3.4.1",
42
42
  "publint": "^0.3.17",
43
- "svelte": "^5.49.0",
44
- "svelte-check": "^4.3.5",
43
+ "svelte": "^5.49.1",
44
+ "svelte-check": "^4.3.6",
45
45
  "tailwindcss": "^4.1.18",
46
46
  "tsx": "^4.21.0",
47
47
  "typescript": "^5.9.3",