@dorsk/tsumikit 0.47.0 → 0.48.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,276 @@
1
+ <script lang="ts" generics="T">
2
+ /**
3
+ * Ordered full-bleed panels stepped with prev/next, dot indicators, arrow
4
+ * keys and horizontal swipe.
5
+ *
6
+ * ARIA follows the WAI-ARIA carousel pattern in its *group* flavour: the
7
+ * root is a region with `aria-roledescription="carousel"`, every slide is a
8
+ * `group` with `aria-roledescription="slide"` named "N of M", and the dots
9
+ * are plain buttons. The tablist flavour was rejected because it makes the
10
+ * dots the primary navigation and turns each slide into a `tabpanel`, which
11
+ * is the wrong model when prev/next is the main affordance and the panels
12
+ * are unlabelled content rather than named destinations. The slide container
13
+ * is a polite live region while the carousel is not auto-rotating.
14
+ */
15
+ import type { Snippet } from 'svelte';
16
+ import IconButton from './IconButton.svelte';
17
+ import { clampSlide, nextSlideForKey, stepSlide, swipeStep } from './carousel-keyboard.js';
18
+
19
+ let {
20
+ slides,
21
+ slide,
22
+ index = $bindable(0),
23
+ onchange,
24
+ loop = false,
25
+ autoplay = 0,
26
+ dots = true,
27
+ controls = true,
28
+ counter = false,
29
+ label = 'Carousel',
30
+ class: klass = '',
31
+ style: styleProp = '',
32
+ slideClass = ''
33
+ }: {
34
+ /** One entry per panel. Entries may themselves be snippets when no `slide` renderer is given. */
35
+ slides: T[];
36
+ /** Renders one panel from its entry and position. */
37
+ slide?: Snippet<[T, number]>;
38
+ /** Active panel; bindable. Clamped (or wrapped with `loop`) into range. */
39
+ index?: number;
40
+ onchange?: (index: number) => void;
41
+ /** Wrap from the last panel to the first and back. */
42
+ loop?: boolean;
43
+ /** Milliseconds between automatic advances. `0` (default) disables it. When
44
+ * on, rotation pauses on hover and focus and a play/pause control appears. */
45
+ autoplay?: number;
46
+ dots?: boolean;
47
+ controls?: boolean;
48
+ /** Show an `N / M` position counter. */
49
+ counter?: boolean;
50
+ label?: string;
51
+ class?: string;
52
+ style?: string;
53
+ slideClass?: string;
54
+ } = $props();
55
+
56
+ const count = $derived(slides.length);
57
+ const baseId = `carousel-${Math.random().toString(36).slice(2, 8)}`;
58
+
59
+ $effect(() => {
60
+ const clamped = clampSlide(index, count, false);
61
+ if (clamped !== index) index = clamped;
62
+ });
63
+
64
+ let rootEl = $state<HTMLElement | null>(null);
65
+
66
+ function go(next: number, focusDot = false) {
67
+ const target = clampSlide(next, count, loop);
68
+ if (target === index) return;
69
+ index = target;
70
+ onchange?.(target);
71
+ if (focusDot) {
72
+ queueMicrotask(() =>
73
+ rootEl?.querySelector<HTMLButtonElement>(`#${baseId}-dot-${target}`)?.focus()
74
+ );
75
+ }
76
+ }
77
+ const prev = () => go(stepSlide(index, count, -1, loop));
78
+ const next = () => go(stepSlide(index, count, 1, loop));
79
+ const atStart = $derived(!loop && index <= 0);
80
+ const atEnd = $derived(!loop && index >= count - 1);
81
+
82
+ function onkeydown(e: KeyboardEvent) {
83
+ const tag = (e.target as HTMLElement | null)?.tagName;
84
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
85
+ const target = nextSlideForKey(index, count, e.key, loop);
86
+ if (target === undefined) return;
87
+ e.preventDefault();
88
+ const inDots = (e.target as HTMLElement | null)?.closest('.dots') !== null;
89
+ go(target, inDots);
90
+ }
91
+
92
+ let startX = 0;
93
+ let startY = 0;
94
+ let pointerId: number | null = null;
95
+ function onpointerdown(e: PointerEvent) {
96
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
97
+ pointerId = e.pointerId;
98
+ startX = e.clientX;
99
+ startY = e.clientY;
100
+ }
101
+ function onpointerup(e: PointerEvent) {
102
+ if (e.pointerId !== pointerId) return;
103
+ pointerId = null;
104
+ const step = swipeStep(e.clientX - startX, e.clientY - startY);
105
+ if (step) go(stepSlide(index, count, step, loop));
106
+ }
107
+ function onpointercancel() {
108
+ pointerId = null;
109
+ }
110
+
111
+ let playing = $derived(autoplay > 0);
112
+ let hovered = $state(false);
113
+ let focused = $state(false);
114
+ const rotating = $derived(playing && autoplay > 0 && count > 1 && !hovered && !focused);
115
+ $effect(() => {
116
+ if (!rotating) return;
117
+ const id = setInterval(() => go(stepSlide(index, count, 1, true)), autoplay);
118
+ return () => clearInterval(id);
119
+ });
120
+ </script>
121
+
122
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
123
+ <section
124
+ bind:this={rootEl}
125
+ class="carousel {klass}"
126
+ style={styleProp}
127
+ aria-roledescription="carousel"
128
+ aria-label={label}
129
+ data-tsu="Carousel"
130
+ {onkeydown}
131
+ onpointerenter={() => (hovered = true)}
132
+ onpointerleave={() => (hovered = false)}
133
+ onfocusin={() => (focused = true)}
134
+ onfocusout={(e) => {
135
+ if (!rootEl?.contains(e.relatedTarget as Node | null)) focused = false;
136
+ }}
137
+ >
138
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
139
+ <div
140
+ class="viewport"
141
+ aria-live={rotating ? 'off' : 'polite'}
142
+ {onpointerdown}
143
+ {onpointerup}
144
+ {onpointercancel}
145
+ >
146
+ <div class="track" style:transform="translateX(-{index * 100}%)">
147
+ {#each slides as item, i (i)}
148
+ <div
149
+ class="slide {slideClass}"
150
+ role="group"
151
+ aria-roledescription="slide"
152
+ aria-label="{i + 1} of {count}"
153
+ aria-hidden={i !== index}
154
+ inert={i !== index}
155
+ id="{baseId}-slide-{i}"
156
+ >
157
+ {#if slide}
158
+ {@render slide(item, i)}
159
+ {:else}
160
+ {@render (item as unknown as Snippet)()}
161
+ {/if}
162
+ </div>
163
+ {/each}
164
+ </div>
165
+ </div>
166
+
167
+ {#if count > 1 && (controls || dots || counter || autoplay > 0)}
168
+ <div class="bar">
169
+ {#if controls}
170
+ <IconButton icon="chevron-left" label="Previous slide" variant="ghost" box="sm" disabled={atStart} onclick={prev} />
171
+ {/if}
172
+ {#if dots}
173
+ <div class="dots" role="group" aria-label="Choose slide">
174
+ {#each slides as _, i (i)}
175
+ <button
176
+ type="button"
177
+ class="dot"
178
+ class:active={i === index}
179
+ id="{baseId}-dot-{i}"
180
+ aria-label="Slide {i + 1}"
181
+ aria-current={i === index ? 'true' : undefined}
182
+ aria-controls="{baseId}-slide-{i}"
183
+ tabindex={i === index ? 0 : -1}
184
+ onclick={() => go(i)}
185
+ ></button>
186
+ {/each}
187
+ </div>
188
+ {/if}
189
+ {#if counter}
190
+ <span class="counter">{index + 1} / {count}</span>
191
+ {/if}
192
+ {#if autoplay > 0}
193
+ <IconButton
194
+ icon={playing ? 'pause' : 'play'}
195
+ label={playing ? 'Stop automatic rotation' : 'Start automatic rotation'}
196
+ variant="ghost"
197
+ box="sm"
198
+ pressed={playing}
199
+ onclick={() => (playing = !playing)}
200
+ />
201
+ {/if}
202
+ {#if controls}
203
+ <IconButton icon="chevron-right" label="Next slide" variant="ghost" box="sm" disabled={atEnd} onclick={next} />
204
+ {/if}
205
+ </div>
206
+ {/if}
207
+ </section>
208
+
209
+ <style>
210
+ .carousel {
211
+ display: flex;
212
+ flex-direction: column;
213
+ gap: var(--sp-3);
214
+ min-width: 0;
215
+ }
216
+ .viewport {
217
+ overflow: hidden;
218
+ border-radius: var(--r-md);
219
+ touch-action: pan-y;
220
+ user-select: none;
221
+ }
222
+ .track {
223
+ display: flex;
224
+ width: 100%;
225
+ transition: transform 0.35s var(--ease);
226
+ }
227
+ .slide {
228
+ flex: 0 0 100%;
229
+ min-width: 0;
230
+ }
231
+ .bar {
232
+ display: flex;
233
+ align-items: center;
234
+ justify-content: center;
235
+ gap: var(--sp-2);
236
+ }
237
+ .dots {
238
+ display: flex;
239
+ align-items: center;
240
+ gap: var(--sp-2);
241
+ }
242
+ .dot {
243
+ width: 0.5rem;
244
+ height: 0.5rem;
245
+ padding: 0;
246
+ border: 0;
247
+ border-radius: var(--r-pill);
248
+ background: var(--border);
249
+ cursor: pointer;
250
+ transition:
251
+ background 0.12s var(--ease),
252
+ transform 0.12s var(--ease);
253
+ }
254
+ .dot:hover {
255
+ background: var(--text-faint);
256
+ }
257
+ .dot.active {
258
+ background: var(--accent);
259
+ transform: scale(1.25);
260
+ }
261
+ .dot:focus-visible {
262
+ outline: 2px solid var(--accent);
263
+ outline-offset: 2px;
264
+ }
265
+ .counter {
266
+ color: var(--text-muted);
267
+ font-size: var(--fs-sm);
268
+ font-variant-numeric: tabular-nums;
269
+ }
270
+ @media (prefers-reduced-motion: reduce) {
271
+ .track,
272
+ .dot {
273
+ transition: none;
274
+ }
275
+ }
276
+ </style>
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Ordered full-bleed panels stepped with prev/next, dot indicators, arrow
3
+ * keys and horizontal swipe.
4
+ *
5
+ * ARIA follows the WAI-ARIA carousel pattern in its *group* flavour: the
6
+ * root is a region with `aria-roledescription="carousel"`, every slide is a
7
+ * `group` with `aria-roledescription="slide"` named "N of M", and the dots
8
+ * are plain buttons. The tablist flavour was rejected because it makes the
9
+ * dots the primary navigation and turns each slide into a `tabpanel`, which
10
+ * is the wrong model when prev/next is the main affordance and the panels
11
+ * are unlabelled content rather than named destinations. The slide container
12
+ * is a polite live region while the carousel is not auto-rotating.
13
+ */
14
+ import type { Snippet } from 'svelte';
15
+ declare function $$render<T>(): {
16
+ props: {
17
+ /** One entry per panel. Entries may themselves be snippets when no `slide` renderer is given. */
18
+ slides: T[];
19
+ /** Renders one panel from its entry and position. */
20
+ slide?: Snippet<[T, number]>;
21
+ /** Active panel; bindable. Clamped (or wrapped with `loop`) into range. */
22
+ index?: number;
23
+ onchange?: (index: number) => void;
24
+ /** Wrap from the last panel to the first and back. */
25
+ loop?: boolean;
26
+ /** Milliseconds between automatic advances. `0` (default) disables it. When
27
+ * on, rotation pauses on hover and focus and a play/pause control appears. */
28
+ autoplay?: number;
29
+ dots?: boolean;
30
+ controls?: boolean;
31
+ /** Show an `N / M` position counter. */
32
+ counter?: boolean;
33
+ label?: string;
34
+ class?: string;
35
+ style?: string;
36
+ slideClass?: string;
37
+ };
38
+ exports: {};
39
+ bindings: "index";
40
+ slots: {};
41
+ events: {};
42
+ };
43
+ declare class __sveltets_Render<T> {
44
+ props(): ReturnType<typeof $$render<T>>['props'];
45
+ events(): ReturnType<typeof $$render<T>>['events'];
46
+ slots(): ReturnType<typeof $$render<T>>['slots'];
47
+ bindings(): "index";
48
+ exports(): {};
49
+ }
50
+ interface $$IsomorphicComponent {
51
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
52
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
53
+ } & ReturnType<__sveltets_Render<T>['exports']>;
54
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
55
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
56
+ }
57
+ declare const Carousel: $$IsomorphicComponent;
58
+ type Carousel<T> = InstanceType<typeof Carousel<T>>;
59
+ export default Carousel;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Clamp a slide index into `[0, count)`, wrapping when `loop` is set.
3
+ *
4
+ * @param {number} index
5
+ * @param {number} count
6
+ * @param {boolean} [loop]
7
+ * @returns {number}
8
+ */
9
+ export function clampSlide(index: number, count: number, loop?: boolean): number;
10
+ /**
11
+ * Step from `current` by `delta` slides. Without `loop` the edges absorb the
12
+ * step, so stepping past the last slide stays on it.
13
+ *
14
+ * @param {number} current
15
+ * @param {number} count
16
+ * @param {number} delta
17
+ * @param {boolean} [loop]
18
+ * @returns {number}
19
+ */
20
+ export function stepSlide(current: number, count: number, delta: number, loop?: boolean): number;
21
+ /**
22
+ * Resolve a Carousel navigation key to the slide it selects. Left/Right step by
23
+ * one (wrapping only with `loop`), Home/End jump to the first/last slide.
24
+ * Unhandled keys return `undefined` so the browser keeps its default.
25
+ *
26
+ * @param {number} current
27
+ * @param {number} count
28
+ * @param {string} key
29
+ * @param {boolean} [loop]
30
+ * @returns {number | undefined}
31
+ */
32
+ export function nextSlideForKey(current: number, count: number, key: string, loop?: boolean): number | undefined;
33
+ /**
34
+ * Interpret a completed pointer gesture as a slide step. A gesture counts as a
35
+ * horizontal swipe only when it travels at least `threshold` px on the x axis
36
+ * and further on x than on y; anything else is left to the page (vertical
37
+ * scroll, taps). Swiping left reveals the next slide, so the result is +1.
38
+ *
39
+ * @param {number} dx
40
+ * @param {number} dy
41
+ * @param {number} [threshold]
42
+ * @returns {-1 | 0 | 1}
43
+ */
44
+ export function swipeStep(dx: number, dy: number, threshold?: number): -1 | 0 | 1;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Clamp a slide index into `[0, count)`, wrapping when `loop` is set.
3
+ *
4
+ * @param {number} index
5
+ * @param {number} count
6
+ * @param {boolean} [loop]
7
+ * @returns {number}
8
+ */
9
+ export function clampSlide(index, count, loop = false) {
10
+ if (count <= 0 || !Number.isFinite(index)) return 0;
11
+ if (loop) return ((Math.trunc(index) % count) + count) % count;
12
+ return Math.min(Math.max(Math.trunc(index), 0), count - 1);
13
+ }
14
+
15
+ /**
16
+ * Step from `current` by `delta` slides. Without `loop` the edges absorb the
17
+ * step, so stepping past the last slide stays on it.
18
+ *
19
+ * @param {number} current
20
+ * @param {number} count
21
+ * @param {number} delta
22
+ * @param {boolean} [loop]
23
+ * @returns {number}
24
+ */
25
+ export function stepSlide(current, count, delta, loop = false) {
26
+ return clampSlide(current + delta, count, loop);
27
+ }
28
+
29
+ /**
30
+ * Resolve a Carousel navigation key to the slide it selects. Left/Right step by
31
+ * one (wrapping only with `loop`), Home/End jump to the first/last slide.
32
+ * Unhandled keys return `undefined` so the browser keeps its default.
33
+ *
34
+ * @param {number} current
35
+ * @param {number} count
36
+ * @param {string} key
37
+ * @param {boolean} [loop]
38
+ * @returns {number | undefined}
39
+ */
40
+ export function nextSlideForKey(current, count, key, loop = false) {
41
+ if (count <= 0) return undefined;
42
+ if (key === 'ArrowRight') return stepSlide(current, count, 1, loop);
43
+ if (key === 'ArrowLeft') return stepSlide(current, count, -1, loop);
44
+ if (key === 'Home') return 0;
45
+ if (key === 'End') return count - 1;
46
+ return undefined;
47
+ }
48
+
49
+ /**
50
+ * Interpret a completed pointer gesture as a slide step. A gesture counts as a
51
+ * horizontal swipe only when it travels at least `threshold` px on the x axis
52
+ * and further on x than on y; anything else is left to the page (vertical
53
+ * scroll, taps). Swiping left reveals the next slide, so the result is +1.
54
+ *
55
+ * @param {number} dx
56
+ * @param {number} dy
57
+ * @param {number} [threshold]
58
+ * @returns {-1 | 0 | 1}
59
+ */
60
+ export function swipeStep(dx, dy, threshold = 40) {
61
+ if (Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return 0;
62
+ return dx < 0 ? 1 : -1;
63
+ }
package/dist/index.d.ts CHANGED
@@ -41,6 +41,7 @@ export { type Attachment, default as AttachmentList, } from './components/molecu
41
41
  export { type BreadcrumbItem, default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
42
42
  export { default as Callout } from './components/molecules/Callout.svelte';
43
43
  export { default as CapBar } from './components/molecules/CapBar.svelte';
44
+ export { default as Carousel } from './components/molecules/Carousel.svelte';
44
45
  export { default as ChatBubble } from './components/molecules/ChatBubble.svelte';
45
46
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
46
47
  export { default as Composer } from './components/molecules/Composer.svelte';
package/dist/index.js CHANGED
@@ -47,6 +47,7 @@ export { default as AttachmentList, } from './components/molecules/AttachmentLis
47
47
  export { default as Breadcrumb, } from './components/molecules/Breadcrumb.svelte';
48
48
  export { default as Callout } from './components/molecules/Callout.svelte';
49
49
  export { default as CapBar } from './components/molecules/CapBar.svelte';
50
+ export { default as Carousel } from './components/molecules/Carousel.svelte';
50
51
  export { default as ChatBubble } from './components/molecules/ChatBubble.svelte';
51
52
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
52
53
  export { default as Composer } from './components/molecules/Composer.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",